capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)

Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MaksTinyWorkshop
2026-05-02 22:12:44 +02:00
parent 02ad0de258
commit b3417ad77b
31 changed files with 5370 additions and 12 deletions

View File

@@ -441,3 +441,181 @@ Checklist minimale après `prisma migrate resolve --applied` :
- Ajouter des tests ciblés sur payload partiel et concurrence logique.
- Contexte technique : Prisma / partition logique — RL799_V2 09-04-2026
---
<a id="risque-capture-pre-updatemany-race-window"></a>
## Capture pré-`updateMany` sans transaction — race window silencieuse
### Risques
- `findUnique` + `updateMany` non atomiques : entre les deux, un autre process peut modifier le champ capturé. L'audit log ment (enregistre une `previousValue` qui n'était plus la valeur courante au moment de l'écriture). Notif envoyée au mauvais target.
- Si l'`updateMany` ne filtre que sur `status` sans inclure la valeur attendue, il peut écraser une nouvelle valeur sans erreur
### Symptômes
```typescript
// ❌ Race window entre les deux requêtes
const before = await prisma.entity.findUnique({ where: { id }, select: { x: true } });
// … un autre process modifie entity.x ici …
const after = await prisma.entity.updateMany({
where: { id, status: 'X' },
data: { status: 'Y', x: null },
});
audit.log({ previousX: before.x }); // ← MENT
notify(before.x); // ← mauvais target
```
### Bonnes pratiques / mitigations
**Solution 1 — `SELECT ... FOR UPDATE` dans une transaction** (cf. `pattern-revocation-atomique-etat-transversal` dans `patterns/prisma.md`) :
```typescript
let previousValue: T | null = null;
await prisma.$transaction(async (tx) => {
const locked = await tx.$queryRaw<Array<{ x: T }>>`
SELECT x FROM "entities" WHERE id = ${id} FOR UPDATE
`;
if (locked.length === 0) return;
previousValue = locked[0].x;
await tx.entity.updateMany({ where: { id, ... }, data: { ... } });
});
```
**Solution 2 — `WHERE` qui inclut la valeur attendue** (CAS-light, sans transaction) :
```typescript
const updated = await prisma.entity.updateMany({
where: { id, status: 'X', x: expectedX }, // ← guard sur la valeur
data: { ... },
});
if (updated.count === 0) {
// soit déjà transitionné, soit x a changé — relire et décider
}
```
### Détecteur mental
Si tu écris :
```typescript
const before = await prisma.X.findUnique(...);
await prisma.X.updateMany(...);
// … tu utilises before.<champ> dans l'audit ou la notif
```
**Stop**. Tu as une race. Soit `before.<champ>` n'a pas changé entre les deux (et alors pourquoi le capturer ?), soit il a pu changer (et tu mens).
- Contexte technique : Prisma / concurrence — RL799_V2 27-04-2026
---
<a id="risque-slugs-metier-user-id"></a>
## Slugs métier comme `User.id` — schémas Zod laxistes obligés
### Risques
- Un `User.id` en `String` libre (slug lisible côté seed, UUID `@default(uuid())` côté invitations) empêche toute rigidification Zod `.uuid()` sur les champs `userId` côté API
- Couplage tests/seed invisible : des dizaines de tests hardcodent `'membre-m05'` côté input, sans contrat explicite. Tout renommage du seed casse la suite en cascade sans warning compilateur
- Drift silencieux : deux populations d'ids coexistent en base, validation impossible à uniformiser
### Symptômes
- `prisma.user.create({ data: { id: '<texte-lisible>', ... } })` dans un fichier de seed
- Schéma Zod avec `z.string().min(1).max(128)` là où on voudrait `z.string().uuid()`
- Test qui référence `userId: 'membre-m05'` en argument d'une requête API
- Commentaire "l'ID n'est pas forcément un UUID, on accepte toute chaîne" → dette déguisée
### Bonnes pratiques / mitigations
- Décider tôt : soit `@default(uuid())` côté Prisma partout, soit IDs structurés documentés avec une regex stricte (`^[a-z]+-[a-z0-9]+$`) publiée dans un helper shared (`isValidUserId`)
- **Ne jamais mélanger** : si le seed utilise des slugs et les comptes produits utilisent des UUIDs, les schémas Zod sont condamnés à être laxistes
- Migration : utiliser un UUID v5 déterministe (`seedUserId(slug)`) — cf. `pattern-uuid-v5-deterministe-seed` dans `patterns/prisma.md`
- 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
---
<a id="risque-prisma-where-relation-every-vide"></a>
## `where: { relation: { every: ... } }` trivialement vrai sur relation vide
### Risques
- La clause `every` sur une relation est **trivialement vraie** quand la relation est vide. Sans coupler avec `some: {}`, on capture aussi les rows qui n'ont aucune entrée liée — risque de purge à tort sur les nouvelles entités
### Symptômes
```typescript
// ❌ Faux : capture aussi les profiles SANS aucune VR liée
const orphans = await prisma.visitorProfile.findMany({
where: {
lastSeenAt: { lt: cutoff },
registrations: { every: { status: 'rejected' } }, // vacuously true si pas de VR
},
});
```
- Test "purge orphelins après 30 j" qui supprime un profile fraîchement créé
- Tests qui passent sur des fixtures avec relations existantes mais cassent dès qu'une entité sans relation est créée
### Bonnes pratiques / mitigations
```typescript
// ✅ Correct : exige au moins une VR liée ET toutes rejected
where: {
lastSeenAt: { lt: cutoff },
registrations: {
some: {}, // au moins une VR existe
every: { status: 'rejected' }, // toutes rejected
},
},
```
**Règle générale** : à chaque fois qu'on cherche « toutes les X de Y sont Z », vérifier si Y peut avoir 0 X. Si oui, ajouter `some: {}` pour exclure le cas vide.
- Contexte technique : Prisma — RL799_V2 01-05-2026
---
<a id="risque-resolvedburl-template-based-testing"></a>
## `resolveDbUrl()` testing template-based — préserver le DSN explicite vers la template
### Risques
- Un helper `resolveDbUrl()` qui force `pathname='/<projet>_test'` quand `NODE_ENV=test` écrase un DSN appelant qui pointe explicitement vers `<projet>_test_template`
- Le bootstrap template (`runPrisma(['db', 'seed'])` en sub-process avec `DB_URL=...test_template + NODE_ENV=test`) écrit dans la mauvaise DB ou échoue avec "database does not exist"
### Symptômes
- `bootstrapTemplate échec "pnpm prisma db seed" (exit 1)`
- Tests vitest échouent ensuite avec `Database <projet>_test does not exist on the database server`
- Sub-process de seed qui logge un DSN différent de celui passé en `env`
### Bonnes pratiques / mitigations
```typescript
const resolveDbUrl = (): string | undefined => {
const url = process.env.DB_URL;
if (!url) return url;
if (process.env.NODE_ENV !== 'test') return url;
try {
const parsed = new URL(url);
// Exception : préserver le DSN si déjà sur la template
// (cas bootstrap migrate/seed, sinon le seed pointe sur <projet>_test inexistante)
if (parsed.pathname === '/<projet>_test_template') {
return url;
}
parsed.pathname = '/<projet>_test';
return parsed.toString();
} catch {
return url;
}
};
```
**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