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>
39 KiB
title: Backend — Risques & vigilance : Prisma domain: backend bucket: risques tags: [prisma, transactions, tenant, schema, race-condition, index, soft-delete, performance] applies_to: [implementation, review, debug, architecture] severity: high validated_on: 2026-06-25 source_projects: [app-template-resto, app-alexandrie, RL799_V2]
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
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.
// ❌ 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 jointureuserAId/userBId) DOIT avoir sa@relationpaire 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@@indexdédié sur cette colonne — un index composite(conversation_id, created_at, id)ne couvre pas un filtre parsender_idseul (seq scan sinon). -
Contexte technique : Prisma / multi-tenant — app-template-resto 17-03-2026 ; app-alexandrie 13-05-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
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.
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
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.)
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
keylogique stable (alice,alice-bob), pas parid; construire unMap<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
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.
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
globalSetuple recrée from scratch (migrate deploy + seed) avec la colonne. Sipsqlindisponible, via clientpg: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 resetexige 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
Index partial sur colonnes soft-delete (nuance perf)
Risques
- Un index plein sur une colonne soft-delete nullable (
deleted_at) indexe tous les NULL, c'est-à-dire la quasi-totalité des lignes (les comptes actifs). L'index est volumineux et peu sélectif pour les requêtes qui ne ciblent que les lignes supprimées. - Distinct de l'index unique partiel déjà documenté (cf.
risque-prisma-unique-nullable, qui traite de l'idempotence) : ici l'enjeu est purement la performance / le coût d'indexation.
Symptômes
CREATE INDEX ... ON ("deleted_at")sans clauseWHEREsur une table où >99 % des lignes ontdeleted_at IS NULL- Index gros pour un bénéfice quasi nul sur les requêtes « lister les comptes supprimés »
Bonnes pratiques / mitigations
-- ❌ Index plein — indexe la masse des NULL (lignes actives)
CREATE INDEX "users_deleted_at_idx" ON "users"("deleted_at");
-- ✅ Index partial — n'indexe que les lignes effectivement supprimées
CREATE INDEX "users_deleted_at_idx" ON "users"("deleted_at")
WHERE "deleted_at" IS NOT NULL;
-
Règle : pour une colonne soft-delete nullable à majorité
NULL, préférer un index partialWHERE deleted_at IS NOT NULL. -
Contexte technique : Prisma / PostgreSQL / index partial — app-alexandrie 13-04-2026
Index partiels avec littéraux text — rejettent ALTER COLUMN String → enum
Risques
- Une migration de conversion
String → enumplante auALTER TABLE ... ALTER COLUMN ... TYPE "<Enum>" USING ...à cause d'un index partiel historique dont la clauseWHEREcontient 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 :
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) :
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 :
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
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 :
- Besoin réel
(global, immuable)→ constante côté contracts, pas de colonne. - Besoin
(par-row, configurable plus tard)→ colonne + endpoint admin pour la peupler dès la story qui l'introduit. - 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/.upsertdu codebase, c'est probablement une colonne morte. -
Contexte technique : Prisma / schema — app-alexandrie 05-05-2026
Read-then-write sur invariant d'unicité / transition one-shot — race condition
Risques
- Vérifier une condition par un
findUnique/findFirst/SELECTpuis agir par unupdateséparé n'est pas atomique sous concurrence. SousREAD 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
UserPackpour 1 code). - Une transition
open → settled(oudraft → published,pending → approved) appliquée deux fois : testPromise.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.
// ✅ 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'updateManygardé 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)
@@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 : unCREATE UNIQUE INDEXet unCREATE INDEXnormal. 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 INDEXet unCREATE INDEXsur la même colonne (ex:season_reports.season_id).
Bonnes pratiques / mitigations
-
N'ajouter
@@indexque sur des colonnes qui ne sont pas déjà couvertes par@@unique/@unique. -
Correction : supprimer
@@index([col])et générer une migrationDROP INDEX IF EXISTS. -
Contexte technique : Prisma / PostgreSQL — RL799_V2 14-06-2026
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 →
RecordNotFoundou retournull; - les requêtes post-DELETE peuvent retomber sur un état pré-CASCADE ou retourner des rows liées zombies.
- audit log, projection DTO de retour, side-effects référencent un id qui n'existe plus →
Symptômes
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.
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 = nullquand un enquêteur est remplacé — le rapport reste consultable, le lien à l'auteur est anonymisé). -
Toujours capturer les
fileUrl/pathavant le DELETE pour permettre unfs.rmpost-commit. -
Audit log avant DELETE — sinon le
targetIdréférence une row inexistante. -
Contexte technique : Prisma / transactions — RL799_V2 05-05-2026