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