Files
_Assistant_Lead_Tech/knowledge/backend/patterns/prisma.md
T
MaksTinyWorkshop f1b783407a docs(knowledge): capitalisation backend — intégration du triage local (mai-juin 2026)
Triage et intégration des propositions backend du buffer 95_a_capitaliser.md
(lot local RL799_V2 + app-alexandrie, mai-juin 2026), distinct de la capitalisation
remote antérieure (triage 2026-05-02).

~73 entrées intégrées sur knowledge/backend/, dont :
- patterns/auth.md : série "membrane d'auth fédérée BFF/OIDC" (9 patterns) + jose algo whitelist
- patterns/prisma.md : recette fusionnée "Migration String/Int → enum" (backfill + Cas A/B/C),
  row réactivable, endpoint replace atomique, updateMany conditionnel, etc.
- risques/general.md : 19 risques (epoch s vs ms, keepAliveTimeout=0, upsert+filtre liste,
  fail-safe catch-all, retrait asymétrique front/back, anti-énumération rate-limit, etc.)
- patterns/general, async, nestjs, contracts, tests + risques/auth, contracts, prisma, redis, stripe, tests
- compléments d'entrées existantes (authorize-after-fetch, P3014, cursor opaque, DI swc, Stripe v20...)
- README patterns/risques mis à jour

Doublons internes corrigés en relecture (suppression-champ .map() → general seul ;
e2e DB-based → tests.md seul). Doublons hors backend / entrées projet / rejets non intégrés.
Source 95_a_capitaliser.md non purgée à ce stade (purge en fin de capitalisation complète).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:25:02 +02:00

56 KiB
Raw Blame History


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.

Variante : réaligner une DB dev sur un schéma amendé (migration WIP non encore mergée)

Quand migrate dev est bloqué (P3014, user applicatif sans droit CREATE DATABASE) et qu'une migration non encore mergée doit être corrigée : amender directement le migration.sql existant (la DB dev est jetable), puis réaligner la base sans nouvelle migration :

# 1. Générer le SQL d'écart DB → schéma cible
npx prisma migrate diff \
  --from-config-datasource --to-schema prisma/schema.prisma \
  --config prisma.config.ts --script > diff.sql

# 2. Appliquer (v7 : PAS de --schema ici, datasource lue depuis prisma.config.ts ;
#    --file OU --stdin, une seule des deux)
npx prisma db execute --file diff.sql --config prisma.config.ts

# 3. Vérifier
npx prisma migrate diff ... --exit-code   # doit afficher « No difference detected »

⚠️ Valable uniquement pour une migration WIP non poussée. Une fois la migration mergée, créer une migration corrective additive (ne jamais amender une migration déjà partagée).


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)

Pattern : Colonnes plates vs table dédiée — choix par durée de vie de la donnée

  • Objectif : choisir la bonne forme de stockage pour les données d'étapes/cycle de vie d'un agrégat selon que celles-ci survivent ou non à la fin du parcours.
  • Contexte : agrégat avec un parcours en N étapes (timeline d'un candidat, lifecycle d'un dossier, états d'un workflow).
  • Quand l'utiliser :
    • Colonnes plates sur la row principale → si les données sont purgées en même temps que la row à la fin du parcours (admission/clôture). Pas de table satellite : moins de JOINs, projection DTO plate, transactions plus simples.
    • Table dédiée (one-to-many ou one-to-one séparé) → si les données survivent à la fin du parcours (audit trail, archivage légal, historique multi-candidatures).
  • Quand l'éviter : si la cardinalité du détail est variable (préférer alors une table), ou si l'on est tenté de stocker N champs hétérogènes dans un seul blob JSON.
  • Avantage colonnes plates :
    • 0 JOIN sur le détail courant
    • DTO plat, sérialisation directe
    • transactions atomiques plus simples (1 seule row à locker)
  • Avantage table dédiée :
    • indépendance du cycle de vie (la donnée historique ne contraint pas la suppression du parent)
    • index dédiés possibles
    • cardinalité variable (vs N colonnes fixes)
  • Limites / vigilance :
    • colonnes plates : N colonnes nullable bien nommées (pas un JSON blob), DELETE de la row = perte définitive (acceptable seulement si la purge est prévue)
  • Validé le : 05-05-2026
  • Contexte technique : Prisma / Postgres — RL799_V2

Heuristique de décision

Question simple : « cette donnée doit-elle survivre à la suppression de la row parent ? » → si non → colonnes plates ; si oui → table dédiée.

Exemple

RL799 — module Enquête profane : Profane.letterReadAt, Profane.letterVoteOutcome, Profane.enquetesMarkedDoneAt, Profane.reportReadingAt, etc. (9 colonnes timeline plates) plutôt qu'une table ProfaneTimelineEvent. Justifié : la row Profane est DELETE à l'admission (purge totale), donc l'historique de parcours n'a pas à survivre à la row.


Pattern : Étape courante dérivée de colonnes booléennes/timestamps (source unique de vérité)

  • Objectif : éviter la duplication entre une colonne status explicite et l'état réel dérivé des timestamps/outcomes.
  • Contexte : agrégat avec parcours en N étapes où chaque étape laisse une trace (date, outcome). On veut connaître l'étape courante à un instant T.
  • Quand l'utiliser : dès qu'une colonne currentStep/status redondante risquerait de diverger de l'état réel dérivable des autres colonnes.
  • Quand l'éviter : si l'étape courante a une sémantique métier autre que ses propres timestamps (ex. dépend d'un autre agrégat).
  • Solution : un helper pur (côté package shared) qui prend en input les colonnes brutes (pas un objet ORM) et retourne un type union discriminant. Ordre de priorité explicite documenté en tête du helper (et qui matche l'ordre des if).
  • Avantage :
    • source unique de vérité — frontend et backend partagent le même calcul
    • testable en isolation (helper pur, pas de DB)
    • aucun drift possible entre status stocké et état réel
  • Limites / vigilance :
    • toute évolution du parcours nécessite de mettre à jour le helper + ses tests
    • ne pas exposer en parallèle une colonne currentStep stockée en DB : le helper EST la source, le frontend reçoit currentStep calculé dans le DTO mapper, pas lu d'une colonne
  • Validé le : 05-05-2026
  • Contexte technique : monorepo TS partagé front/back — RL799_V2

Implémentation (exemple)

// packages/shared/src/dto/soirees.ts
export const getSoireeLifecycle = (input, now?) => { /* … */ };
// Priorité documentée : cancelledAt > closedAt > status('draft'|'pending')
//                     > openedAt > status('published')

// packages/shared/src/utils/profaneTimeline.ts
export const deriveCurrentStep = (input) => { /* … */ };
// Priorité : status !== pending → 'closed' ; sinon
//   bandeauVoteOutcome === 'passed' → 'initiation' ;
//   reportReadingVoteOutcome === 'passed' → 'bandeau' ; etc.

Tests

Matrice paramétrée (~15-30 cas) couvrant toutes les transitions pertinentes (cf. soireeLifecycle.test.ts).


Pattern : Row réactivable (reset des colonnes de cycle, identité préservée)

  • Objectif : permettre à une même entité métier de traverser plusieurs cycles (candidatures, abonnements, mandats) en gardant la même identité technique.
  • Contexte : entité dont l'identité (nom/prénom/email humain) doit rester reconnaissable d'un cycle au suivant, mais dont l'état fonctionnel doit repartir de zéro.
  • Quand l'utiliser : entité multi-cycles dont l'identité technique doit rester stable (URLs persistantes, audit trail continu, comptage natif des cycles).
  • Quand préférer DELETE+INSERT à la place :
    • si l'entité doit garder un historique riche par cycle (rapports, pièces jointes spécifiques) → table satellite <Entity>Cycle avec FK vers la row principale
    • si l'identité change vraiment d'un cycle au suivant (changement légal, fusion d'entités)
  • Solution : une fonction reactivate<Entity>() qui reset toutes les colonnes "de cycle" + status à pending initial, sans toucher à l'identité (id, créateur, coordonnées). Compteur attemptCount incrémenté, gate métier sur la valeur (ex. max 3 cycles).
  • Avantage :
    • identité technique stable → URLs persistantes, audit trail continu
    • comptage natif des cycles (attemptCount)
    • pas de "fantôme" historique à filtrer en table
  • Limites / vigilance :
    • le reset doit être exhaustif — chaque nouvelle colonne de cycle doit être ajoutée à la fonction reset (à enforcer par revue ou test)
    • l'audit log doit conserver l'événement <entity>:reactivated (le reset efface tout sauf l'audit séparé)
  • Validé le : 05-05-2026
  • Contexte technique : Prisma / Postgres — RL799_V2

Implémentation (exemple)

export const reactivateProfane = async (profaneId, client) => {
  await client.profane.update({
    where: { id: profaneId },
    data: {
      status: 'pending',
      refusedAt: null,
      rejectionReason: null,
      attemptCount: { increment: 1 },
      letterReadAt: null,
      letterVoteOutcome: null,
      // … toutes les colonnes timeline reset à null
    },
  });
};

Service : gate attemptCount >= 3 → 400 MAX_ATTEMPTS_REACHED avant le reset. Audit enquete:profane_reactivated posé pour l'historique.

Checklist

  • Fonction reactivate exhaustive (toutes les colonnes de cycle)
  • Compteur attemptCount incrémenté
  • Gate métier sur le compteur (limite max)
  • Audit log de la réactivation
  • Test d'intégration : 1 cycle complet → réactivation → état initial

Pattern : Endpoint replace atomique (remplacement à un slot)

  • Objectif : remplacer un membre d'une collection de slots (3 enquêteurs, 5 officiers, etc.) en une seule transaction atomique, sans passer par "DELETE puis POST".
  • Contexte : agrégat avec une collection de slots où l'on veut remplacer un membre par un autre. Le chaînage DELETE puis POST ouvre une fenêtre où la collection est dans un état intermédiaire invalide (cardinalité < attendue) et double les notifications.
  • Quand l'utiliser : dès qu'un remplacement à un slot doit être atomique et que les notifications doivent être chirurgicales (1 sortie, 1 entrée).
  • Quand l'éviter : ajout/retrait simple sans sémantique de remplacement → POST/DELETE suffisent.
  • Solution : PUT /resource/:id/members/:oldId avec body { newMemberId }. Le service exécute revoke + assign + side-effects (anonymisation, audit) dans la même transaction Prisma.
  • Avantage :
    • aucune fenêtre d'incohérence visible par un lecteur concurrent
    • une seule notification post-commit (notifyAssigned(newId) + notifyRevoked(oldId)) au lieu d'un mailing dupliqué aux membres inchangés
    • permet de gérer les invariants intermédiaires (ex. anonymisation du rapport déposé par le remplacé) en cohérence avec la modification
  • Limites / vigilance :
    • plus complexe qu'un POST (2 IDs au lieu d'1)
    • le frontend doit comprendre la sémantique "remplacement" et ne pas chaîner DELETE+POST
  • Validé le : 05-05-2026
  • Contexte technique : Next.js App Router + transaction Prisma — RL799_V2

Implémentation (exemple)

// PUT /api/venerable/profanes/:profaneId/enqueteurs/:oldEnqueteurId
// Body: { newEnqueteurId }
export const handleReplaceEnqueteur = async (req, profaneId, oldId) => {
  const { newEnqueteurId } = await validate(req);
  await prisma.$transaction(async (tx) => {
    // 1. Anonymiser l'éventuel rapport de l'ancien
    const rapport = await findRapportByEnqueteur(oldId, tx);
    if (rapport) await anonymizeRapport(rapport.id, tx);
    // 2. Revoke + assign
    await revokeEnqueteur(enqueteId, oldId, tx);
    await assignEnqueteurs(enqueteId, [newEnqueteurId], { /* … */ }, tx);
    // 3. Audit composite (1 seul log au lieu de 2)
    await logAction(tx, 'enquete:investigator_replaced', { oldId, newId: newEnqueteurId });
  });
  // Post-commit : notifications ciblées (diff connu : 1 sortie, 1 entrée)
  void notifyAssigned([newEnqueteurId]);
  void notifyRevoked(oldId);
  // Les autres membres de la collection ne reçoivent RIEN (cloisonnement)
};

Cloisonnement des notifications

Avec un endpoint replace atomique, le service connaît exactement le diff (1 sortie, 1 entrée) → mailing chirurgical. Avec 2 appels DELETE+POST, le 2e appel voit la collection déjà réduite et re-mailerait les membres inchangés par défaut sans diff intelligent.


Pattern : Bascule d'état idempotente avec updateMany conditionnel (anti-race)

  • Objectif : basculer une row d'un état A vers un état B au franchissement d'un seuil (compteur de reports, quota, vote) sans race ni double effet.
  • Contexte : transition pilotée par un seuil où le pattern "lire l'état puis updater" est vulnérable aux courses. Deux requêtes concurrentes voient l'état initial simultanément et écrasent toutes deux la transition.
  • Quand l'utiliser : transition dont la condition de garde peut s'exprimer entièrement dans un WHERE (état lu en base).
  • Quand l'éviter : si la condition de garde est calculée hors DB, ou si l'on doit retourner la row mise à jour (utiliser update + gestion P2025, mais l'idempotence est alors perdue).
  • Validé le : 05-05-2026
  • Contexte technique : Prisma / Postgres — app-alexandrie

Anti-pattern

// ❌ DANGEREUX : race entre findUnique et update
const thread = await prisma.thread.findUnique({ where: { id }, select: { visibilityStatus: true } });
if (thread?.visibilityStatus !== 'VISIBLE') return;
await prisma.thread.update({
  where: { id },
  data: { visibilityStatus: 'AUTO_HIDDEN', autoHiddenAt: new Date() },
});
// Deux requêtes concurrentes voient 'VISIBLE' et écrasent toutes deux autoHiddenAt.

Pattern correct

// ✅ updateMany filtre côté SQL → idempotence garantie par le SGBD
const result = await prisma.thread.updateMany({
  where: { id, visibilityStatus: 'VISIBLE' },
  data: { visibilityStatus: 'AUTO_HIDDEN', autoHiddenAt: new Date() },
});
if (result.count > 0) {
  logger.log(`Thread ${id} basculé (count=${result.count})`);
}

L'UPDATE ... WHERE est atomique au niveau row : pas de transaction explicite ni de SELECT ... FOR UPDATE. result.count === 0 = no-op idempotent (le perdant de la course).


Pattern : Récupération paginée via relation N-N — some plutôt que double findMany

  • Objectif : paginer une liste d'entités qui satisfont une relation N-N sans charger en mémoire un set intermédiaire non borné (risque DoS).
  • Contexte : "récupérer une liste paginée d'entités E qui satisfont une relation N-N (UserPack, Member, Tag)". La version naïve fait deux findMany séquentiels — le premier sans take, donc non borné si la relation explose (1000+ rows).
  • Quand l'utiliser : tout listing paginé dont le filtre passe par une relation N-N.
  • Quand l'éviter : si le set intermédiaire est borné par construction et petit (quelques rows).
  • Validé le : 27-05-2026
  • Contexte technique : Prisma — app-alexandrie

Anti-pattern (DoS-able si la relation explose)

// ❌ Charge potentiellement N×1000 lignes avant pagination
const sharingUserPacks = await prisma.userPack.findMany({
  where: { packId: { in: myPacks.map(p => p.packId) } },
  select: { userId: true },
  distinct: ['userId'],
});
const users = await prisma.user.findMany({
  where: { id: { in: sharingUserPacks.map(p => p.userId) } },
  take: limit + 1,
});

Pattern recommandé

// ✅ Pagination bornée au niveau User, pas de chargement intermédiaire
const myPacks = await prisma.userPack.findMany({
  where: { userId: currentUserId, revokedAt: null },
  select: { packId: true },
});
const users = await prisma.user.findMany({
  where: {
    id: { not: currentUserId },
    deletedAt: null,
    userPacks: { some: { packId: { in: myPacks.map(p => p.packId) }, revokedAt: null } },
  },
  orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
  take: limit + 1,
});

Prisma génère un sous-select EXISTS borné par l'orderBy + take du niveau supérieur. L'index utilisé est celui de la jointure (UserPack (userId, packId)).


Pattern : Raw SQL via $queryRawUnsafe quand Prisma est encapsulé dans une façade

  • Objectif : écrire une requête raw SQL paramétrée quand l'accès DB passe par un service-façade qui ne réexporte pas le tag template $queryRaw.
  • Contexte : Prisma encapsulé dans un PrismaService (façade NestJS qui ne réexpose que les modèles + quelques méthodes). Le tag template $queryRaw\...` n'est PAS disponible (Property '$queryRaw' does not exist), mais $queryRawUnsafe(query, ...values)` l'est.
  • Quand l'utiliser : raw SQL nécessaire (agrégations, requêtes non exprimables via le query builder) à travers une façade Prisma.
  • Quand l'éviter : si la requête s'exprime via le query builder Prisma (préférer le typage natif).
  • Validé le : 08-06-2026
  • Contexte technique : NestJS / Prisma façade — app-alexandrie

Règle

// Paramètres positionnels $1, $2… → toujours paramétrés, jamais d'interpolation
const rows = await this.prisma.$queryRawUnsafe<Row[]>(
  'SELECT COUNT(*) AS cnt FROM members WHERE tenant_id = $1',
  tenantId,
);
const total = Number(rows[0].cnt); // bigint → Number
  • $queryRawUnsafe n'est "unsafe" que par son nom : Unsafe désigne le fait que le SQL est une string libre (non validée par Prisma), PAS l'absence de paramétrage. Avec des $n paramétrés il est aussi sûr que le tag template — jamais d'interpolation de chaîne dans le SQL.
  • ⚠️ Noms de tables/colonnes = noms DB réels (@map/@@map, souvent snake_case), pas les noms du client Prisma (camelCase). Une requête raw contourne le mapping → vérifier le schéma avant d'écrire le SQL.
  • Caster les agrégats : selon le driver, COUNT(...) revient en bigintNumber(row.cnt) côté TS.
  • Avant de suivre une tech-spec qui écrit du raw, vérifier ce que la façade expose réellement (grep des usages raw existants) plutôt que supposer l'API Prisma standard.

Pattern : Consommation concurrente d'un token usage-unique — condition dans le WHERE de l'UPDATE

  • Objectif : rendre un token (ou flag) usage-unique sous concurrence (2 POST simultanés avec le même token) sans double consommation.
  • Contexte : sous READ COMMITTED (défaut Postgres/Prisma), un findFirst(tokenHash) + update(WHERE id) séparés laissent les deux lectures voir le token vivant → les deux updates réussissent (double consommation).
  • Quand l'utiliser : consommation atomique d'une ressource usage-unique (token, ticket, slot) sous concurrence possible (double-clic, retry, multi-onglets).
  • Quand l'éviter : ressource sans contrainte d'usage unique.
  • Validé le : 16-06-2026
  • Contexte technique : Prisma / Postgres — RL799_V2 (Lot C Keycloak onboarding)

Règle

Faire un updateMany dont le WHERE porte la condition de consommation (le hash encore présent), pas seulement un SELECT préalable. Le verrou de ligne sérialise les transactions concurrentes : le 2e voit count: 0 → traiter comme token_not_found.

const { count } = await tx.delivery.updateMany({
  where: { id, onboardingTokenHash: hash },
  data: { keycloakSub, onboardingTokenHash: null },
});
if (count === 0) return { ok: false, reason: 'token_not_found' };

update (par @id) ne permet pas un WHERE composite → updateMany est l'outil, en lisant result.count.

Garde-fou complémentaire : un re-pointage de colonne @unique peut lever P2002 sous race (un concurrent prend la valeur entre check et update) → catcher P2002 et le mapper en « collision » plutôt que 500.

Test obligatoire : Promise.all([POST, POST]) même token → attendu [200, 400].


Pattern : « FK + snapshot label dérivé serveur » ≠ « FK + texte libre saisi client »

  • Objectif : distinguer deux conceptions « FK + texte » visuellement identiques mais sémantiquement opposées, pour ne pas produire un label falsifiable ou une donnée perdue.
  • Contexte : un enregistrement référence une autre entité ET veut afficher son libellé même après disparition de la cible.
  • Quand l'utiliser : tout « sujet / cible » d'une entité pointant une autre entité supprimable.
  • Validé le : 18-06-2026
  • Contexte technique : Prisma / Next.js App Router — RL799_V2 (chantier ODJ)

Les deux patterns

  • (a) Texte libre client (ex. plancheAuthorId FK ⊕ plancheAuthorName saisi) : deux modes exclusifs, tous deux fournis par le client. Le handler prend le nom tel quel. Convient quand la cible peut ne pas exister en base (auteur non-membre).
  • (b) Snapshot dérivé serveur (ex. subjectProfaneId/subjectUserId FK + subjectLabel calculé) : le client envoie seulement l'id, le backend résout firstName/lastName de la cible et fige le label. Anti-falsification + survie à la suppression de la cible.

Piège

Croire que (b) « imite » (a). NON — (a) ne fait aucune résolution serveur. Implémenter (b) en copiant (a) produit un label client falsifiable et désynchronisé.

Règle

  • Sujet/cible référençant une entité connue → pattern (b) : résolution + snapshot côté serveur à l'écriture, FK onDelete: SetNull.
  • Cible hors-base → pattern (a).

Corollaire sur onDelete: SetNull

Sa justification dépend du cycle de vie réel de la cible :

  • réellement déclenché si la cible est hard-deleted (ex. Profane DELETE à l'admission → le snapshot est indispensable) ;
  • purement garde-fou FK si la cible est soft-deleted/anonymisée (ex. User jamais hard-deleted → le snapshot survit trivialement).

Ne pas copier le rationale d'un cas à l'autre.


Pattern : Migration Postgres String/Int → enum (backfill défensif + cast sans downtime)

  • Objectif : durcir une colonne String libre ou Int en enum Postgres sans déploiement raté ni état hybride, en préservant l'audit et sans downtime.
  • Contexte : opération d'hygiène DB la plus fréquente et la plus piégeuse — la migration plante au premier INSERT/valeur qui ne matche pas l'enum, et Int → enum n'accepte pas le cast direct.
  • Quand l'utiliser : conversion d'une colonne à valeurs finies (status, type, grade) vers un enum.
  • Quand l'éviter : champ réellement libre (texte saisi), ou snapshot historique volontairement laissé en String?/Int? (cf. cascade ci-dessous).
  • Validé le : 11-05-2026
  • Contexte technique : Prisma 7 + PostgreSQL 16 — RL799_V2

Étape 0 — Backfill défensif (pré-scan AVANT toute migration)

Avant TOUTE conversion, exécuter un script de pré-scan qui :

  1. liste les valeurs distinctes en DB : SELECT DISTINCT col FROM table (avec cardinalités) ;
  2. compare aux valeurs attendues par l'enum (issues du DTO as const / du schéma Zod) ;
  3. identifie les orphelins (présents en DB mais pas dans l'enum) ;
  4. pour chaque orphelin, décision explicite avant la migration : mapper (UPDATE col = 'new' WHERE col = 'orphan'), NULLifier (si nullable), ou rejeter la migration si l'orphelin révèle un bug applicatif.

Anti-pattern : lancer prisma migrate deploy en pensant « la DB est cohérente parce que l'app valide via Zod » — la valeur peut venir d'un ancien feature flag, d'un import historique, d'une console SQL admin. (Cas RL799 V1.1 : pré-scan exécuté, 0 orphelin sur 7 colonnes → migration appliquée en confiance.)

Cas A — String → enum (cast direct natif, pas de colonne tampon)

-- 1. Créer l'enum
CREATE TYPE "Grade" AS ENUM ('Apprenti', 'Compagnon', 'Maitre');

-- 2. Si la colonne a un DEFAULT, le drop avant ALTER TYPE
ALTER TABLE "Document" ALTER COLUMN "grade" DROP DEFAULT;

-- 3. Cast direct (Postgres accepte String → enum via USING)
ALTER TABLE "Document"
  ALTER COLUMN "grade" TYPE "Grade" USING "grade"::"Grade";

-- 4. Re-poser le DEFAULT typé enum
ALTER TABLE "Document" ALTER COLUMN "grade" SET DEFAULT 'Apprenti'::"Grade";

Les contraintes UNIQUE sur la colonne sont préservées automatiquement par Postgres tant que le nouveau type accepte les mêmes valeurs — pas de drop+recreate.

Cas B — String? → enum NOT NULL (backfill des NULL AVANT le SET NOT NULL)

Le cast direct fonctionne sans colonne tampon, mais il faut backfiller les NULL avant SET NOT NULL, sinon il lève à la fin.

-- 1. Backfill des NULL historiques vers la valeur par défaut métier
UPDATE "table" SET "col" = 'DefaultValue' WHERE "col" IS NULL;

-- 2. Cast direct text → enum
ALTER TABLE "table" ALTER COLUMN "col" TYPE "MyEnum" USING "col"::"MyEnum";

-- 3. SET NOT NULL après le backfill
ALTER TABLE "table" ALTER COLUMN "col" SET NOT NULL;

-- 4. Garde-fou anti-NULL résiduel (la NOT NULL bloquerait déjà, mais log explicite pour le debug)
DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM "table" WHERE "col" IS NULL) THEN
    RAISE EXCEPTION 'table.col contient des NULL après backfill — anomalie';
  END IF;
END $$;

Cas C — Int → enum (cast direct REFUSÉ → colonne tampon obligatoire)

Postgres refuse ALTER COLUMN x TYPE myEnum USING x::myEnum quand x est INTEGER, même avec un USING explicite. Passer par une colonne tampon + UPDATE CASE WHEN, sans downtime (expand/contract) :

-- 1. Enum cible
CREATE TYPE "Grade" AS ENUM ('Apprenti', 'Compagnon', 'Maitre');

-- 2. Colonne tampon du type cible (expand)
ALTER TABLE "OdjItem" ADD COLUMN "grade_new" "Grade";

-- 3. Remplir via UPDATE CASE/WHEN
UPDATE "OdjItem"
SET "grade_new" = CASE "grade"
  WHEN 1 THEN 'Apprenti'::"Grade"
  WHEN 2 THEN 'Compagnon'::"Grade"
  WHEN 3 THEN 'Maitre'::"Grade"
END;

-- 4. Garde-fou : aucune ligne ne doit rester NULL après l'UPDATE
DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM "OdjItem" WHERE "grade_new" IS NULL AND "grade" IS NOT NULL) THEN
    RAISE EXCEPTION 'Migration grade : valeur Int hors mapping détectée';
  END IF;
END $$;

-- 5. Drop l'index éventuel sur l'ancienne colonne
DROP INDEX IF EXISTS "OdjItem_grade_idx";

-- 6. Swap : drop old, rename new, SET NOT NULL si besoin (contract)
ALTER TABLE "OdjItem" DROP COLUMN "grade";
ALTER TABLE "OdjItem" RENAME COLUMN "grade_new" TO "grade";
ALTER TABLE "OdjItem" ALTER COLUMN "grade" SET NOT NULL;

-- 7. Recréer l'index
CREATE INDEX "OdjItem_grade_idx" ON "OdjItem"("grade");

Pour une colonne Int? nullable : omettre le SET NOT NULL (étape 6) et adapter le garde-fou (WHERE grade_new IS NULL AND grade IS NOT NULL) pour ne pas crier sur les NULL légitimes.

Récapitulatif des deux casts

  • String → enum : USING natif accepté → pas de colonne tampon.
  • Int → enum : USING direct refusé → colonne tampon + UPDATE CASE/WHEN obligatoire.
  • Dans tous les cas : backfill défensif préalable + garde-fou DO $$ + drop/recreate du DEFAULT typé.

Cascade côté code (post-migration)

Après prisma generate, TypeScript révèle toutes les coercions implicites précédentes (x as Grade, comparaisons numériques) — effet iceberg : un fix SQL unique peut révéler 30-50 erreurs TS dormantes.

  • Helpers de conversion aux frontières : gradeToRank(g): 1|2|3 / rankToGrade(r): Grade exportés depuis @app/shared/utils (UI qui pivote par rang sans toucher au domain).
  • Snapshots historiques : laisser volontairement String?/Int? les colonnes de snapshot d'état (ex. Attendance.gradeAtTime). Le domain strict ne s'applique qu'aux entités vivantes.
  • Validation API : durcir les query params (?grade=) avec un type guard isGrade(s): s is Grade qui rejette aussi lowercase/abréviations.

Bug latent typique capté : un === 'apprenti' (lowercase) qui ne matche jamais 'Apprenti' (TitleCase) — invisible en string, signalé immédiatement par TS après la bascule en enum. Le typage strict révèle, ne crée pas, ces bugs.

Vigilance

⚠️ Le pré-scan ne détecte PAS les index partiels avec littéraux text qui bloquent l'ALTER — cf. risque-index-partiel-text-alter-enum dans risques/prisma.md.

Checklist

  • Pré-scan des valeurs distinctes vs enum attendu, orphelins décidés explicitement
  • DEFAULT droppé avant ALTER TYPE, re-posé typé enum après
  • Int → enum : colonne tampon + garde-fou DO $$
  • String? → enum NOT NULL : backfill des NULL avant SET NOT NULL
  • Grep préalable des index partiels littéraux (cf. risque compagnon)
  • Cascade TS gérée (helpers de frontière, snapshots laissés souples)

Pattern : Extraire un util crypto/transverse neutre partagé entre deux domaines

  • Objectif : factoriser une mécanique technique partagée entre deux domaines métier (ici : tokens de réponse « quick-link » hashés sha256) sans coupler les domaines.
  • Contexte : deux domaines (convocations + instructions) partagent la même primitive crypto. Le piège est de réutiliser un repository du domaine A dans le domaine B. La distinction : on factorise un util transverse neutre (crypto), jamais du métier.
  • Quand l'utiliser : deux domaines partagent une mécanique purement technique (hash, génération de token, encodage).
  • Quand l'éviter : si le code partagé porte de la logique métier → préférer la duplication au couplage inter-domaines.
  • Validé le : 23-06-2026
  • Contexte technique : Prisma / monorepo — RL799_V2

Règles

  1. L'util (lib/responseToken.ts) ne connaît aucun domaine : pas d'import Prisma, pas d'import repository, JSDoc sans référence métier. Il produit/hashe, c'est tout.
  2. Chaque domaine pose son propre champ responseToken sur son modèle de delivery et gère son lookup.
  3. Pour ne pas casser les call-sites historiques pendant la migration, ré-exporter depuis l'ancien emplacement : export { hashResponseToken } from '@/lib/responseToken' — zéro modification chez le call-site legacy, zéro duplication.
  4. Vérifier l'absence de duplication résiduelle par grep ciblé sur la primitive (randomBytes(32), createHash('sha256')) — tolérer les redéfinitions locales en zone test pure.

Modèle de delivery « autonome »

Calquer la mécanique token d'un modèle existant (ConvocationDelivery) mais retirer toute la chaîne FK du domaine d'origine (issue/grade/mailLog/status) — ne garder que : id applicatif + 2 FK (parent métier + recipient) + responseToken @unique + timestamps. Migration : table créée vide, en-tête documentant explicitement « pas de backfill » + l'invariant d'isolation (zéro FK croisée).