mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-06-28 01:53:40 +02:00
f1b783407a
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>
446 lines
20 KiB
Markdown
446 lines
20 KiB
Markdown
# Backend — Patterns : Async
|
|
|
|
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet.
|
|
|
|
---
|
|
|
|
<a id="pattern-execution-asynchrone-taches-longues"></a>
|
|
|
|
## Pattern : Exécution asynchrone des tâches longues (queue + outbox light)
|
|
|
|
- Objectif : sortir les opérations longues ou fragiles du chemin request/response.
|
|
- Contexte : envoi d'emails, appels SaaS, génération de PDF, traitements batch, webhooks sortants.
|
|
- Quand l'utiliser : dès qu'une opération peut dépasser la latence acceptable ou dépendre d'un service externe.
|
|
- Quand l'éviter : opérations réellement instantanées et sans dépendances externes.
|
|
- Avantage :
|
|
- API plus rapide et plus fiable
|
|
- Retries maîtrisés
|
|
- Meilleure résilience aux pannes externes
|
|
- Limites / vigilance :
|
|
- Demande une discipline stricte sur l'idempotence
|
|
- Nécessite une stratégie minimale de dead-letter ou d'alerting
|
|
- Validé le : 25-01-2026
|
|
- Contexte technique : Backend agnostique + DB transactionnelle + worker
|
|
|
|
### Implémentation (exemple minimal)
|
|
|
|
```txt
|
|
- API écrit un job ou event en DB dans la transaction métier
|
|
- Worker lit les jobs en attente et exécute
|
|
- Retries avec backoff + compteur
|
|
- Statut FAILED ou dead-letter + alerte
|
|
- Idempotence par clé métier ou idempotency key
|
|
```
|
|
|
|
### Checklist
|
|
|
|
- Job créé dans une transaction (évite les pertes)
|
|
- Retries et backoff définis
|
|
- Dead-letter ou statut FAILED visible
|
|
- Idempotence garantie
|
|
- Logs corrélés (requestId/traceId)
|
|
|
|
---
|
|
|
|
<a id="pattern-webhooks-sortants-robustes-idempotents"></a>
|
|
|
|
## Pattern : Webhooks sortants robustes et idempotents
|
|
|
|
- Objectif : garantir des intégrations fiables avec des systèmes externes.
|
|
- Contexte : notifications, synchronisations, événements métier sortants.
|
|
- Quand l'utiliser : dès qu'un événement doit être transmis à un tiers.
|
|
- Quand l'éviter : intégrations strictement synchrones et internes.
|
|
- Avantage :
|
|
- Tolérance aux pannes réseau
|
|
- Retries maîtrisés
|
|
- Observabilité des échecs
|
|
- Limites / vigilance :
|
|
- Gestion des retries et du volume
|
|
- Nécessite une idempotence côté consommateur
|
|
- Validé le : 25-01-2026
|
|
- Contexte technique : Backend + HTTP + worker/queue
|
|
|
|
### Implémentation (exemple minimal)
|
|
|
|
```txt
|
|
- Événement persisté (outbox) en DB
|
|
- Envoi asynchrone via worker
|
|
- Retries avec backoff
|
|
- Signature du payload (HMAC)
|
|
- Idempotency key dans le header
|
|
```
|
|
|
|
### Checklist
|
|
|
|
- Payload signé et vérifiable
|
|
- Retries + backoff définis
|
|
- Dead-letter ou statut FAILED visible
|
|
- Idempotence documentée
|
|
- Logs corrélés (requestId/traceId)
|
|
|
|
---
|
|
|
|
<a id="pattern-hooks-fire-and-forget-creation-db"></a>
|
|
## Pattern : Hooks fire-and-forget après création DB critique
|
|
|
|
- Objectif : déclencher des hooks secondaires (mail accusé réception, notification, invalidation cache) après une création DB sans bloquer la réponse HTTP au client.
|
|
- Contexte : endpoint POST qui crée une ressource en DB et déclenche en cascade des hooks impliquant des appels réseau (Resend, FCM, Redis cache).
|
|
- Quand l'utiliser : hooks **rapides** (< 1-2 s) qui peuvent vivre dans le même process que la requête HTTP.
|
|
- Quand l'éviter : tâches lourdes (génération PDF, batch envoi sur 100 destinataires) — utiliser un vrai job queue (BullMQ, pg-boss).
|
|
- Avantage :
|
|
- la 201 part dès la création DB (l'AC critique de la route)
|
|
- chaque hook logge ses propres échecs sans bloquer le caller
|
|
- `Promise.allSettled` détaché → robustesse même si un hook futur ajoute un comportement async
|
|
- Limites / vigilance :
|
|
- dans Next.js 15+, préférer `after()` (cf. `knowledge/backend/patterns/nextjs.md`) qui garantit l'exécution post-réponse même en serverless
|
|
- `Promise.all` reject au premier échec — `allSettled` attend toutes les promesses
|
|
- tests : poll DB borné (`waitForX`) plutôt que `setTimeout(50)` (cf. `knowledge/backend/patterns/tests.md`)
|
|
- Validé le : 30-04-2026
|
|
- Contexte technique : Node.js — RL799_V2
|
|
|
|
### Implémentation
|
|
|
|
```typescript
|
|
// ✅ La 201 part dès la création DB ; les hooks tournent en parallèle
|
|
const created = await prisma.registration.create({ data });
|
|
|
|
// Promise.allSettled détaché : ne reject jamais, on capture quand même
|
|
// au cas où le service de log lui-même bug
|
|
void Promise.allSettled([
|
|
sendAcknowledgmentMail(data.email),
|
|
notifyObservers(created.id),
|
|
invalidateCache(`stats:${data.scope}`),
|
|
]).catch((err) => {
|
|
logger.error({ type: 'hooks', event: 'unexpected_error', err: String(err) });
|
|
});
|
|
|
|
return jsonResponse(201, { data: created });
|
|
```
|
|
|
|
### Règles d'utilisation
|
|
|
|
1. **L'AC critique doit être atteint avant** : la création DB doit réussir (await) — c'est le seul résultat que le client attend.
|
|
2. **Chaque hook doit logger ses propres échecs** : le service mail doit avoir son propre `logger.error` sur status=failed. Le `.catch()` du `Promise.allSettled` est un filet, pas le canal d'audit primaire.
|
|
3. **`Promise.allSettled` (pas `Promise.all`)** : robuste si un hook futur ajoute un comportement asynchrone derrière.
|
|
4. **Côté tests** : helper `waitForX` polling-borné plutôt que `setTimeout(N)` arbitraire.
|
|
|
|
---
|
|
|
|
<a id="pattern-fanout-notification-grade-plancher"></a>
|
|
## Pattern : Notification fanout fire-and-forget avec filtre grade plancher
|
|
|
|
- Objectif : notifier N destinataires éligibles (filtrage par grade plancher) après une mutation, sans bloquer la réponse HTTP et sans rollback de la création principale si la notif échoue.
|
|
- Contexte : action métier qui crée une ressource + doit notifier les membres dont le grade ≥ grade plancher de la ressource (`SOIREE_CANCELLED` à tous les membres, `COMMUNICATION_PUBLISHED` aux membres de grade ≥ X, etc.).
|
|
- Quand l'utiliser : fanout multi-rôles avec filtrage métier sur le profil destinataire.
|
|
- Quand l'éviter : si la notif est critique (la ressource ne doit pas exister sans notif) — utiliser une transaction.
|
|
- Avantage :
|
|
- seuil monotone `gradeRank(member) >= gradeRank(resource)` aligné sur les filtres `list*` consommateurs
|
|
- exclusion du créateur via `id: { not: userId }` pour éviter de se notifier soi-même
|
|
- log explicite sur `catch` du fire-and-forget — pas de perte silencieuse
|
|
- Limites / vigilance :
|
|
- pas de transaction avec la création principale : best-effort, dégradation acceptable
|
|
- le `linkUrl` doit être rôle-aware (cf. `knowledge/backend/risques/general.md` risque-notif-linkurl-non-role-aware)
|
|
- Validé le : 23-04-2026
|
|
- Contexte technique : Prisma — RL799_V2
|
|
|
|
### Implémentation
|
|
|
|
```typescript
|
|
const createResourceNotifications = async (input: {
|
|
resourceId: string;
|
|
grade: string; // plancher (seuil monotone)
|
|
excludeUserId?: string;
|
|
}): Promise<void> => {
|
|
const thresholdRank = gradeRank(input.grade);
|
|
|
|
const recipients = await prisma.user.findMany({
|
|
where: {
|
|
isActive: true,
|
|
role: { in: [...ROLES_ALL_ACTIVE] },
|
|
id: input.excludeUserId ? { not: input.excludeUserId } : undefined,
|
|
profile: { is: {} },
|
|
},
|
|
select: {
|
|
id: true,
|
|
role: true, // pour linkUrl rôle-aware si multi-rôles
|
|
profile: { select: { grade: true } },
|
|
},
|
|
});
|
|
|
|
const eligibleIds = recipients
|
|
.filter((r) => {
|
|
const g = r.profile?.grade;
|
|
if (!g) return false;
|
|
return gradeRank(g) >= thresholdRank;
|
|
})
|
|
.map((r) => r.id);
|
|
|
|
if (eligibleIds.length === 0) return;
|
|
|
|
await prisma.notification.createMany({
|
|
data: eligibleIds.map((recipientId) => ({
|
|
type: NotificationType.RESOURCE_CREATED,
|
|
recipientId,
|
|
// …
|
|
linkUrl: ..., // rôle-aware si nécessaire
|
|
})),
|
|
});
|
|
};
|
|
|
|
// Côté handler
|
|
try {
|
|
const resource = await createResource({ ... });
|
|
logAction(userId, 'resource:create', ...);
|
|
|
|
// Fire-and-forget
|
|
void createResourceNotifications({
|
|
resourceId: resource.id,
|
|
...minimumDataForNotif,
|
|
}).catch((err) => {
|
|
console.error('[resource:create] notification fanout failed:', err);
|
|
});
|
|
|
|
return jsonResponse(201, { data: serialize(resource) });
|
|
} catch {
|
|
return errorResponse(500, ...);
|
|
}
|
|
```
|
|
|
|
### Pourquoi un seuil monotone
|
|
|
|
`gradeRank(member) >= gradeRank(resource)` = "à partir du grade X", aligné sur les filtres `list*` consommateurs. Évite les sélections non-contiguës (A+M sans C) qui sont pénibles à représenter.
|
|
|
|
---
|
|
|
|
<a id="pattern-auto-purge-fenetre-temporelle-sql"></a>
|
|
## Pattern : Auto-purge côté vue via fenêtre temporelle SQL
|
|
|
|
- Objectif : faire porter la rétention courte par le filtre de lecture plutôt que par un cron de purge réelle, quand une donnée a deux publics avec des besoins de rétention différents.
|
|
- Contexte : donnée consultée à long terme côté admin/historique mais utile uniquement sur fenêtre courte côté consommateur final (membre lambda).
|
|
- Quand l'utiliser : 2 publics, rétention courte côté consommateur, rétention longue côté admin, volumétrie raisonnable.
|
|
- Quand l'éviter :
|
|
- volumétrie très élevée (millions de rows) — finir par un vrai archivage si le volume explose
|
|
- RGPD / obligations légales de suppression — il faut **vraiment** supprimer la donnée, pas la masquer
|
|
- données avec coût de stockage significatif (PDF, blobs, logs verbeux) — purge réelle + archivage externe
|
|
- Avantage :
|
|
- pas de cron à écrire, déployer, monitorer
|
|
- zéro risque de purge destructive : la donnée reste en DB
|
|
- rétention courte est **déclarative** (paramètre de query), pas cachée dans un job planifié
|
|
- l'admin conserve l'accès complet via un autre endpoint
|
|
- Limites / vigilance :
|
|
- index sur `createdAt` indispensable dès que la table grossit
|
|
- Validé le : 23-04-2026
|
|
- Contexte technique : Prisma / Postgres — RL799_V2
|
|
|
|
### Implémentation
|
|
|
|
```typescript
|
|
export const listRecentXxxForMember = async (
|
|
...filters,
|
|
sinceDays = 30,
|
|
) => {
|
|
const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000);
|
|
return prisma.xxx.findMany({
|
|
where: {
|
|
...filters,
|
|
createdAt: { gte: since },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
};
|
|
```
|
|
|
|
L'admin garde un endpoint distinct sans le filtre temporel pour l'accès historique complet.
|
|
|
|
---
|
|
|
|
<a id="pattern-test-rollback-pipeline-multi-fichiers"></a>
|
|
## Pattern : Test de rollback pour pipelines multi-fichiers atomiques
|
|
|
|
- Objectif : garantir qu'un pipeline qui écrit N fichiers avec rollback nettoie réellement l'état partiel quand le Nème échoue.
|
|
- Contexte : opération qui persiste plusieurs artefacts (ex : variantes/dérivés d'une image, fichiers d'un export) en gardant la liste des chemins déjà écrits pour pouvoir les supprimer en cas d'échec.
|
|
- Quand l'utiliser : tout pipeline « tout ou rien » sur N écritures de fichiers avec compensation manuelle (pas de transaction native).
|
|
- Quand l'éviter : écriture d'un seul fichier, ou stockage qui offre une vraie transactionnalité.
|
|
- Avantage :
|
|
- couvre le cas limite réel (échec en cours de pipeline) plutôt que le seul chemin nominal
|
|
- détecte une compensation incomplète (fichiers orphelins) ou excessive (suppression d'un fichier non écrit par ce pipeline)
|
|
- Limites / vigilance :
|
|
- le test doit être mis à jour quand N change (ajout d'un variant) pour couvrir le nouveau cas limite
|
|
- Validé le : 03-04-2026
|
|
- Contexte technique : Node.js / écriture fichiers — app-template-resto story 4.3
|
|
|
|
### Règle
|
|
|
|
Tout pipeline qui écrit N fichiers avec rollback (suppression des déjà-écrits si un échec survient) doit avoir un test unitaire couvrant le cas **« N-1 fichiers écrits + le Nème échoue »**. Ce test vérifie :
|
|
|
|
- que les N-1 fichiers déjà écrits sont **exactement** supprimés (ni plus, ni moins) ;
|
|
- que la phase de finalisation (`finalize()` ou équivalent) n'est **pas** appelée ;
|
|
- que l'erreur est bien propagée à l'appelant.
|
|
|
|
Quand le nombre d'artefacts change (ajout d'un variant), mettre à jour ce test pour couvrir le nouveau N.
|
|
|
|
---
|
|
|
|
<a id="pattern-promise-all-queries-db-independantes"></a>
|
|
## Pattern : Promise.all des queries DB indépendantes dans un service handler
|
|
|
|
- Objectif : réduire la latence d'un service handler dont les queries DB sont posées en série alors qu'elles n'ont aucune dépendance de données.
|
|
- Contexte : service handler API avec plusieurs `await` chaînés (lectures DB indépendantes).
|
|
- Quand l'utiliser : >2 `await` dans le corps du handler, dont les arguments ne dérivent pas du résultat précédent.
|
|
- Quand l'éviter :
|
|
- contrôles d'autorisation (auth → user lookup → permission) : garder en série pour fail-fast et ne pas gaspiller de queries sur une requête non autorisée
|
|
- boucles `for (...) await` à pagination progressive / arrêt anticipé : volontairement séquentielles
|
|
- Avantage :
|
|
- latence totale `~max()` au lieu de `~sum()`
|
|
- gain proportionnellement **plus grand** en prod réseau (chaque round-trip cumule en série, s'absorbe en parallèle)
|
|
- Limites / vigilance :
|
|
- les queries conditionnelles doivent être emballées en `Promise.resolve(default)` pour garder le type `Promise<T>` dans le tuple
|
|
- la transformation des résultats reste APRÈS le `Promise.all` ; les dérivations synchrones des seuls paramètres d'entrée peuvent passer AVANT
|
|
- Validé le : 11-05-2026
|
|
- Contexte technique : Next.js 16 API + Prisma 7 — RL799_V2
|
|
|
|
### Anti-pattern (séquentiel)
|
|
|
|
```ts
|
|
const odjItems = soireeId ? await findOdjItemsBySoiree(soireeId) : [];
|
|
const issues = await findLatestIssuesByTenueIds(tenueIds);
|
|
const soireeContext = soireeId ? await getSoireeContext(soireeId) : null;
|
|
const convocations = await findConvocationsByTenuesAndGrade(tenueIds, userId);
|
|
// total : ~sum(latence_de_chaque_query)
|
|
```
|
|
|
|
Symptômes : endpoint dans le top des plus lents, `curl -w "%{time_total}"` corrélé linéairement au nombre d'`await`, chaque query individuelle rapide (~50-70 ms) sans N+1 — c'est l'orchestration qui est en cause.
|
|
|
|
### Pattern (parallèle)
|
|
|
|
```ts
|
|
const [odjItems, issues, soireeContext, convocations] = await Promise.all([
|
|
soireeId ? findOdjItemsBySoiree(soireeId) : Promise.resolve([]),
|
|
findLatestIssuesByTenueIds(tenueIds),
|
|
soireeId ? getSoireeContext(soireeId) : Promise.resolve(null),
|
|
findConvocationsByTenuesAndGrade(tenueIds, userId),
|
|
]);
|
|
// total : ~max(latence_de_chaque_query)
|
|
```
|
|
|
|
### Détection
|
|
|
|
1. Mesurer (`curl -w "%{time_total}"` / `console.time()`), cibler les endpoints > 150 ms en local DB chaude.
|
|
2. Compter les `await` du handler ; >2 hors contrôles d'autorisation = suspect.
|
|
3. Pour chaque `await N+1`, vérifier si l'argument dérive du résultat de `await N`. Si NON → parallélisable.
|
|
|
|
### Tests
|
|
|
|
Le contrat de l'endpoint ne change pas. Les tests qui mockent les repositories passent sans modification. Si un test casse, c'est qu'il sur-spécifiait l'ordre d'exécution (anti-pattern de test).
|
|
|
|
Cas vécu : `dashboardService.handleProchaineTenue` — 4 queries séquentielles → `Promise.all`. 240 ms → 170 ms (-29% local, -40 à -50% projeté prod réseau), 1526/1526 tests verts sans modification.
|
|
|
|
---
|
|
|
|
<a id="pattern-cron-multi-instance-lock-redis"></a>
|
|
## Pattern : Cron multi-instance idempotent via lock Redis `SET NX EX`
|
|
|
|
- Objectif : garantir qu'un `@Cron` ne s'exécute qu'**une seule fois** en environnement multi-pods, sans introduire de scheduler distribué (Bull, etc.).
|
|
- Contexte : `@Cron` (`@nestjs/schedule`) déployé sur N instances avec effets de bord (notifications, mails de campagne).
|
|
- Quand l'utiliser : tout cron avec effet de bord non idempotent en déploiement multi-instance.
|
|
- Quand l'éviter : instance unique garantie, ou job purement idempotent.
|
|
- Avantage :
|
|
- aucune dépendance supplémentaire
|
|
- auto-libération du lock si le pod crashe (TTL)
|
|
- Limites / vigilance :
|
|
- TTL > durée du job mais < intervalle entre deux exécutions
|
|
- Redis down → renvoyer `false` (s'abstenir : ne pas risquer une double exécution d'effets de bord)
|
|
- expliciter l'expression cron ET la timezone — les raccourcis `CronExpression.*` partent en UTC serveur
|
|
- Validé le : 04-06-2026
|
|
- Contexte technique : NestJS / `@nestjs/schedule` / Redis — app-alexandrie
|
|
|
|
### Implémentation
|
|
|
|
```typescript
|
|
// Lock distribué : n'exécuter le corps QUE si SET NX renvoie 'OK'
|
|
async acquireLock(key: string, ttlSec: number): Promise<boolean> {
|
|
const res = await client.set(key, '1', { NX: true, EX: ttlSec });
|
|
return res === 'OK';
|
|
}
|
|
|
|
@Cron('0 8 * * 1', { timeZone: 'Europe/Paris' }) // expression + TZ explicites
|
|
async runWeeklyJob() {
|
|
if (!(await this.acquireLock('cron:weekly-job', 600))) return; // un autre pod a gagné, ou Redis down
|
|
// ... corps du job ...
|
|
}
|
|
```
|
|
|
|
### Règle
|
|
|
|
Expliciter l'expression cron ET la timezone (`{ timeZone: 'Europe/Paris' }`) : les raccourcis `CronExpression.EVERY_WEEK` / `EVERY_DAY_AT_9AM` s'exécutent en UTC (heure serveur) → décalage vs l'intention métier.
|
|
|
|
---
|
|
|
|
<a id="pattern-verrou-une-action-par-entite-updatemany"></a>
|
|
## Pattern : Verrou atomique « une action par entité » via `updateMany` conditionnel
|
|
|
|
- Objectif : garantir qu'une action ne peut être déclenchée qu'**au plus une fois** par entité (relance, remboursement, envoi de mail de campagne, confirmation), résistant aux requêtes concurrentes.
|
|
- Contexte : endpoint qui déclenche un effet de bord one-shot marqué par un champ (`sentAt`, `refundedAt`...).
|
|
- Quand l'utiliser : tout flow « au plus une fois par entité » exposé à des appels concurrents.
|
|
- Quand l'éviter : action naturellement idempotente côté effet de bord.
|
|
- Distinction avec le lock Redis cron : ici le verrou est porté par la **DB** (champ d'état de l'entité, atomicité de l'`UPDATE ... WHERE`), pas par un lock Redis externe ; il protège une action par entité, pas une exécution unique de job multi-pods.
|
|
- Avantage :
|
|
- guard précoce (fast-path) qui évite le coût des requêtes intermédiaires pour les appels normaux
|
|
- `updateMany` conditionnel atomique qui tranche la race condition
|
|
- Limites / vigilance :
|
|
- le `if (alreadySent) return 409` initial seul **ne suffit pas** : deux requêtes concurrentes le passent toutes les deux
|
|
- `lock.count === 0` = une autre requête a gagné la course → 409
|
|
- Validé le : 20-06-2026
|
|
- Contexte technique : Prisma / Postgres — RL799_V2
|
|
|
|
### Implémentation
|
|
|
|
```typescript
|
|
// Guard précoce (optimistic fast-path, non atomique — suffit pour 99% des cas)
|
|
if (entity.sentAt) return errorResponse(409, 'ALREADY_SENT', '...');
|
|
|
|
// ... traitement coûteux ...
|
|
|
|
// Verrou atomique : UPDATE conditionnel WHERE sentAt IS NULL
|
|
const lock = await prisma.entity.updateMany({
|
|
where: { id: entityId, sentAt: null },
|
|
data: { sentAt: new Date() },
|
|
});
|
|
if (lock.count === 0) {
|
|
return errorResponse(409, 'ALREADY_SENT', '...');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
<a id="pattern-persist-then-send-statut-apres-effet"></a>
|
|
## Pattern : Effet de bord externe avec statut persistant — « persist-then-send »
|
|
|
|
- Objectif : ne pas faire mentir un statut métier qui reflète l'état d'un effet de bord externe, en cas d'échec partiel.
|
|
- Contexte : opération qui (1) persiste une donnée nécessaire à une vérification ultérieure (token hash, idempotency key), (2) déclenche un effet externe best-effort (mail, webhook), (3) expose un statut (`pending|emailed|active`).
|
|
- Quand l'utiliser : dès que le statut exposé doit refléter l'état RÉEL de l'effet externe et que ce dernier peut échouer.
|
|
- Quand l'éviter : pas d'effet externe, ou statut sans valeur métier (rejouabilité non requise).
|
|
- Avantage :
|
|
- tout artefact émis reste traçable côté serveur (donnée vérifiable posée d'abord)
|
|
- le statut reste `pending` (rejouable) si l'effet externe échoue
|
|
- Limites / vigilance :
|
|
- exige DEUX écritures (poser la donnée vérifiable, puis marquer le statut) — pas un seul `update` atomique
|
|
- Validé le : 16-06-2026
|
|
- Contexte technique : Prisma / Resend / Keycloak — RL799_V2
|
|
|
|
### Règle
|
|
|
|
Séparer en deux écritures : d'abord poser la donnée vérifiable SANS toucher au statut, PUIS — seulement si l'effet externe réussit — faire passer le statut. Ordre `persist token → send → mark status`.
|
|
|
|
```txt
|
|
// ✅ token toujours vérifiable même si le mail rate ; statut = état réel (reste pending → rejouable)
|
|
setOnboardingToken(...) // hash + expiresAt, statut INCHANGÉ
|
|
await executeActionsEmail() // effet externe (attendre le 204)
|
|
markOnboardingEmailed(...) // statut passé à 'emailed' APRÈS confirmation
|
|
|
|
// ❌ un seul update atomique avant l'envoi : le statut ment si Resend/Keycloak est down
|
|
update({ status: 'emailed', tokenHash, expiresAt })
|
|
```
|
|
|
|
Cas vécu : RL799 Lot B onboarding Keycloak — `setOnboardingToken` (statut inchangé) puis `markOnboardingEmailed` (statut après 204 confirmé de `executeActionsEmail`).
|