Files
_Assistant_Lead_Tech/knowledge/backend/risques/prisma.md
MaksTinyWorkshop b3417ad77b 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>
2026-05-02 22:12:44 +02:00

23 KiB

title: Backend — Risques & vigilance : Prisma domain: backend bucket: risques tags: [prisma, transactions, tenant, schema, race-condition] applies_to: [implementation, review, debug, architecture] severity: high validated_on: 2026-04-07 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.md pour l'index complet.


PostgreSQL / Prisma : @unique sur champ nullable (idempotence cassée)

Risques

  • Doublons en base malgré un "unique" attendu (PostgreSQL autorise plusieurs NULL dans 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 where d'upsert doit ê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 $transaction avec un update non 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 tenantId dans le WHERE, même dans une transaction précédée d'un check
  • Utiliser updateMany/deleteMany pour inclure tenantId sans 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 à tenantId nullable distinguant ressources "système" et "tenant", un filtre { isSystem: true } sans tenantId: null expose des ressources corrompues à tous les tenants

Symptômes

  • Un tag isSystem: true avec tenantId non-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) + flag isSystem/isGlobal/isPublic, la branche "ressource publique" du filtre OR doit toujours inclure tenantId: 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ême sortOrder

Symptômes

  • Deux items avec le même sortOrder dans 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 maxOrder hors 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 NULL sans 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 @relation déclarée

Bonnes pratiques / mitigations

Tout modèle tenant-scoped doit avoir les trois :

  1. tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) dans le modèle Prisma
  2. La relation inverse dans Tenant (ex: menuCategories MenuCategory[])
  3. 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 dans schema.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.prisma ne contient pas le champ mentionné dans les tâches

Bonnes pratiques / mitigations

  • Avant de marquer une tâche , croiser avec schema.prisma pour 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.prisma sans son getter dans PrismaService casse 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 PrismaClient direct mais cassé via PrismaService

Bonnes pratiques / mitigations

Tout ajout de modèle Prisma = deux actions :

  1. Ajouter le modèle dans schema.prisma
  2. 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.ts est 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_URL n'est pas disponible dans l'environnement de build

Symptômes

  • PrismaClientInitializationError ou Error: Environment variable not found: DATABASE_URL au next 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.parse ou 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 EnumType dans le service masque l'absence de validation Prisma.

Symptômes

  • as SomeEnum dans un service ou repository sur un champ qui provient de la DB
  • Getter get model(): any dans PrismaService pour contourner le typage

Bonnes pratiques / mitigations

  1. Tout champ à valeurs finies doit être déclaré avec un enum Prisma dès la création du modèle — jamais en String.
  2. Si un modèle existant utilise String, créer une migration de conversion : ALTER COLUMN ... TYPE "EnumType" USING ...::"EnumType".
  3. Signal review : tout cast as EnumType sur 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 --applied produit un fichier migration.sql qui peut rester untracked dans git
  • Quiconque clone le repo et lance prisma migrate deploy n'a pas la migration

Symptômes

  • ?? (untracked) dans git status sur le dossier prisma/migrations/
  • prisma migrate status rapporte 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 que prisma/migrations/<nom>/migration.sql est présent et tracké

  • git add prisma/migrations/<nom>/ si untracked

  • Valider que prisma migrate status rapporte 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 + findFirst cré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.PrismaClientKnownRequestError avec le code spécifique attendu (P2025 pour record not found, P2002 pour unique constraint)

  • Re-throw toute autre erreur pour qu'elle remonte en 500 dans le handler

  • Signal review : catch { ou catch (e) { sans vérification de e.code dans 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 + 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

// ❌ 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.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


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

// ❌ 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 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

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