11 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