Files
_Assistant_Lead_Tech/knowledge/backend/risques/prisma.md
T
MaksTinyWorkshop f1b783407a docs(knowledge): capitalisation backend — intégration du triage local (mai-juin 2026)
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>
2026-06-25 11:25:02 +02:00

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

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 jointure userAId/userBId) DOIT avoir sa @relation paire 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 @@index dédié sur cette colonne — un index composite (conversation_id, created_at, id) ne couvre pas un filtre par sender_id seul (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 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

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 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.)

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 key logique stable (alice, alice-bob), pas par id ; construire un Map<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 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.

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 globalSetup le recrée from scratch (migrate deploy + seed) avec la colonne. Si psql indisponible, via client pg : 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 reset exige 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 clause WHERE sur une table où >99 % des lignes ont deleted_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 partial WHERE 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 → enum plante au ALTER TABLE ... ALTER COLUMN ... TYPE "<Enum>" USING ... à cause d'un index partiel historique dont la clause WHERE contient 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 :

  1. Besoin réel (global, immuable)constante côté contracts, pas de colonne.
  2. Besoin (par-row, configurable plus tard) → colonne + endpoint admin pour la peupler dès la story qui l'introduit.
  3. 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 / .upsert du 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/SELECT puis agir par un update séparé n'est pas atomique sous concurrence. Sous READ 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 UserPack pour 1 code).
  • Une transition open → settled (ou draft → published, pending → approved) appliquée deux fois : test Promise.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'updateMany gardé 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 : un CREATE UNIQUE INDEX et un CREATE INDEX normal. 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 INDEX et un CREATE INDEX sur la même colonne (ex: season_reports.season_id).

Bonnes pratiques / mitigations

  • N'ajouter @@index que sur des colonnes qui ne sont pas déjà couvertes par @@unique/@unique.

  • Correction : supprimer @@index([col]) et générer une migration DROP 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 → RecordNotFound ou retour null ;
    • les requêtes post-DELETE peuvent retomber sur un état pré-CASCADE ou retourner des rows liées zombies.

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 = null quand un enquêteur est remplacé — le rapport reste consultable, le lien à l'auteur est anonymisé).

  • Toujours capturer les fileUrl/path avant le DELETE pour permettre un fs.rm post-commit.

  • Audit log avant DELETE — sinon le targetId référence une row inexistante.

  • Contexte technique : Prisma / transactions — RL799_V2 05-05-2026