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>
23 KiB
Backend — Risques & vigilance : Prisma
Extrait de la base de connaissance Lead_tech. Voir
knowledge/backend/risques/README.mdpour l'index complet.
PostgreSQL / Prisma : @unique sur champ nullable (idempotence cassée)
Risques
- Doublons en base malgré un "unique" attendu (PostgreSQL autorise plusieurs
NULLdans un index UNIQUE) - Upserts non idempotents si la clé peut être
null(where: { externalId: null }crée plusieurs lignes)
Symptômes
- Plusieurs enregistrements "équivalents" avec
externalId = NULL - Rejouer un webhook / retry réseau crée une nouvelle ligne au lieu d'upsert
Bonnes pratiques / mitigations
- Toute clé utilisée dans un
whered'upsertdoit être non-nullable - Si un identifiant externe peut légitimement être
null, ne pas l'utiliser comme clé d'idempotence : choisir une autre clé unique non-nullable
Prisma $transaction : fenêtres TOCTOU (check hors transaction)
Risques
- Un pre-check + une
$transactionavec unupdatenon sécurisé crée une fenêtre TOCTOU - Deux appels concurrents peuvent tous deux passer le check et agir simultanément
- En multi-tenant : un bug upstream peut permettre une écriture cross-tenant malgré le guard applicatif
Symptômes
- Double action sur un état booléen (ex : double mise en vitrine) si le check n'est pas dans la transaction
- Écriture sur une ressource d'un autre tenant possible en race condition
Bonnes pratiques / mitigations
Cas 1 — Multi-tenant : inclure tenantId dans chaque écriture
// ❌ Anti-pattern — check OK mais écriture sans tenantId
const existing = await prisma.item.findMany({ where: { id: { in: ids }, tenantId } });
await prisma.$transaction(
ids.map((id, idx) => prisma.item.update({ where: { id }, data: { sortOrder: idx + 1 } }))
);
// ✅ Défense en profondeur — tenantId dans chaque écriture
await prisma.$transaction(
ids.map((id, idx) => prisma.item.updateMany({ where: { id, tenantId }, data: { sortOrder: idx + 1 } }))
);
- Règle : toute écriture Prisma sur une ressource tenant-aware doit inclure
tenantIddans le WHERE, même dans une transaction précédée d'un check - Utiliser
updateMany/deleteManypour incluretenantIdsans exception si 0 lignes
Cas 2 — Idempotence / plafond : re-check d'état à l'intérieur de la transaction
// ❌ Anti-pattern : check d'état hors transaction
if (resource.isActive) throw ...;
await prisma.$transaction(async (tx) => {
// resource.isActive a pu changer entre-temps
return tx.resource.update(...);
});
// ✅ Pattern correct : check ET update dans la transaction
await prisma.$transaction(async (tx) => {
const current = await tx.resource.findUnique({ where: { id } });
if (current?.isActive) throw ...; // re-check atomique
const count = await tx.resource.count(...);
if (count >= LIMIT) throw ...;
return tx.resource.update(...);
});
-
Règle : tout guard métier de type "déjà fait / plafond atteint" doit être vérifié à l'intérieur de la transaction, pas avant
-
Contexte technique : Prisma / multi-tenant — app-template-resto 21-03-2026 ; NestJS / Prisma — app-alexandrie 23-03-2026
Prisma OR multi-tenant : tenantId: null manquant sur la branche système
Risques
- Sur un modèle à
tenantIdnullable distinguant ressources "système" et "tenant", un filtre{ isSystem: true }sanstenantId: nullexpose des ressources corrompues à tous les tenants
Symptômes
- Un tag
isSystem: trueavectenantIdnon-null est exposé à tous les tenants - Bug de sécurité difficile à détecter car le comportement nominal semble correct
Bonnes pratiques / mitigations
// ❌ Trop permissif
OR: [{ isSystem: true }, { tenantId, isSystem: false }]
// ✅ Défense en profondeur — double condition sur la branche système
OR: [{ isSystem: true, tenantId: null }, { tenantId, isSystem: false }]
-
Règle : sur tout modèle
tenantId?(nullable) + flagisSystem/isGlobal/isPublic, la branche "ressource publique" du filtre OR doit toujours incluretenantId: null -
Contexte technique : Prisma / multi-tenant — app-template-resto 21-03-2026
Calcul de nextOrder hors transaction (race condition sortOrder)
Risques
- Deux requêtes concurrentes obtiennent le même
MAX(sortOrder)et créent deux entités avec le mêmesortOrder
Symptômes
- Deux items avec le même
sortOrderdans la même catégorie/scope - Bug aléatoire selon la charge — invisible en dev, présent en prod
Bonnes pratiques / mitigations
// ✅ Calcul dans la transaction interactive
return prisma.$transaction(async (tx) => {
const maxOrder = await tx.entity.aggregate({
where: { tenantId, scopeId },
_max: { sortOrder: true },
});
const nextOrder = (maxOrder._max.sortOrder ?? 0) + 1;
return tx.entity.create({ data: { ..., sortOrder: nextOrder } });
});
-
Règle : ne jamais calculer
maxOrderhors de la transaction qui crée l'entité -
Contexte technique : Prisma / transactions — app-template-resto 21-03-2026
Champ tenantId sans FK ni relation Prisma vers Tenant
Risques
- Un
tenantId TEXT NOT NULLsans relation Prisma ne génère aucune FK en DB - L'isolation multi-tenant n'est pas enforced au niveau base de données
Symptômes
- Migration SQL sans
ALTER TABLE ... ADD CONSTRAINT ... REFERENCES "tenants" - Prisma ne génère pas de FK automatiquement sans
@relationdéclarée
Bonnes pratiques / mitigations
Tout modèle tenant-scoped doit avoir les trois :
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)dans le modèle Prisma- La relation inverse dans
Tenant(ex:menuCategories MenuCategory[]) - La FK correspondante dans la migration SQL
-
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
Divergence schéma Prisma / spec story (champ déclaré ✅ mais absent)
Risques
- Une tâche de story cochée ✅ implique un champ (ex:
consumedAt,tokenHash) qui n'existe pas dansschema.prisma - Le code compile ou passe en review sans que le champ soit réellement présent en DB
Symptômes
- Erreur à l'exécution sur un champ inexistant malgré une story marquée "done"
schema.prismane contient pas le champ mentionné dans les tâches
Bonnes pratiques / mitigations
-
Avant de marquer une tâche ✅, croiser avec
schema.prismapour confirmer que le champ existe réellement -
Une story peut décrire un champ comme stratégie de conception sans l'avoir intégré — toujours vérifier
-
Contexte technique : Prisma / app-template-resto — 16-03-2026
PrismaService — getter explicite manquant sur nouveau modèle
Risques
- L'ajout d'un modèle dans
schema.prismasans son getter dansPrismaServicecasse le typecheck - Erreur silencieuse si les modules sont peu typés
Symptômes
Property 'forum' does not exist on type 'PrismaService'à la compilation- Module fonctionnel sur le
PrismaClientdirect mais cassé viaPrismaService
Bonnes pratiques / mitigations
Tout ajout de modèle Prisma = deux actions :
- Ajouter le modèle dans
schema.prisma - Ajouter le getter dans
prisma.service.ts
// apps/api/src/infra/prisma/prisma.service.ts
get forum() {
return this.client.forum;
}
- Checklist review : à chaque nouvelle migration Prisma, vérifier que
prisma.service.tsest mis à jour. - Contexte technique : NestJS / PrismaService encapsulé — app-alexandrie 20-03-2026
Prisma initialisé au chargement de module — casse le build Next.js
Risques
- Un import global qui initialise Prisma immédiatement peut faire échouer la collecte de pages/routes au build si
DATABASE_URLn'est pas disponible dans l'environnement de build
Symptômes
PrismaClientInitializationErrorouError: Environment variable not found: DATABASE_URLaunext build- L'app tourne en dev mais le build CI échoue
Bonnes pratiques / mitigations
-
Préférer une initialisation lazy-safe : retarder l'accès DB au moment de l'appel métier
-
Retourner un proxy qui lève une erreur claire uniquement lors du premier accès réel à la DB
-
Ne jamais instancier
new PrismaClient()au top-level d'un module importé par Next.js -
Contexte technique : Next.js App Router / Prisma — app-template-resto 16-03-2026
jest.clearAllMocks() dans des beforeEach imbriqués avec mocks Prisma
Risques
- Remise à zéro d'un setup attendu par un scope de test plus profond
- Tests verts ou rouges pour de mauvaises raisons
- Forte difficulté à comprendre l'état réel des mocks
Symptômes
- Comportement différent selon l'ordre ou le niveau d'imbrication des
describe - Mocks Prisma "perdus" entre deux tests
- Corrections locales qui cassent d'autres blocs de tests
Bonnes pratiques / mitigations
- Centraliser la stratégie de reset des mocks
- Éviter les
clearAllMocks()concurrents à plusieurs niveaux de nesting - Préférer un setup explicite et local par scénario quand les mocks Prisma sont structurants
- Contexte technique : Jest / Prisma / tests NestJS — 10-03-2026
Cursor de pagination opaque — validation manquante (500 au lieu de 400)
Risques
- Un cursor base64url+JSON non validé crash en HTTP 500 si malformé ou corrompu
- Exposé à des attaques par input malveillant sur les endpoints paginés publics ou semi-publics
Symptômes
JSON.parseou décodage base64 lève une exception non catchée → 500 en prod- Les logs montrent une stack trace sur un endpoint paginé avec un cursor externe
Bonnes pratiques / mitigations
// ❌ DANGEREUX — crash 500 si cursor corrompu
const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString());
// ✅ CORRECT — validation avec code d'erreur sémantique
let decoded = null;
if (cursor) {
try {
decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString());
if (!decoded.createdAt || !decoded.id) throw new Error('Champs manquants');
} catch {
throw new BadRequestException({ error: { code: 'INVALID_CURSOR', message: 'Cursor de pagination invalide.' } });
}
}
-
Règle : ajouter un test unitaire "cursor invalide → 400" sur tout endpoint paginé par cursor
-
Contexte technique : NestJS / pagination — app-alexandrie 24-03-2026
Champ enum-like stocké en String Prisma — perte de contrainte DB et typage dégradé
Risques
- Aucune contrainte en base sur les valeurs acceptées — insertion de valeurs invalides possible sans erreur DB.
- Cast manuel
as EnumTypedans le service masque l'absence de validation Prisma.
Symptômes
as SomeEnumdans un service ou repository sur un champ qui provient de la DB- Getter
get model(): anydans PrismaService pour contourner le typage
Bonnes pratiques / mitigations
- Tout champ à valeurs finies doit être déclaré avec un
enumPrisma dès la création du modèle — jamais enString. - Si un modèle existant utilise
String, créer une migration de conversion :ALTER COLUMN ... TYPE "EnumType" USING ...::"EnumType". - Signal review : tout cast
as EnumTypesur une valeur issue de Prisma = dette à corriger immédiatement.
- Contexte technique : Prisma / PostgreSQL — app-alexandrie 31-03-2026
Migration appliquée manuellement hors git
Risques
- Une migration Prisma appliquée via DDL direct +
migrate resolve --appliedproduit un fichiermigration.sqlqui peut rester untracked dans git - Quiconque clone le repo et lance
prisma migrate deployn'a pas la migration
Symptômes
??(untracked) dansgit statussur le dossierprisma/migrations/prisma migrate statusrapporte un drift entre le schéma et l'état de la DB
Bonnes pratiques / mitigations
Checklist minimale après prisma migrate resolve --applied :
-
git status→ vérifier queprisma/migrations/<nom>/migration.sqlest présent et tracké -
git add prisma/migrations/<nom>/si untracked -
Valider que
prisma migrate statusrapporte la migration comme appliquée sans drift -
Contexte technique : Prisma / migrations manuelles — RL799_V2 02-04-2026
Relation 1:1 métier sans contrainte @unique en DB
Risques
- Un mapping métier 1:1 (ex: planche tracée → document généré) implémenté avec un simple index +
findFirstcrée un risque de backlinks non déterministes en cas d'incohérence de données
Symptômes
- Champ de référence nullable seulement indexé, puis lecture via
findFirst - Le code suppose l'unicité mais la base ne l'impose pas
Bonnes pratiques / mitigations
-
Poser une contrainte
@unique(nullable) sur la référence quand la relation métier est 1:1 -
Préférer
findUnique/ lecture déterministe -
Signal review : si le code suppose l'unicité, la base doit l'imposer explicitement
-
Contexte technique : Prisma / contraintes DB — RL799_V2 06-04-2026
Catch-all silencieux dans les repositories Prisma
Risques
- Un
try { prisma.update(...); return true } catch { return false }dans un repository masque TOUTES les erreurs Prisma (connexion perdue, timeout, contrainte violée) derrière une réponse "not found" - Le service appelant ne peut pas distinguer un échec technique d'une absence de données
Symptômes
- Le service retourne 404 alors que la DB est down, ou 404 alors qu'une contrainte FK est violée
- Le monitoring ne voit aucune erreur 500
Bonnes pratiques / mitigations
-
Dans les repositories Prisma, catcher uniquement
Prisma.PrismaClientKnownRequestErroravec le code spécifique attendu (P2025pour record not found,P2002pour unique constraint) -
Re-throw toute autre erreur pour qu'elle remonte en 500 dans le handler
-
Signal review :
catch {oucatch (e) {sans vérification dee.codedans un repository Prisma -
Contexte technique : Prisma / error handling — RL799_V2 08-04-2026
Filtre de lecture appliqué mais filtre d'écriture oublié
Risques
- Mutation (
updateMany/deleteMany) affectant des lignes hors périmètre autorisé.
Symptômes
- Le listing semble correct, mais les opérations d'écriture touchent des données invisibles pour l'utilisateur.
Bonnes pratiques / mitigations
-
Aligner strictement les prédicats lecture/écriture sur les mêmes dimensions métier (ex: grade, tenant, statut).
-
Factoriser le filtre dans un helper partagé côté service/repository.
-
Contexte technique : Prisma / filtres métier — RL799_V2 09-04-2026
deleteMany partiel sans clé de partition métier
Risques
- Suppression transversale de données d'autres partitions (ex: grade, segment, scope logique).
Symptômes
- Comportement correct tant que le frontend envoie un payload complet, puis corruption lors d'un refactor/concurrence.
Bonnes pratiques / mitigations
-
Inclure toutes les dimensions de partition dans les clauses
deleteMany/updateMany. -
Ajouter des tests ciblés sur payload partiel et concurrence logique.
-
Contexte technique : Prisma / partition logique — RL799_V2 09-04-2026
Capture pré-updateMany sans transaction — race window silencieuse
Risques
findUnique+updateManynon atomiques : entre les deux, un autre process peut modifier le champ capturé. L'audit log ment (enregistre unepreviousValuequi n'était plus la valeur courante au moment de l'écriture). Notif envoyée au mauvais target.- Si l'
updateManyne filtre que surstatussans inclure la valeur attendue, il peut écraser une nouvelle valeur sans erreur
Symptômes
// ❌ 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) :
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) :
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 :
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
Slugs métier comme User.id — schémas Zod laxistes obligés
Risques
- Un
User.idenStringlibre (slug lisible côté seed, UUID@default(uuid())côté invitations) empêche toute rigidification Zod.uuid()sur les champsuserIdcô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 voudraitz.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-seeddanspatterns/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
where: { relation: { every: ... } } trivialement vrai sur relation vide
Risques
- La clause
everysur une relation est trivialement vraie quand la relation est vide. Sans coupler avecsome: {}, on capture aussi les rows qui n'ont aucune entrée liée — risque de purge à tort sur les nouvelles entités
Symptômes
// ❌ 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
// ✅ 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
resolveDbUrl() testing template-based — préserver le DSN explicite vers la template
Risques
- Un helper
resolveDbUrl()qui forcepathname='/<projet>_test'quandNODE_ENV=testécrase un DSN appelant qui pointe explicitement vers<projet>_test_template - Le bootstrap template (
runPrisma(['db', 'seed'])en sub-process avecDB_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
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