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>
27 KiB
Backend — Patterns : Prisma
Extrait de la base de connaissance Lead_tech. Voir
knowledge/backend/patterns/README.mdpour 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
wheredans unincludeto-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
includesur 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,updateouupsertPrisma sur un champ@uniquealimenté 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
P2002intercepté 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
DecimalPrisma traversent les couches et causent des erreurs de sérialisation JSON silencieuses. - Contexte : tout champ
Decimalen Prisma (ex:price) retourné via API ou Server Action. - Quand l'utiliser : systématiquement sur tout champ
Decimaldans les repositories. - Risque si ignoré :
Decimaln'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 avecP3014 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 existequand 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étierlogActionSync(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
createAuditLogqui 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/closedoù 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
statusdiscriminante 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 formataccidentel pourrait droper l'index
- Prisma ne supporte pas les unique partials en
- 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
P2002traduit en code métier (RACE_CONFLICT, 409) - Test de race :
Promise.all([resend(), resend()])puiscount({ 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 queUser.idreste 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.idsont des UUID RFC 4122 →.uuid()activable - lisibilité préservée : le code de tests reste sémantique
- déterminisme :
- 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)
- le slug ne doit JAMAIS être persisté en clair (mapping explicite
- 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 écriventTEST_SECRETARY.id, pas'secretaire' - les users ad-hoc éphémères (créés/supprimés dans le scope d'un test) utilisent
randomUUID(), passeedUserId()— 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 à unRAISE. - 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
- une migration doit rester rejouable sur une DB vide ET une DB peuplée — c'est le contrat de
- 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éfinitifd'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
WHEREduupdateMany(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
- La révocation vit dans le même
updateManyque la transition principale. - La capture de la valeur précédente est sous
SELECT FOR UPDATEdans la transaction. - Les effets de bord (audit, notif) sortent de la transaction.
- 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 TABLEpatchwork 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 + CREATEquand 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_dumpavant migration) - Smoke test post-deploy (login, création, magic link)