Files
_Assistant_Lead_Tech/knowledge/backend/patterns/prisma.md
MaksTinyWorkshop b3417ad77b capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:12:44 +02:00

27 KiB

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)

- 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.*<relationName>" après l'ajout d'un soft delete pour identifier les sites à fixer.

Pattern atomique anti-race delete/restore

const result = await prisma.<model>.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)

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

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

- Catch explicite de PrismaClientKnownRequestError code P2002
- Mapping vers une erreur métier stable
- Conserver requestId et format d'erreur standardisé

Implémentation (exemple complet)

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

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

# 1. Écrire le SQL manuellement
mkdir -p prisma/migrations/<timestamp>_<nom>
# Créer migration.sql à la main

# 2. Appliquer le SQL directement en DB
npx prisma db execute --file prisma/migrations/<timestamp>_<nom>/migration.sql

# 3. Marquer la migration comme appliquée dans _prisma_migrations
npx prisma migrate resolve --applied <timestamp>_<nom>

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

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

type AuditClient = Prisma.TransactionClient | typeof prisma;

export const logActionSync = async (
  client: AuditClient,
  userId: string,
  action: string,
  targetType?: string,
  targetId?: string,
  metadata?: Record<string, unknown>,
) => {
  await client.auditLog.create({ data: { userId, action, targetType, targetId, metadata } });
};

await prisma.$transaction(async (tx) => {
  await tx.<entity>.update({ where: { id }, data: { ... } });
  await logActionSync(tx, userId, '<entity>.<action>', '<entity>', 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

-- prisma/migrations/<TS>_xxx/migration.sql
CREATE UNIQUE INDEX invitations_one_active_per_user
  ON invitations(user_id) WHERE status = 'active';
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

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

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

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

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

let previousDelegateeId: string | null = null;
let updateCount = 0;

await prisma.$transaction(async (tx) => {
  const lockResult = await tx.$queryRaw<Array<{ delegatee_id: string | null }>>`
    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

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

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)