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

---

<a id="risque-prisma-unique-nullable"></a>
## 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

---

<a id="risque-prisma-transaction-toctou-tenantid"></a>
## 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**

```typescript
// ❌ 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**

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

---

<a id="risque-prisma-or-tenantid-null"></a>
## 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

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

---

<a id="risque-nextorder-hors-transaction"></a>
## 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

```typescript
// ✅ 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

---

<a id="risque-tenantid-sans-fk-relation"></a>
## 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.

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

---

<a id="risque-schema-divergence-spec-story"></a>
## 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

---

<a id="risque-prismaservice-getter-manquant"></a>
## 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`

```typescript
// 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

---

<a id="risque-prisma-init-module-build"></a>
## 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

---

<a id="risque-jest-clearallmocks-imbrique"></a>
## `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

---

<a id="risque-cursor-pagination-opaque"></a>
## 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

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

```ts
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

---

<a id="risque-enum-like-string-prisma"></a>
## 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

---

<a id="risque-migration-manuelle-hors-git"></a>
## 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

---

<a id="risque-relation-1-1-sans-unique"></a>
## 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

---

<a id="risque-catch-all-silencieux-repository"></a>
## 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

---

<a id="risque-prisma-filtre-lecture-ecriture-desaligne"></a>
## 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

---

<a id="risque-prisma-deletemany-sans-partition"></a>
## `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

---

<a id="risque-capture-pre-updatemany-race-window"></a>
## 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

```typescript
// ❌ 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`) :

```typescript
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) :

```typescript
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 :

```typescript
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

---

<a id="risque-slugs-metier-user-id"></a>
## 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

---

<a id="risque-prisma-where-relation-every-vide"></a>
## `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

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

```typescript
// ✅ 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

---

<a id="risque-resolvedburl-template-based-testing"></a>
## `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

```typescript
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

---

<a id="risque-index-partial-soft-delete-perf"></a>
## 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

```sql
-- ❌ 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

---

<a id="risque-index-partiel-text-alter-enum"></a>
## 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` :

```sql
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) :

```sql
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 :

```bash
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

---

<a id="risque-colonne-prisma-jamais-ecrite"></a>
## 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

---

<a id="risque-read-then-write-transition-one-shot"></a>
## 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`.

```typescript
// ✅ 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)

---

<a id="risque-unique-plus-index-redondant"></a>
## `@@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

---

<a id="risque-delete-row-fin-transaction-anonymisation"></a>
## 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

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

```typescript
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
