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
+253 -4
View File
@@ -177,7 +177,34 @@ Tout modèle tenant-scoped doit avoir les trois :
- **Checklist review** : vérifier systématiquement que les nouveaux modèles respectent ce guardrail
- Contexte technique : Prisma / multi-tenant — app-template-resto 17-03-2026
### Règle générale — toute FK doit déclarer sa relation Prisma des DEUX côtés
Le piège n'est pas spécifique à `tenantId`. Tout `xxxId String @map(...)` sans `@relation` correspondante (côté table cible **et** côté table référencée) ne génère **aucune FK SQL**. Prisma ne le détecte pas — il faut un check humain à la review de schéma.
```prisma
// ❌ pas de @relation → pas de FK générée → orphelins possibles
model DmConversation {
userAId String @map("user_a_id")
}
model User { /* pas de field DmConversation[] */ }
// ✅ relation déclarée des deux côtés → FK générée
model User {
dmConversationsAsA DmConversation[] @relation("DmConversationUserA")
dmConversationsAsB DmConversation[] @relation("DmConversationUserB")
}
model DmConversation {
userAId String @map("user_a_id")
userBId String @map("user_b_id")
userA User @relation("DmConversationUserA", fields: [userAId], references: [id], onDelete: Cascade)
userB User @relation("DmConversationUserB", fields: [userBId], references: [id], onDelete: Cascade)
}
```
- **Critère review** : tout `xxxId String @map(...)` (y compris les paires de tables de jointure `userAId`/`userBId`) DOIT avoir sa `@relation` paire des deux côtés.
- **Bonus index** : Postgres n'indexe pas automatiquement la colonne porteuse de la FK. Dès qu'on filtre dessus (`updateMany({ where: { senderId } })`, purges admin, dashboards), ajouter un `@@index` dédié sur cette colonne — un index composite `(conversation_id, created_at, id)` ne couvre pas un filtre par `sender_id` seul (seq scan sinon).
- Contexte technique : Prisma / multi-tenant — app-template-resto 17-03-2026 ; app-alexandrie 13-05-2026
---
@@ -314,7 +341,29 @@ if (cursor) {
- **Règle** : ajouter un test unitaire "cursor invalide → 400" sur tout endpoint paginé par cursor
- Contexte technique : NestJS / pagination — app-alexandrie 24-03-2026
### Valider chaque champ typé du cursor décodé, pas seulement sa structure
Le décodage JSON valide la **structure** (présence des clés) mais pas le **format** des champs typés. Un attaquant peut forger `{"createdAt":"garbage","id":"x"}` : `JSON.parse` réussit → `new Date('garbage') = Invalid Date` → Prisma renvoie un 500 au lieu d'un 400 propre.
```ts
let decoded: { createdAt: string; id: string } | null = null;
if (cursor) {
try {
decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString());
if (!decoded.createdAt || !decoded.id) throw new Error('Champs manquants');
// ✅ valider la convertibilité de chaque champ consommé par Prisma
if (Number.isNaN(new Date(decoded.createdAt).getTime()))
throw new Error('createdAt invalide');
// (id UUID : check regex si requis)
} catch {
throw new BadRequestException({ error: { code: 'INVALID_CURSOR', message: '…' } });
}
}
```
- **Règle** : pour chaque champ du cursor décodé consommé par Prisma (`new Date()`, `BigInt()`, etc.), valider explicitement la convertibilité avant la query, sinon l'erreur fuit en 500.
- Contexte technique : NestJS / pagination — app-alexandrie 24-03-2026 ; app-alexandrie 28-05-2026
---
@@ -534,7 +583,17 @@ await prisma.X.updateMany(...);
- Test d'invariant post-seed obligatoire (cf. pattern dédié)
- Si migration en cours de route : prévoir un script qui propage sur **toutes** les FKs (`audit_logs.user_id`, `notifications.recipient_id`, `refresh_tokens.user_id`, etc.)
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026
### Sous-règle — seed Prisma + contracts Zod : `id` auto-généré + validation Zod sortante
Tout modèle Prisma référencé dans un schéma Zod par `z.string().uuid()` (ex: `DmConversation.id`, `User.id` exposé en `peerUserId`) doit avoir un `id` **auto-généré** par Prisma (`@id @default(uuid())`) dans le seed. **Jamais d'ID lisible** type `seed-user-alice` sur ces modèles : la validation Zod **sortante** les rejette.
Le piège est silencieux : `prisma.user.create({ data: { id: 'seed-alice' } })` ne plante pas (Postgres ne valide pas le format UUID sur une colonne `text`/`varchar`), mais l'endpoint de listing renvoie un HTTP 400 (`fieldErrors.items: ["Invalid UUID"]`) après `ZodValidationPipe` sur la **response**. Invisible si les e2e mockent `PrismaClient` (les UUID auto-générés sont remplacés par des stubs) — visible uniquement à l'usage réel (mobile/curl).
- Fixtures : référencer les entités par une **`key` logique stable** (`alice`, `alice-bob`), pas par `id` ; construire un `Map<key, uuid>` après insertion et le propager aux fixtures dépendantes.
- Les modèles dont le contract n'exige pas un UUID (Thread, Comment, Mention) peuvent garder des IDs lisibles si utile à la lisibilité des fixtures.
- **Test de non-régression** : tout endpoint de listing qui validate sa response via `ZodValidationPipe` + fixture seed doit être testé via un e2e **DB-based** (non mocké) qui hit l'endpoint réel.
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026 ; app-alexandrie 26-05-2026
---
@@ -618,7 +677,15 @@ const resolveDbUrl = (): string | undefined => {
**Règle générale** : toute stratégie template-based doit auditer le chemin du `DB_URL` à travers les sub-processes de bootstrap. Le bootstrap ouvre une connexion sur la template, mais le seed transitif exécuté via un sub-process peut être sujet à des transformations agressives du DSN qui le redirigent ailleurs.
- Contexte technique : Prisma / template database / Vitest — RL799_V2 01-05-2026
### Après TOUTE nouvelle migration : droper le template DB de test avant de re-run
Un template DB construit une fois (`migrate + seed`) puis cloné par worker (`bootstrapTemplate.ensureTemplateReady` / `globalSetup`) est **réutilisé tel quel s'il existe** — il ne détecte PAS qu'une nouvelle migration est apparue. Symptôme trompeur : juste après avoir ajouté une migration `ADD COLUMN`, les tests échouent en `column "x" does not exist` (`PrismaClientKnownRequestError`) alors que `prisma migrate status` dit « up to date » sur la DB dev et que le schema est correct. Cause : le template de test est resté sur l'ancien schéma.
- **Fix** : droper le template avant la 1ʳᵉ exécution → le `globalSetup` le recrée from scratch (migrate deploy + seed) avec la colonne. Si `psql` indisponible, via client `pg` : `DROP DATABASE <template> WITH (FORCE)`.
- **À automatiser idéalement** : faire dépendre la validité du template d'un **hash du dossier `migrations/`** (re-build si le hash change).
- Note Prisma 7.x : garde-fou anti-IA sur les actions destructives (`prisma migrate reset` exige un consentement explicite). La cohabitation migration+seed se prouve via le rebuild du template de test, pas besoin de reset la DB dev.
- Contexte technique : Prisma / template database / Vitest — RL799_V2 01-05-2026 ; RL799_V2 14-06-2026
---
@@ -649,3 +716,185 @@ CREATE INDEX "users_deleted_at_idx" ON "users"("deleted_at")
- **Règle** : pour une colonne soft-delete nullable à majorité `NULL`, préférer un index partial `WHERE deleted_at IS NOT NULL`.
- Contexte technique : Prisma / PostgreSQL / index partial — app-alexandrie 13-04-2026
---
<a id="risque-index-partiel-text-alter-enum"></a>
## Index partiels avec littéraux text — rejettent `ALTER COLUMN String → enum`
### Risques
- Une migration de conversion `String → enum` plante au `ALTER TABLE ... ALTER COLUMN ... TYPE "<Enum>" USING ...` à cause d'un index partiel **historique** dont la clause `WHERE` contient un littéral text.
- **Le pré-scan des valeurs DB ne détecte PAS ce piège** : une migration peut passer le pré-scan des orphelins et planter quand même. Coût d'oubli : rollback en urgence + reset du template DB de tests.
### Symptômes
```
ERROR: operator does not exist: "<EnumName>" = text
HINT: No operator matches the given name and argument types.
```
Apparaît au moment de l'`ALTER COLUMN ... TYPE`. La migration est rollée back atomiquement par Postgres (pas d'état hybride), mais bloque le déploiement.
### Cause racine
Une migration historique a créé un index partiel avec un **littéral text** dans le `WHERE` :
```sql
CREATE UNIQUE INDEX my_index ON table(col) WHERE status = 'active';
```
Quand `status` passe de `text` à `enum`, le littéral `'active'` reste **typé text** → Postgres refuse la conversion car l'opérateur `enum = text` n'est pas défini.
### Bonnes pratiques / mitigations
Encadrer la conversion par DROP/CREATE de l'index (le `CREATE` post-conversion typera automatiquement le littéral en enum) :
```sql
DROP INDEX IF EXISTS "my_index";
ALTER TABLE "table" ALTER COLUMN "status" TYPE "MyEnum" USING "status"::"MyEnum";
CREATE UNIQUE INDEX "my_index" ON "table"("col") WHERE "status" = 'active';
```
**Détection préventive** — avant toute migration enum, grep les index partiels littéraux :
```bash
grep -rn "WHERE.*=.*'" prisma/migrations --include="*.sql" | grep -v "DELETE\|UPDATE"
```
Tout `WHERE col = 'literal'` touchant une colonne candidate à conversion doit être ajouté au DROP/CREATE de la migration. Risque compagnon du pattern `pattern-migration-string-int-enum-sans-downtime` dans `patterns/prisma.md`.
- Contexte technique : Prisma / PostgreSQL — RL799_V2 05-05-2026
---
<a id="risque-colonne-prisma-jamais-ecrite"></a>
## Colonnes Prisma jamais écrites (placeholder / i18n côté contracts)
### Risques
- Un champ ajouté à un modèle Prisma "au cas où" mais que le code projette systématiquement depuis une constante en mémoire (côté contracts/schemas) → colonne morte : dette schéma silencieuse, writes ralentis par un index inutile, confusion future ("à quoi sert-elle ?").
### Symptômes
- Migration `ADD COLUMN "placeholder_label" TEXT;`
- Service : `placeholderLabel: isAutoHidden ? AUTO_HIDE_PLACEHOLDER_LABEL : null` (constante de contracts)
- Aucun `update({ data: { placeholderLabel: ... } })` dans le codebase ; colonne toujours NULL en pratique
### Bonnes pratiques / mitigations
Avant d'ajouter une colonne destinée à porter un libellé ou un texte localisable :
1. Besoin réel `(global, immuable)`**constante côté contracts**, pas de colonne.
2. Besoin `(par-row, configurable plus tard)` → colonne **+** endpoint admin pour la peupler **dès la story** qui l'introduit.
3. Jamais "j'ajoute la colonne au cas où une story future en aurait besoin" → YAGNI.
- **Signal review** : si une colonne du schema n'apparaît dans **aucun** `.create` / `.update` / `.upsert` du codebase, c'est probablement une colonne morte.
- Contexte technique : Prisma / schema — app-alexandrie 05-05-2026
---
<a id="risque-read-then-write-transition-one-shot"></a>
## Read-then-write sur invariant d'unicité / transition one-shot — race condition
### Risques
- Vérifier une condition par un `findUnique`/`findFirst`/`SELECT` puis agir par un `update` séparé n'est **pas atomique** sous concurrence. Sous `READ COMMITTED` (défaut Prisma/Postgres), deux requêtes concurrentes passent toutes deux la garde en mémoire avant tout update → double consommation (usage-unique) ou double transition (machine à états "irréversible" devenue ré-écrasable).
- Le check applicatif ne protège PAS contre la concurrence (double-clic, retry, multi-onglets).
### Symptômes
- Deux ressources créées pour un seul code/ticket usage-unique (2 `UserPack` pour 1 code).
- Une transition `open → settled` (ou `draft → published`, `pending → approved`) appliquée deux fois : test `Promise.all([settle(), settle()])` prouve `[200, 200]` au lieu de `[200, 409]`.
### Bonnes pratiques / mitigations
Porter la garde **dans l'écriture** : `updateMany` conditionnel atomique + test de `count`. Le verrou de ligne sérialise les transactions concurrentes ; le perdant voit `count === 0`.
```typescript
// ✅ Consommation usage-unique
const { count } = await tx.code.updateMany({
where: { id, consumedBy: null }, // garde DANS le WHERE
data: { consumedBy: userId },
});
if (count === 0) throw new ConflictException('ALREADY_USED');
// ✅ Transition one-shot
const { count } = await prisma.proposal.updateMany({
where: { id, status: 'open' },
data: { status: 'settled', ... },
});
if (count === 0) /* perdant de la course → 409 */;
```
- La garde en mémoire reste utile en **fail-fast** (évite un round-trip si déjà transité à la lecture), mais ce n'est plus elle qui garantit l'unicité.
- Alternative : `isolationLevel: 'Serializable'` + retry, mais l'`updateMany` gardé est plus simple.
- **Test obligatoire** : deux opérations concurrentes (`Promise.all`) → exactement une réussit.
- Contexte technique : Prisma / Postgres / concurrence — app-alexandrie 02-06-2026 ; RL799_V2 (settle proposition d'instruction)
---
<a id="risque-unique-plus-index-redondant"></a>
## `@@unique` + `@@index` sur la même colonne — index redondant en Postgres
### Risques
- Déclarer `@@unique([col])` ET `@@index([col])` (ou `@unique` + `@@index`) sur la même colonne génère **deux** construits SQL : un `CREATE UNIQUE INDEX` et un `CREATE INDEX` normal. Postgres utilise l'index unique pour les lookups — le second ne sert à rien, consomme de l'espace et ralentit toutes les écritures (chaque write tient les deux index à jour).
### Symptômes
- Migration générée avec un `CREATE UNIQUE INDEX` et un `CREATE INDEX` sur la même colonne (ex: `season_reports.season_id`).
### Bonnes pratiques / mitigations
- N'ajouter `@@index` que sur des colonnes qui **ne sont pas déjà couvertes** par `@@unique`/`@unique`.
- Correction : supprimer `@@index([col])` et générer une migration `DROP INDEX IF EXISTS`.
- Contexte technique : Prisma / PostgreSQL — RL799_V2 14-06-2026
---
<a id="risque-delete-row-fin-transaction-anonymisation"></a>
## DELETE row à la fin d'une transaction d'anonymisation
### Risques
- Dans une transaction qui clôture un cycle métier sensible (admission, archivage, anonymisation RGPD) et DELETE une row "pivot" pour purger ses dépendances en cascade (`ON DELETE CASCADE`), placer le DELETE **en milieu** de transaction casse les opérations suivantes :
- audit log, projection DTO de retour, side-effects référencent un id qui n'existe plus → `RecordNotFound` ou retour `null` ;
- les requêtes post-DELETE peuvent retomber sur un état pré-CASCADE ou retourner des rows liées zombies.
### Symptômes
```typescript
await prisma.$transaction(async (tx) => {
const row = await tx.profane.findUnique({ where: { id } });
await tx.profane.delete({ where: { id } }); // ← TROP TÔT
await logActionSync(tx, 'enquete:admitted', 'Profane', id, { ... }); // référence un id supprimé
return tx.user.create({ data: { ... } });
});
```
### Bonnes pratiques / mitigations
DELETE = **dernière opération** de la transaction. Tout ce qui doit lire ou auditer la row se fait avant. Les side-effects post-commit (notifs, `fs.rm`) utilisent des données **capturées avant** le DELETE.
```typescript
await prisma.$transaction(async (tx) => {
const row = await tx.profane.findUnique({ where: { id }, include: { rapports: true } });
// 1. Lectures, audits, créations dérivées
await logActionSync(tx, 'enquete:admitted_purge', 'Profane', id, { profaneId: id, nbRapports: row.rapports.length });
const newUser = await tx.user.create({ data: { email: row.email, ... } });
// 2. DELETE EN DERNIER (CASCADE balaie Enquete + Rapports + …)
await tx.profane.delete({ where: { id } });
return newUser;
});
// Post-commit (hors tx) : fs.rm uploads/enquetes/{enqueteId} en best-effort, sur les paths capturés avant
```
- **DELETE vs SET NULL** : DELETE si la row n'a plus aucune valeur métier post-cycle ; SET NULL/anonymize si la row doit rester pour des liens entrants (ex. `RapportEnquete.enqueteurId = null` quand un enquêteur est remplacé — le rapport reste consultable, le lien à l'auteur est anonymisé).
- Toujours capturer les `fileUrl`/`path` **avant** le DELETE pour permettre un `fs.rm` post-commit.
- Audit log **avant** DELETE — sinon le `targetId` référence une row inexistante.
- Contexte technique : Prisma / transactions — RL799_V2 05-05-2026