---
title: Backend — Patterns : Prisma
domain: backend
bucket: patterns
tags: [prisma, postgres, migration, pagination, idempotency, decimal]
applies_to: [analysis, implementation, review, debug]
severity: medium
validated_on: 2026-03-23
source_projects: [app-template-resto, app-alexandrie]
---
# Backend — Patterns : Prisma
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet.
---
## Pattern : Soft delete et archivage explicite
- Objectif : permettre la suppression logique sans perte immédiate de données.
- Contexte : données métier critiques, besoins d'audit, restauration ou conformité.
- Quand l'utiliser : dès qu'une suppression peut avoir des impacts métier ou légaux.
- Quand l'éviter : données purement techniques ou réellement éphémères.
- Avantage :
- Restauration possible
- Audit et traçabilité
- Réduction des suppressions irréversibles
- Limites / vigilance :
- Complexité accrue sur les requêtes
- Nécessite une discipline stricte (filtres par défaut)
- Validé le : 25-01-2026
- Contexte technique : API + DB relationnelle
### Implémentation (exemple minimal)
```txt
- Champ deletedAt (nullable) ou status
- Les requêtes standards filtrent deletedAt IS NULL
- Endpoints dédiés pour restauration / purge
- Index DB tenant compte du soft delete
```
### Piège — `include` ne filtre pas `deletedAt` automatiquement
`include: { related: true }` n'applique pas le filtre soft delete sur la relation. Si la relation pointe vers une entité elle-même soft-deletable, le doc caché reste exposé via la relation → fuite systématique.
Mitigations :
- relations to-many : `include: { related: { where: { deletedAt: null } } }`
- relations to-one (Prisma ne supporte pas `where` dans un `include` to-one) : `include: { related: { select: { deletedAt: true, ... } } }` puis filtrer post-query côté repo (`if (entity.related?.deletedAt) entity.related = null`)
Toujours `grep -rn "include.*"` après l'ajout d'un soft delete pour identifier les sites à fixer.
### Pattern atomique anti-race delete/restore
```typescript
const result = await prisma..updateMany({
where: { id, deletedAt: null }, // ou { not: null } pour restore
data: { deletedAt: new Date(), deletedById: actorId },
});
if (result.count === 0) return notFound(); // idempotent, pas de double-audit
```
`updateMany` + `where: { id, deletedAt: null }` permet de transformer un check-then-update non atomique en un update atomique conditionnel — le `count === 0` distingue "déjà supprimé" de "introuvable" sans risque de double effet de bord.
### Checklist
- Filtrage soft delete par défaut
- Restauration explicite possible
- Purge maîtrisée (cron / job)
- Index DB adaptés
- Tests sur cas supprimé / restauré
- Audit des `include` sur les relations soft-deletables
---
## Pattern : Pagination robuste (cursor-based) pour les listings
- Objectif : fournir des listings stables et performants sans incohérences entre pages.
- Contexte : endpoints de liste (ex. /users, /orders) avec volume potentiellement important.
- Quand l'utiliser : dès qu'un listing peut dépasser quelques dizaines/centaines d'items ou subir des écritures concurrentes.
- Quand l'éviter : listes strictement petites et statiques.
- Avantage :
- Résultats stables malgré insertions/suppressions
- Meilleure performance que l'offset sur gros volumes
- Expérience client plus fiable
- Limites / vigilance :
- Nécessite un tri déterministe (champ + tie-breaker)
- Complexité légèrement supérieure à offset/limit
- Validé le : 25-01-2026
- Contexte technique : API HTTP + DB (Postgres/MySQL), agnostique framework
### Implémentation (exemple minimal)
```txt
- Trier par (createdAt DESC, id DESC) (exemple)
- Le client envoie cursor = dernier (createdAt,id) reçu
- Le backend renvoie nextCursor si plus de résultats
- Ne jamais exposer de cursor implicite ou non documenté
```
### Checklist
- Tri déterministe (avec tie-breaker)
- nextCursor renvoyé et documenté
- Limite max de page (protection)
- Index DB aligné avec le tri
---
## Pattern : Idempotency key pour opérations sensibles
- Objectif : empêcher les doublons lors de retries ou timeouts.
- Contexte : création de ressources, paiements, webhooks.
- Quand l'utiliser : toute opération non strictement en lecture.
- Quand l'éviter : endpoints purement GET.
- Avantage :
- Protection contre doublons
- Robustesse face aux retries
- Limites / vigilance :
- Stockage et expiration des clés à gérer
- Validé le : 25-01-2026
- Contexte technique : API HTTP + DB transactionnelle
### Implémentation (exemple minimal)
```txt
- Client fournit Idempotency-Key
- Backend stocke la clé + résultat
- Retry retourne le résultat initial
```
### Checklist
- Clé obligatoire sur endpoints sensibles
- Contrainte d'unicité côté DB
- Comportement documenté
---
## Pattern : mapping explicite de `P2002` Prisma sur create/update de champ unique
- Objectif : transformer un conflit d'unicité prévisible en erreur métier exploitable plutôt qu'en 500 opaque.
- Contexte : `create`, `update` ou `upsert` Prisma sur un champ `@unique` alimenté par une source externe, concurrente, ou après un pre-check.
- Quand l'utiliser : dès qu'un champ unique peut entrer en collision — à la création ET à la modification.
- Quand l'éviter : jamais si le champ peut réellement entrer en collision.
- Avantage :
- réponse client stable
- diagnostic métier plus rapide
- Limites / vigilance :
- le mapping doit rester cohérent avec le format d'erreur API standardisé
- Validé le : 10-03-2026
- Contexte technique : Prisma / PostgreSQL / NestJS
### Implémentation (exemple minimal)
```txt
- Catch explicite de PrismaClientKnownRequestError code P2002
- Mapping vers une erreur métier stable
- Conserver requestId et format d'erreur standardisé
```
### Implémentation (exemple complet)
```typescript
import { Prisma } from "@prisma/client";
try {
await prisma.item.create({ data: { ... } });
// ou: await prisma.item.update({ where: { id }, data: { ... } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
throw new HttpError("Un élément avec ce nom existe déjà.", { status: 409 });
}
throw err;
}
```
**Important :** un pre-check applicatif (`findUnique` avant `create`) ne suffit pas contre les race conditions. Le `try/catch P2002` est le seul garde-fou fiable. S'applique à `create`, `update`, `updateMany`, `upsert`.
### Checklist
- `P2002` intercepté sur les creates ET les updates sensibles
- Code d'erreur métier stable (409 Conflict)
- Pas de 500 générique sur conflit prévisible
---
## Pattern : Sérialiser les champs `Decimal` Prisma en string au niveau du repository
- Objectif : éviter que les objets `Decimal` Prisma traversent les couches et causent des erreurs de sérialisation JSON silencieuses.
- Contexte : tout champ `Decimal` en Prisma (ex: `price`) retourné via API ou Server Action.
- Quand l'utiliser : systématiquement sur tout champ `Decimal` dans les repositories.
- Risque si ignoré : `Decimal` n'est pas JSON-sérialisable nativement — comportement varie selon Node vs browser vs test runner.
- Validé le : 17-03-2026
- Contexte technique : Prisma / Node.js — app-template-resto
### Implémentation
```typescript
// Repository — convertir avant de retourner
return {
...dish,
price: dish.price?.toString() ?? null, // Decimal → string
};
// DTO public
type DishDto = {
price: string | null; // pas Decimal
};
```
---
## Pattern : Prisma — Migration manuelle sans shadow DB (P3014)
- Objectif : créer et appliquer une migration Prisma quand la shadow database est interdite (DB managée, permissions restreintes).
- Contexte : DB managées — Supabase, PlanetScale, Railway avec rôle limité, RDS sans superuser.
- Quand l'utiliser : quand `prisma migrate dev` échoue avec `P3014 Prisma Migrate could not create the shadow database`.
- Risque si ignoré : blocage complet de la migration sur env managé.
- Validé le : 23-03-2026
- Contexte technique : Prisma v7+ — app-alexandrie / Supabase
### Implémentation
```bash
# 1. Écrire le SQL manuellement
mkdir -p prisma/migrations/_
# Créer migration.sql à la main
# 2. Appliquer le SQL directement en DB
npx prisma db execute --file prisma/migrations/_/migration.sql
# 3. Marquer la migration comme appliquée dans _prisma_migrations
npx prisma migrate resolve --applied _
# Note Prisma v7 : ne pas utiliser --schema= (option supprimée), utiliser prisma.config.ts
```
**Ne pas utiliser `prisma db push` en production** — il ne versionne pas les migrations.
---
## Pattern : Filtrage des règles métier dans le service, pas dans le repository
- Objectif : séparer la couche d'accès aux données (repository) des règles de visibilité métier (service).
- Contexte : entités publiques avec règles de filtrage (`isVisible`, `isActive`), qui varient selon le contexte appelant (public vs admin).
- Quand l'utiliser : dès qu'une règle de visibilité dépend du contexte d'appel.
- Quand l'éviter : filtres de performance (pagination, tenant scoping) — ceux-là restent dans le `where`.
- Avantage :
- la règle est testable unitairement sans Prisma (mock de données brutes)
- la requête DB reste simple et stable entre contextes
- les cas futurs (ex: admin voit les invisibles) ne nécessitent pas de modifier la requête
- Validé le : 17-03-2026
- Contexte technique : Prisma / Node.js / Next.js — app-template-resto
### Implémentation (exemple minimal)
```typescript
// Repository — charge tout ce qui est candidat
async findCategories(tenantId: string) {
return prisma.category.findMany({ where: { tenantId } }); // pas de filtre isVisible
}
// Service — applique la règle métier et mappe vers DTO
const raw = await repo.findCategories(tenantId);
return raw.filter(c => c.isVisible).map(toPublicDto);
// Admin : même repo, filtre différent dans le service admin
return raw.map(toAdminDto); // retourne tout, visible ou non
```
---
## Pattern : Audit transactionnel — mutation et log dans la même `$transaction`
- Objectif : garantir l'invariant `mutation persistée ⇔ audit log existe` quand l'audit est un livrable métier (pas un simple effet de bord informatif).
- Contexte : opérations sensibles (correction par un délégué hors périmètre habituel, opérations admin, opérations soumises à conformité).
- Quand l'utiliser : tout flux où une mutation sans trace serait inacceptable.
- Quand l'éviter : audits purement informatifs (statistiques d'usage, debug) — fire-and-forget acceptable.
- Avantage :
- rollback automatique si l'audit échoue → pas de mutation orpheline
- aucune divergence possible entre l'état persisté et la trace
- Limites / vigilance :
- une mutation peut désormais échouer pour cause "audit indisponible" → 5xx renvoyé au client (cohérent : on préfère refuser la mutation que la passer sans trace)
- Validé le : 27-04-2026
- Contexte technique : Prisma / NestJS — RL799_V2
### Implémentation
```typescript
type AuditClient = Prisma.TransactionClient | typeof prisma;
export const logActionSync = async (
client: AuditClient,
userId: string,
action: string,
targetType?: string,
targetId?: string,
metadata?: Record,
) => {
await client.auditLog.create({ data: { userId, action, targetType, targetId, metadata } });
};
await prisma.$transaction(async (tx) => {
await tx..update({ where: { id }, data: { ... } });
await logActionSync(tx, userId, '.', '', id, { ... });
});
```
### Anti-patterns
- `logAction(...)` (fire-and-forget) après le persist quand l'audit est requis métier
- `logActionSync(prisma, ...)` (hors transaction) après le persist : synchrone mais pas atomique avec la mutation
- `.catch(() => {})` autour de l'audit "pour ne pas casser la mutation"
### Checklist
- [ ] Le helper d'audit accepte un `client: AuditClient` (transaction ou prisma)
- [ ] Mutation et audit dans la même `$transaction`
- [ ] Test d'atomicité : mock `createAuditLog` qui throw → assert rollback (cf. `knowledge/backend/patterns/tests.md`)
---
## Pattern : Index unique partiel Postgres pour invariant "≤ 1 active par X"
- Objectif : enforcer l'invariant "au plus une row active par scope" au niveau base de données plutôt que via un check applicatif vulnérable aux races.
- Contexte : ressources avec un cycle de vie `active → revoked/closed` où l'invariant métier impose une seule active par user/contexte (invitation, mandat d'officier, lock éditeur).
- Quand l'utiliser : dès qu'un check applicatif "≤ 1 active" est nécessaire et que la concurrence est possible.
- Quand l'éviter : si la table n'a pas de colonne `status` discriminante ou si plusieurs rows actives sont métier-acceptables.
- Avantage :
- 2e INSERT concurrente échoue avec contrainte unique violée (P2002) plutôt que de créer un doublon
- défense en profondeur : le check applicatif reste, mais la DB est la dernière ligne
- Limites / vigilance :
- Prisma ne supporte pas les unique partials en `schema.prisma` → ajouter dans la migration SQL brute
- documenter dans la migration : un `prisma format` accidentel pourrait droper l'index
- Validé le : 28-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```sql
-- prisma/migrations/_xxx/migration.sql
CREATE UNIQUE INDEX invitations_one_active_per_user
ON invitations(user_id) WHERE status = 'active';
```
```typescript
export const revokeAndIssueInvitation = async (input) => {
try {
return await prisma.$transaction(async (tx) => {
await tx.invitation.updateMany({
where: { userId: input.userId, status: 'active' },
data: { status: 'revoked', revokedAt: new Date() },
});
return tx.invitation.create({
data: { userId: input.userId, ..., status: 'active' },
});
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
return { ok: false, reason: 'RACE_CONFLICT' };
}
throw err;
}
};
```
### Checklist
- [ ] Index partiel ajouté dans le SQL brut de la migration
- [ ] Handler `P2002` traduit en code métier (`RACE_CONFLICT`, 409)
- [ ] Test de race : `Promise.all([resend(), resend()])` puis `count({ status: 'active' }) === 1`
- [ ] Commentaire dans la migration : "Prisma ne supporte pas les unique partials en schema, ne pas droper sur `prisma format`"
---
## Pattern : UUID v5 déterministe pour ids de seed
- Objectif : permettre d'écrire `seedUserId('venerable')` côté tests/code tout en garantissant que `User.id` reste un UUID RFC 4122 en base — débloque la rigidification Zod `.uuid()` en aval.
- Contexte : seed Prisma qui crée des entités référencées par slug lisible dans les tests, et qui doivent rester typées strictement côté API.
- Quand l'utiliser : nouveaux seeds OU migration d'un seed historique avec slugs littéraux comme PK.
- Quand l'éviter : si le seed est purement aléatoire (`@default(uuid())`) et qu'aucun test ne référence un user particulier par identifiant.
- Avantage :
- déterminisme : `seedUserId('venerable')` donne toujours le même UUID v5
- type uniforme : tous les `User.id` sont des UUID RFC 4122 → `.uuid()` activable
- lisibilité préservée : le code de tests reste sémantique
- Limites / vigilance :
- le slug ne doit JAMAIS être persisté en clair (mapping explicite `{ id: seedUserId(slug), ...rest }`)
- migration depuis un seed slug existant = chantier en 2 commits (cf. pattern rigidification Zod 2 phases dans `contracts.md`)
- Validé le : 24-04-2026
- Contexte technique : Prisma / uuid v5 — RL799_V2
### Implémentation
```typescript
// packages/shared/src/utils/seedIdentity.ts
import { v5 as uuidv5 } from 'uuid';
// Namespace stable du projet (généré une fois, committé ensuite)
export const SEED_USER_NAMESPACE = '2cd71e75-dd5e-42cc-b9fa-52888c42cc3d';
export const seedUserId = (slug: string): string =>
uuidv5(slug, SEED_USER_NAMESPACE);
```
Côté tests :
- helpers (`TEST_SECRETARY`, `TEST_VENERABLE`) exposent l'UUID résolu : les tests écrivent `TEST_SECRETARY.id`, pas `'secretaire'`
- les users ad-hoc éphémères (créés/supprimés dans le scope d'un test) utilisent `randomUUID()`, pas `seedUserId()` — réservé aux entités seed durables
### Pourquoi pas UUID v4 aléatoire
Le déterminisme est essentiel : il permet aux fixtures E2E de pointer un user précis (`const TRESORIER_ID = seedUserId('tresorier')`) sans lire la base, et garantit la reproductibilité du seed en CI.
---
## Pattern : Test d'invariant post-seed
- Objectif : transformer la liste canonique des entités seed en contrat exécutable, détecter immédiatement un drift (slug ajouté hors helper, user oublié, format incohérent).
- Contexte : projet avec un seed structurant (users, configurations système) référencé par les fixtures de tests et les flux E2E.
- Quand l'utiliser : à chaque migration qui modifie la forme d'une entité seed (UUID, format d'id, contraintes).
- Quand l'éviter : seed purement aléatoire et jetable (pas de référence stable depuis les tests).
- Avantage :
- le test devient un contrat lisible du seed, pas une abstraction
- détecte un futur dev qui ajouterait un user via un slug littéral sans `seedUserId()`
- détecte un user oublié ou dupliqué
- Limites / vigilance :
- **nombre exact**, pas "au moins N" : si le seed tronque à 29 au lieu de 30, le test doit échouer
- filtrer explicitement par la liste des slugs connus — ne pas valider "tous les users en base" (résidus possibles)
- Validé le : 24-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
### Implémentation
```typescript
// __tests__/seedInvariants.test.ts
const SEED_SLUGS: readonly string[] = [
'venerable', 'secretaire', /* … 30 slugs … */
];
const EXPECTED_SEED_USER_COUNT = 31;
test('seed invariant: users seed possèdent un UUID déterministe', async () => {
assert.equal(SEED_SLUGS.length, EXPECTED_SEED_USER_COUNT, 'liste figée');
const expectedIds = SEED_SLUGS.map(seedUserId);
const users = await prisma.user.findMany({
where: { id: { in: expectedIds } },
select: { id: true },
});
assert.equal(users.length, EXPECTED_SEED_USER_COUNT);
assert.ok(users.every((u) => isValidUuid(u.id)));
});
```
### Checklist
- [ ] Liste figée des slugs en constante
- [ ] Compte exact (`.equal`, pas `.gte`)
- [ ] Filtrage explicite par la liste (pas de `findMany()` global)
- [ ] Vérification du format de l'id
---
## Pattern : Check `RAISE EXCEPTION` conditionnée à la présence de données
- Objectif : préserver la rejouabilité de la migration sur une DB vide (dev `prisma migrate reset`) tout en gardant le fail-loud sur DB peuplée.
- Contexte : migration qui fait un backfill de données existantes et veut échouer si l'admin/owner cible est absent.
- Quand l'utiliser : toute check "fail if missing X" qui protège un backfill, jamais le schéma lui-même.
- Quand l'éviter : check de schéma purement structurel (`NOT NULL`, FK) — ces contraintes appartiennent au DDL, pas à un `RAISE`.
- Avantage :
- DB vide (dev reset) : 0 row à backfiller → check skip propre, migration passe
- DB prod/staging avec données : check conservée, fail-loud comme prévu
- Limites / vigilance :
- une migration doit rester rejouable sur une DB vide ET une DB peuplée — c'est le contrat de `prisma migrate reset`
- Validé le : 21-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Anti-pattern
```sql
-- ❌ Bloque tout migrate reset sur dev (DB vide)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true) THEN
RAISE EXCEPTION 'Migration X requires an admin user.';
END IF;
END $$;
```
### Pattern correct
```sql
-- ✅ Exige admin uniquement s'il y a des données à backfiller
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM cotisation_entries WHERE status = 'paid')
AND NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true)
THEN
RAISE EXCEPTION 'Migration X requires an admin user to backfill existing paid entries.';
END IF;
END $$;
```
---
## Pattern : Révocation atomique d'un état transversal lors d'une transition de cycle
- Objectif : éteindre les champs d'état transversaux (délégation, lock, ownership) dans la **même transaction** que la transition de cycle de vie de l'entité parente.
- Contexte : transitions `close / archive / cancel / soft-delete / lock définitif` d'une entité qui porte un ou plusieurs champs transversaux n'ayant plus de sens dans le nouveau cycle.
- Quand l'utiliser : à chaque transition de cycle où un état transversal devient un "zombie" potentiel (délégation qui survit à la clôture, lock qui survit à l'archivage).
- Quand l'éviter : transitions sans état transversal pertinent (archivage simple).
- Avantage :
- aucun état zombie possible
- la valeur précédente est capturée sous lock → audit et notif fiables (pas de race entre lecture et écriture)
- Limites / vigilance :
- les effets de bord (audit, notif) DOIVENT sortir de la transaction (best-effort, fire-and-forget)
- l'idempotence est gérée par le `WHERE` du `updateMany` (`closedAt: null`) — la 2e tentative ne re-déclenche pas les effets de bord
- Validé le : 27-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```typescript
let previousDelegateeId: string | null = null;
let updateCount = 0;
await prisma.$transaction(async (tx) => {
const lockResult = await tx.$queryRaw>`
SELECT delegatee_id FROM "entities"
WHERE id = ${id} AND closed_at IS NULL
FOR UPDATE
`;
if (lockResult.length === 0) return; // idempotence
previousDelegateeId = lockResult[0].delegatee_id;
const updated = await tx.entity.updateMany({
where: { id, closedAt: null },
data: {
closedAt: now,
closedBy: userId,
delegateeId: null, // ← révocation atomique dans la même tx
},
});
updateCount = updated.count;
});
// Effets de bord HORS de la transaction
if (updateCount > 0 && previousDelegateeId !== null) {
logAction(userId, 'entity:delegation_revoked_on_close', ...);
void notifyDelegatee(previousDelegateeId, ...);
}
```
### Les 4 invariants
1. La révocation vit dans le **même `updateMany`** que la transition principale.
2. La capture de la valeur précédente est sous **`SELECT FOR UPDATE`** dans la transaction.
3. Les effets de bord (audit, notif) **sortent de la transaction**.
4. L'idempotence est gérée par le `WHERE` (`closedAt: null`) — la 2e tentative est un no-op observable.
### Tests minimaux
- Happy path : transition avec valeur transversale présente → champ nullé + audit + notif au bon target
- Sans valeur transversale : pas d'effet de bord (pas d'audit révocation, pas de notif)
- Idempotence : 2e transition retombe en already_closed sans double effet
---
## Pattern : Migration destructive en 4 phases avec sentinelle d'archive
- Objectif : refondre une table avec PK changée ou colonnes incompatibles sans perdre l'audit historique des rows métier importantes.
- Contexte : table dont la PK ou la forme évolue de façon non-rétrocompatible (ex : token en clair → hash SHA-256 stocké, slug → UUID).
- Quand l'utiliser : refonte structurelle où un `ALTER TABLE` patchwork serait fragile (FK multiples, index, contraintes).
- Quand l'éviter : ajout simple de colonne nullable, refactor cosmétique d'index.
- Avantage :
- DROP + CREATE plus sûr qu'un patchwork ALTER quand la PK change
- les rows historiques (`status = 'consumed'`) sont conservées pour audit
- sentinelle d'archive non-collisionnable garantit qu'aucun login ne peut matcher une row archivée
- Limites / vigilance :
- Phase 1 (DELETE des rows non-migrables) impose une communication aux admins pré-deploy
- inspection manuelle obligatoire du SQL généré par `prisma migrate dev --create-only`
- Validé le : 28-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Recette
```sql
-- Phase 1 : invalidation propre des données non-migrables
-- Les tokens en clair ne peuvent pas être convertis en SHA-256 (one-way).
-- Les rows 'consumed' sont conservées pour audit historique.
DELETE FROM invitations WHERE status != 'consumed';
-- Phase 2 : refonte de la table
CREATE TEMP TABLE invitations_archive AS
SELECT email, status, consumed_at FROM invitations WHERE status = 'consumed';
DROP TABLE invitations CASCADE;
CREATE TABLE invitations (
id TEXT NOT NULL DEFAULT gen_random_uuid()::text PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
...
);
CREATE UNIQUE INDEX invitations_one_active_per_user
ON invitations(user_id) WHERE status = 'active';
-- Phase 3 : restauration des rows consommées avec sentinelle non-collisionnable
-- '_legacy_' n'est jamais produit par crypto.randomBytes(32).toString('hex')
INSERT INTO invitations (id, user_id, token_hash, email, status, consumed_at)
SELECT
gen_random_uuid()::text,
u.id,
'_legacy_' || gen_random_uuid()::text,
a.email,
'consumed',
a.consumed_at
FROM invitations_archive a
JOIN users u ON u.email = a.email;
DROP TABLE invitations_archive;
-- Phase 4 : drop des colonnes obsolètes sur d'autres tables
ALTER TABLE users DROP COLUMN IF EXISTS must_change_password;
```
Côté repository, filtrer la sentinelle :
```typescript
const LEGACY_TOKEN_HASH_PREFIX = '_legacy_';
export const findInvitationByTokenHash = async (tokenHash: string) => {
if (tokenHash.startsWith(LEGACY_TOKEN_HASH_PREFIX)) return null;
// … lookup normal
};
```
### Checklist
- [ ] Phase 1 communiquée aux admins pré-deploy si tokens actifs en cours
- [ ] Phase 2 préfère `DROP + CREATE` quand la PK change
- [ ] Phase 3 utilise un préfixe **garanti non-collisionnable** par construction cryptographique
- [ ] Idempotence (`IF EXISTS` / `IF NOT EXISTS`) sur les changements réversibles
- [ ] Procédure rollback documentée (`pg_dump` avant migration)
- [ ] Smoke test post-deploy (login, création, magic link)