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

@@ -42,6 +42,29 @@ source_projects: [app-template-resto, app-alexandrie]
- Index DB tenant compte du soft delete
```
### Piège — `include` ne filtre pas `deletedAt` automatiquement
`include: { related: true }` n'applique pas le filtre soft delete sur la relation. Si la relation pointe vers une entité elle-même soft-deletable, le doc caché reste exposé via la relation → fuite systématique.
Mitigations :
- relations to-many : `include: { related: { where: { deletedAt: null } } }`
- relations to-one (Prisma ne supporte pas `where` dans un `include` to-one) : `include: { related: { select: { deletedAt: true, ... } } }` puis filtrer post-query côté repo (`if (entity.related?.deletedAt) entity.related = null`)
Toujours `grep -rn "include.*<relationName>"` après l'ajout d'un soft delete pour identifier les sites à fixer.
### Pattern atomique anti-race delete/restore
```typescript
const result = await prisma.<model>.updateMany({
where: { id, deletedAt: null }, // ou { not: null } pour restore
data: { deletedAt: new Date(), deletedById: actorId },
});
if (result.count === 0) return notFound(); // idempotent, pas de double-audit
```
`updateMany` + `where: { id, deletedAt: null }` permet de transformer un check-then-update non atomique en un update atomique conditionnel — le `count === 0` distingue "déjà supprimé" de "introuvable" sans risque de double effet de bord.
### Checklist
- Filtrage soft delete par défaut
@@ -49,6 +72,7 @@ source_projects: [app-template-resto, app-alexandrie]
- Purge maîtrisée (cron / job)
- Index DB adaptés
- Tests sur cas supprimé / restauré
- Audit des `include` sur les relations soft-deletables
---
@@ -256,3 +280,387 @@ return raw.filter(c => c.isVisible).map(toPublicDto);
// Admin : même repo, filtre différent dans le service admin
return raw.map(toAdminDto); // retourne tout, visible ou non
```
---
<a id="pattern-audit-transactionnel-atomique"></a>
## Pattern : Audit transactionnel — mutation et log dans la même `$transaction`
- Objectif : garantir l'invariant `mutation persistée ⇔ audit log existe` quand l'audit est un livrable métier (pas un simple effet de bord informatif).
- Contexte : opérations sensibles (correction par un délégué hors périmètre habituel, opérations admin, opérations soumises à conformité).
- Quand l'utiliser : tout flux où une mutation sans trace serait inacceptable.
- Quand l'éviter : audits purement informatifs (statistiques d'usage, debug) — fire-and-forget acceptable.
- Avantage :
- rollback automatique si l'audit échoue → pas de mutation orpheline
- aucune divergence possible entre l'état persisté et la trace
- Limites / vigilance :
- une mutation peut désormais échouer pour cause "audit indisponible" → 5xx renvoyé au client (cohérent : on préfère refuser la mutation que la passer sans trace)
- Validé le : 27-04-2026
- Contexte technique : Prisma / NestJS — RL799_V2
### Implémentation
```typescript
type AuditClient = Prisma.TransactionClient | typeof prisma;
export const logActionSync = async (
client: AuditClient,
userId: string,
action: string,
targetType?: string,
targetId?: string,
metadata?: Record<string, unknown>,
) => {
await client.auditLog.create({ data: { userId, action, targetType, targetId, metadata } });
};
await prisma.$transaction(async (tx) => {
await tx.<entity>.update({ where: { id }, data: { ... } });
await logActionSync(tx, userId, '<entity>.<action>', '<entity>', id, { ... });
});
```
### Anti-patterns
- `logAction(...)` (fire-and-forget) après le persist quand l'audit est requis métier
- `logActionSync(prisma, ...)` (hors transaction) après le persist : synchrone mais pas atomique avec la mutation
- `.catch(() => {})` autour de l'audit "pour ne pas casser la mutation"
### Checklist
- [ ] Le helper d'audit accepte un `client: AuditClient` (transaction ou prisma)
- [ ] Mutation et audit dans la même `$transaction`
- [ ] Test d'atomicité : mock `createAuditLog` qui throw → assert rollback (cf. `knowledge/backend/patterns/tests.md`)
---
<a id="pattern-index-unique-partiel-actif"></a>
## Pattern : Index unique partiel Postgres pour invariant "≤ 1 active par X"
- Objectif : enforcer l'invariant "au plus une row active par scope" au niveau base de données plutôt que via un check applicatif vulnérable aux races.
- Contexte : ressources avec un cycle de vie `active → revoked/closed` où l'invariant métier impose une seule active par user/contexte (invitation, mandat d'officier, lock éditeur).
- Quand l'utiliser : dès qu'un check applicatif "≤ 1 active" est nécessaire et que la concurrence est possible.
- Quand l'éviter : si la table n'a pas de colonne `status` discriminante ou si plusieurs rows actives sont métier-acceptables.
- Avantage :
- 2e INSERT concurrente échoue avec contrainte unique violée (P2002) plutôt que de créer un doublon
- défense en profondeur : le check applicatif reste, mais la DB est la dernière ligne
- Limites / vigilance :
- Prisma ne supporte pas les unique partials en `schema.prisma` → ajouter dans la migration SQL brute
- documenter dans la migration : un `prisma format` accidentel pourrait droper l'index
- Validé le : 28-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```sql
-- prisma/migrations/<TS>_xxx/migration.sql
CREATE UNIQUE INDEX invitations_one_active_per_user
ON invitations(user_id) WHERE status = 'active';
```
```typescript
export const revokeAndIssueInvitation = async (input) => {
try {
return await prisma.$transaction(async (tx) => {
await tx.invitation.updateMany({
where: { userId: input.userId, status: 'active' },
data: { status: 'revoked', revokedAt: new Date() },
});
return tx.invitation.create({
data: { userId: input.userId, ..., status: 'active' },
});
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
return { ok: false, reason: 'RACE_CONFLICT' };
}
throw err;
}
};
```
### Checklist
- [ ] Index partiel ajouté dans le SQL brut de la migration
- [ ] Handler `P2002` traduit en code métier (`RACE_CONFLICT`, 409)
- [ ] Test de race : `Promise.all([resend(), resend()])` puis `count({ status: 'active' }) === 1`
- [ ] Commentaire dans la migration : "Prisma ne supporte pas les unique partials en schema, ne pas droper sur `prisma format`"
---
<a id="pattern-uuid-v5-deterministe-seed"></a>
## Pattern : UUID v5 déterministe pour ids de seed
- Objectif : permettre d'écrire `seedUserId('venerable')` côté tests/code tout en garantissant que `User.id` reste un UUID RFC 4122 en base — débloque la rigidification Zod `.uuid()` en aval.
- Contexte : seed Prisma qui crée des entités référencées par slug lisible dans les tests, et qui doivent rester typées strictement côté API.
- Quand l'utiliser : nouveaux seeds OU migration d'un seed historique avec slugs littéraux comme PK.
- Quand l'éviter : si le seed est purement aléatoire (`@default(uuid())`) et qu'aucun test ne référence un user particulier par identifiant.
- Avantage :
- déterminisme : `seedUserId('venerable')` donne toujours le même UUID v5
- type uniforme : tous les `User.id` sont des UUID RFC 4122 → `.uuid()` activable
- lisibilité préservée : le code de tests reste sémantique
- Limites / vigilance :
- le slug ne doit JAMAIS être persisté en clair (mapping explicite `{ id: seedUserId(slug), ...rest }`)
- migration depuis un seed slug existant = chantier en 2 commits (cf. pattern rigidification Zod 2 phases dans `contracts.md`)
- Validé le : 24-04-2026
- Contexte technique : Prisma / uuid v5 — RL799_V2
### Implémentation
```typescript
// packages/shared/src/utils/seedIdentity.ts
import { v5 as uuidv5 } from 'uuid';
// Namespace stable du projet (généré une fois, committé ensuite)
export const SEED_USER_NAMESPACE = '2cd71e75-dd5e-42cc-b9fa-52888c42cc3d';
export const seedUserId = (slug: string): string =>
uuidv5(slug, SEED_USER_NAMESPACE);
```
Côté tests :
- helpers (`TEST_SECRETARY`, `TEST_VENERABLE`) exposent l'UUID résolu : les tests écrivent `TEST_SECRETARY.id`, pas `'secretaire'`
- les users ad-hoc éphémères (créés/supprimés dans le scope d'un test) utilisent `randomUUID()`, pas `seedUserId()` — réservé aux entités seed durables
### Pourquoi pas UUID v4 aléatoire
Le déterminisme est essentiel : il permet aux fixtures E2E de pointer un user précis (`const TRESORIER_ID = seedUserId('tresorier')`) sans lire la base, et garantit la reproductibilité du seed en CI.
---
<a id="pattern-test-invariant-post-seed"></a>
## Pattern : Test d'invariant post-seed
- Objectif : transformer la liste canonique des entités seed en contrat exécutable, détecter immédiatement un drift (slug ajouté hors helper, user oublié, format incohérent).
- Contexte : projet avec un seed structurant (users, configurations système) référencé par les fixtures de tests et les flux E2E.
- Quand l'utiliser : à chaque migration qui modifie la forme d'une entité seed (UUID, format d'id, contraintes).
- Quand l'éviter : seed purement aléatoire et jetable (pas de référence stable depuis les tests).
- Avantage :
- le test devient un contrat lisible du seed, pas une abstraction
- détecte un futur dev qui ajouterait un user via un slug littéral sans `seedUserId()`
- détecte un user oublié ou dupliqué
- Limites / vigilance :
- **nombre exact**, pas "au moins N" : si le seed tronque à 29 au lieu de 30, le test doit échouer
- filtrer explicitement par la liste des slugs connus — ne pas valider "tous les users en base" (résidus possibles)
- Validé le : 24-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
### Implémentation
```typescript
// __tests__/seedInvariants.test.ts
const SEED_SLUGS: readonly string[] = [
'venerable', 'secretaire', /* … 30 slugs … */
];
const EXPECTED_SEED_USER_COUNT = 31;
test('seed invariant: users seed possèdent un UUID déterministe', async () => {
assert.equal(SEED_SLUGS.length, EXPECTED_SEED_USER_COUNT, 'liste figée');
const expectedIds = SEED_SLUGS.map(seedUserId);
const users = await prisma.user.findMany({
where: { id: { in: expectedIds } },
select: { id: true },
});
assert.equal(users.length, EXPECTED_SEED_USER_COUNT);
assert.ok(users.every((u) => isValidUuid(u.id)));
});
```
### Checklist
- [ ] Liste figée des slugs en constante
- [ ] Compte exact (`.equal`, pas `.gte`)
- [ ] Filtrage explicite par la liste (pas de `findMany()` global)
- [ ] Vérification du format de l'id
---
<a id="pattern-check-fail-loud-conditionnee"></a>
## Pattern : Check `RAISE EXCEPTION` conditionnée à la présence de données
- Objectif : préserver la rejouabilité de la migration sur une DB vide (dev `prisma migrate reset`) tout en gardant le fail-loud sur DB peuplée.
- Contexte : migration qui fait un backfill de données existantes et veut échouer si l'admin/owner cible est absent.
- Quand l'utiliser : toute check "fail if missing X" qui protège un backfill, jamais le schéma lui-même.
- Quand l'éviter : check de schéma purement structurel (`NOT NULL`, FK) — ces contraintes appartiennent au DDL, pas à un `RAISE`.
- Avantage :
- DB vide (dev reset) : 0 row à backfiller → check skip propre, migration passe
- DB prod/staging avec données : check conservée, fail-loud comme prévu
- Limites / vigilance :
- une migration doit rester rejouable sur une DB vide ET une DB peuplée — c'est le contrat de `prisma migrate reset`
- Validé le : 21-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Anti-pattern
```sql
-- ❌ Bloque tout migrate reset sur dev (DB vide)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true) THEN
RAISE EXCEPTION 'Migration X requires an admin user.';
END IF;
END $$;
```
### Pattern correct
```sql
-- ✅ Exige admin uniquement s'il y a des données à backfiller
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM cotisation_entries WHERE status = 'paid')
AND NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true)
THEN
RAISE EXCEPTION 'Migration X requires an admin user to backfill existing paid entries.';
END IF;
END $$;
```
---
<a id="pattern-revocation-atomique-etat-transversal"></a>
## Pattern : Révocation atomique d'un état transversal lors d'une transition de cycle
- Objectif : éteindre les champs d'état transversaux (délégation, lock, ownership) dans la **même transaction** que la transition de cycle de vie de l'entité parente.
- Contexte : transitions `close / archive / cancel / soft-delete / lock définitif` d'une entité qui porte un ou plusieurs champs transversaux n'ayant plus de sens dans le nouveau cycle.
- Quand l'utiliser : à chaque transition de cycle où un état transversal devient un "zombie" potentiel (délégation qui survit à la clôture, lock qui survit à l'archivage).
- Quand l'éviter : transitions sans état transversal pertinent (archivage simple).
- Avantage :
- aucun état zombie possible
- la valeur précédente est capturée sous lock → audit et notif fiables (pas de race entre lecture et écriture)
- Limites / vigilance :
- les effets de bord (audit, notif) DOIVENT sortir de la transaction (best-effort, fire-and-forget)
- l'idempotence est gérée par le `WHERE` du `updateMany` (`closedAt: null`) — la 2e tentative ne re-déclenche pas les effets de bord
- Validé le : 27-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```typescript
let previousDelegateeId: string | null = null;
let updateCount = 0;
await prisma.$transaction(async (tx) => {
const lockResult = await tx.$queryRaw<Array<{ delegatee_id: string | null }>>`
SELECT delegatee_id FROM "entities"
WHERE id = ${id} AND closed_at IS NULL
FOR UPDATE
`;
if (lockResult.length === 0) return; // idempotence
previousDelegateeId = lockResult[0].delegatee_id;
const updated = await tx.entity.updateMany({
where: { id, closedAt: null },
data: {
closedAt: now,
closedBy: userId,
delegateeId: null, // ← révocation atomique dans la même tx
},
});
updateCount = updated.count;
});
// Effets de bord HORS de la transaction
if (updateCount > 0 && previousDelegateeId !== null) {
logAction(userId, 'entity:delegation_revoked_on_close', ...);
void notifyDelegatee(previousDelegateeId, ...);
}
```
### Les 4 invariants
1. La révocation vit dans le **même `updateMany`** que la transition principale.
2. La capture de la valeur précédente est sous **`SELECT FOR UPDATE`** dans la transaction.
3. Les effets de bord (audit, notif) **sortent de la transaction**.
4. L'idempotence est gérée par le `WHERE` (`closedAt: null`) — la 2e tentative est un no-op observable.
### Tests minimaux
- Happy path : transition avec valeur transversale présente → champ nullé + audit + notif au bon target
- Sans valeur transversale : pas d'effet de bord (pas d'audit révocation, pas de notif)
- Idempotence : 2e transition retombe en already_closed sans double effet
---
<a id="pattern-migration-destructive-4-phases"></a>
## Pattern : Migration destructive en 4 phases avec sentinelle d'archive
- Objectif : refondre une table avec PK changée ou colonnes incompatibles sans perdre l'audit historique des rows métier importantes.
- Contexte : table dont la PK ou la forme évolue de façon non-rétrocompatible (ex : token en clair → hash SHA-256 stocké, slug → UUID).
- Quand l'utiliser : refonte structurelle où un `ALTER TABLE` patchwork serait fragile (FK multiples, index, contraintes).
- Quand l'éviter : ajout simple de colonne nullable, refactor cosmétique d'index.
- Avantage :
- DROP + CREATE plus sûr qu'un patchwork ALTER quand la PK change
- les rows historiques (`status = 'consumed'`) sont conservées pour audit
- sentinelle d'archive non-collisionnable garantit qu'aucun login ne peut matcher une row archivée
- Limites / vigilance :
- Phase 1 (DELETE des rows non-migrables) impose une communication aux admins pré-deploy
- inspection manuelle obligatoire du SQL généré par `prisma migrate dev --create-only`
- Validé le : 28-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Recette
```sql
-- Phase 1 : invalidation propre des données non-migrables
-- Les tokens en clair ne peuvent pas être convertis en SHA-256 (one-way).
-- Les rows 'consumed' sont conservées pour audit historique.
DELETE FROM invitations WHERE status != 'consumed';
-- Phase 2 : refonte de la table
CREATE TEMP TABLE invitations_archive AS
SELECT email, status, consumed_at FROM invitations WHERE status = 'consumed';
DROP TABLE invitations CASCADE;
CREATE TABLE invitations (
id TEXT NOT NULL DEFAULT gen_random_uuid()::text PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
...
);
CREATE UNIQUE INDEX invitations_one_active_per_user
ON invitations(user_id) WHERE status = 'active';
-- Phase 3 : restauration des rows consommées avec sentinelle non-collisionnable
-- '_legacy_' n'est jamais produit par crypto.randomBytes(32).toString('hex')
INSERT INTO invitations (id, user_id, token_hash, email, status, consumed_at)
SELECT
gen_random_uuid()::text,
u.id,
'_legacy_' || gen_random_uuid()::text,
a.email,
'consumed',
a.consumed_at
FROM invitations_archive a
JOIN users u ON u.email = a.email;
DROP TABLE invitations_archive;
-- Phase 4 : drop des colonnes obsolètes sur d'autres tables
ALTER TABLE users DROP COLUMN IF EXISTS must_change_password;
```
Côté repository, filtrer la sentinelle :
```typescript
const LEGACY_TOKEN_HASH_PREFIX = '_legacy_';
export const findInvitationByTokenHash = async (tokenHash: string) => {
if (tokenHash.startsWith(LEGACY_TOKEN_HASH_PREFIX)) return null;
// … lookup normal
};
```
### Checklist
- [ ] Phase 1 communiquée aux admins pré-deploy si tokens actifs en cours
- [ ] Phase 2 préfère `DROP + CREATE` quand la PK change
- [ ] Phase 3 utilise un préfixe **garanti non-collisionnable** par construction cryptographique
- [ ] Idempotence (`IF EXISTS` / `IF NOT EXISTS`) sur les changements réversibles
- [ ] Procédure rollback documentée (`pg_dump` avant migration)
- [ ] Smoke test post-deploy (login, création, magic link)