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