@nodefony/framework 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- package/package.json +83 -0
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Idempotence des mutations"
|
|
3
|
+
lang: fr
|
|
4
|
+
module: "@nodefony/framework"
|
|
5
|
+
topic: idempotence
|
|
6
|
+
section: "Cœur runtime"
|
|
7
|
+
audience: [developer]
|
|
8
|
+
tags:
|
|
9
|
+
[
|
|
10
|
+
idempotence,
|
|
11
|
+
mutations,
|
|
12
|
+
idempotency-key,
|
|
13
|
+
resilience,
|
|
14
|
+
securite,
|
|
15
|
+
stores,
|
|
16
|
+
http,
|
|
17
|
+
websocket,
|
|
18
|
+
]
|
|
19
|
+
version: "doc"
|
|
20
|
+
status: stable
|
|
21
|
+
updated: 2026-07-19
|
|
22
|
+
source: "src/packages/@nodefony/framework/docs/idempotence.md"
|
|
23
|
+
coverageModule: framework
|
|
24
|
+
coverageFiles: idempotency.ts,IdempotencyStore.ts,RedisIdempotencyStore.ts,idempotencyGc,idempotencyStoreRegistry,IIdempotencyStore
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
# Idempotence des mutations
|
|
28
|
+
|
|
29
|
+
> Ton client envoie `POST /charge`, le réseau coupe avant la réponse, il retente. Sans garde-fou, tu
|
|
30
|
+
> débites deux fois. L'idempotence garantit qu'une mutation **rejouée à l'identique ne s'exécute
|
|
31
|
+
> qu'une fois** — la seconde reçoit la réponse mémorisée de la première. Nodefony décide toute la
|
|
32
|
+
> sémantique (statuts, scope, empreinte) dans **un helper pur** partagé par HTTP et WebSocket, puis
|
|
33
|
+
> délègue le stockage à un **store pluggable** (mémoire, Redis, SQL).
|
|
34
|
+
|
|
35
|
+
📍 [Documentation](../../../../../docs/index.md) › [Framework](index.md) › **Idempotence**
|
|
36
|
+
|
|
37
|
+
## 🧠 Le modèle mental — un verrou par intention
|
|
38
|
+
|
|
39
|
+
C'est LA chose à comprendre. `store.begin(clé, empreinte)` est **atomique** et rend l'un de quatre
|
|
40
|
+
verdicts (`IdempotencyOutcome`, `src/nodefony/src/types/IIdempotencyStore.ts:34`) ; le helper les
|
|
41
|
+
traduit en une décision neutre que le pipeline applique.
|
|
42
|
+
|
|
43
|
+
```mermaid
|
|
44
|
+
stateDiagram-v2
|
|
45
|
+
[*] --> sans_cle : mutation SANS Idempotency-Key
|
|
46
|
+
sans_cle --> execute : mode souple (HTTP) → exécute sans mémoriser
|
|
47
|
+
sans_cle --> rejet400 : mode strict, ou WebSocket
|
|
48
|
+
[*] --> begin : clé + identité + store
|
|
49
|
+
begin --> fresh : 1re fois → réservation, puis complete() / abort()
|
|
50
|
+
begin --> replayed : déjà complétée → réponse mémorisée, 0 exécution
|
|
51
|
+
begin --> in_flight : exécution identique en cours → 409
|
|
52
|
+
begin --> mismatch : même clé, AUTRE corps → 422
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Les quatre verdicts du store et leur traduction, décidés par `evaluateIdempotency()`
|
|
56
|
+
(`idempotency.ts:142`) :
|
|
57
|
+
|
|
58
|
+
- **fresh** → tu détiens la réservation : exécute l'action, puis **obligatoirement** `complete(clé,
|
|
59
|
+
réponse)` (succès) ou `abort(clé)` (échec). Verdict rendu : `guarded`.
|
|
60
|
+
- **replayed** → la réponse de la 1ʳᵉ exécution est renvoyée telle quelle, l'action **n'est pas
|
|
61
|
+
rejouée**. Verdict rendu : `replay`.
|
|
62
|
+
- **in-flight** → une exécution identique est **déjà en cours** → `409` (le client réessaiera).
|
|
63
|
+
- **mismatch** → la clé a déjà servi pour un **autre** payload → `422` (`idempotency.ts:189`).
|
|
64
|
+
|
|
65
|
+
S'il ne faut retenir qu'une image : `begin` est un **verrou atomique par intention**, et tout le
|
|
66
|
+
reste du pipeline ne fait que traduire son verdict.
|
|
67
|
+
|
|
68
|
+
## 📖 Lexique
|
|
69
|
+
|
|
70
|
+
| Terme | Sens (dans ce module) |
|
|
71
|
+
| ----------------- | -------------------------------------------------------------------------------------------------- |
|
|
72
|
+
| Mutation | Méthode non sûre : `POST`/`PUT`/`PATCH`/`DELETE` (RFC 9110 §9.2.1). GET/HEAD/OPTIONS = no-op. |
|
|
73
|
+
| `Idempotency-Key` | En-tête client : un identifiant par **intention** d'écriture (convention Stripe, draft IETF). |
|
|
74
|
+
| Empreinte | SHA-256 de `route + params + corps` — détecte une clé réutilisée pour autre chose. |
|
|
75
|
+
| Bail (_lease_) | Durée pendant laquelle une réservation _in-flight_ tient sans `complete`/`abort` (60 s). |
|
|
76
|
+
| Rétention (TTL) | Durée pendant laquelle une réponse mémorisée reste rejouable (10 min). |
|
|
77
|
+
| in-flight | Réservation posée, pas encore complétée ni abandonnée. |
|
|
78
|
+
| Verdict | Décision neutre du helper : `execute` / `guarded` / `replay` / `reject`. |
|
|
79
|
+
| Scope de clé | La clé réellement stockée = `[identité, clé client]` → anti-IDOR. |
|
|
80
|
+
| IDOR | _Insecure Direct Object Reference_ : lire la donnée d'autrui en devinant son identifiant. |
|
|
81
|
+
| Store | Backend qui porte les clés (`memory`, `redis`, `drizzle`) derrière le contrat `IIdempotencyStore`. |
|
|
82
|
+
| GC | _Garbage Collection_ : purge périodique des clés expirées — nécessaire seulement sans TTL natif. |
|
|
83
|
+
| Fail-soft | Le store indisponible n'échoue pas la mutation : elle s'exécute **sans** dédup. |
|
|
84
|
+
| Fail-loud | Un store distribué demandé mais non câblé fait échouer le boot plutôt que dédupliquer en silence. |
|
|
85
|
+
|
|
86
|
+
## Qu'est-ce que c'est ?
|
|
87
|
+
|
|
88
|
+
Imagine un **ticket de vestiaire**. Tu déposes ton manteau, on te donne un numéro. Si tu présentes
|
|
89
|
+
deux fois le même numéro, on ne te donne pas deux manteaux : on te rend **le même**. La clé
|
|
90
|
+
d'idempotence est ce numéro — le client la choisit, le serveur s'engage à ne servir l'intention
|
|
91
|
+
qu'une fois.
|
|
92
|
+
|
|
93
|
+
Sans ce ticket, trois choses cassent :
|
|
94
|
+
|
|
95
|
+
1. **Double-effet sur rejeu** — le cœur du problème. Un retry réseau, une reconnexion WebSocket, un
|
|
96
|
+
double-clic : la même intention arrive deux fois et produit deux débits, deux commandes, deux
|
|
97
|
+
e-mails. Le client ne peut pas distinguer « la requête a échoué » de « la réponse s'est perdue »,
|
|
98
|
+
donc il **doit** retenter ; c'est au serveur de rendre le retry inoffensif.
|
|
99
|
+
2. **IDOR sur le cache** — si la clé stockée était la clé brute du client, il suffirait de deviner
|
|
100
|
+
`order-42` pour lire la **réponse mémorisée d'un autre utilisateur**. Nodefony stocke
|
|
101
|
+
`JSON.stringify([identité, cléClient])` (`idempotency.ts:182`) : l'anti-IDOR n'est pas un contrôle
|
|
102
|
+
ajouté, il est **structurel**, encodé dans la clé de cache. Le JSON sert de frontière non ambiguë
|
|
103
|
+
(aucun séparateur magique qui pourrait entrer en collision).
|
|
104
|
+
3. **DoS du cache** — une clé arbitrairement longue, ou un flot de clés uniques, remplirait la
|
|
105
|
+
mémoire. Une clé > 255 octets est traitée comme **absente** plutôt que stockée
|
|
106
|
+
(`IDEMPOTENCY_KEY_MAX`, `idempotency.ts:36`) et le cache mémoire est **borné** (`DEFAULT_CAP`,
|
|
107
|
+
`IdempotencyStore.ts:18`).
|
|
108
|
+
|
|
109
|
+
> [!IMPORTANT]
|
|
110
|
+
> L'idempotence n'est pas qu'une commodité de résilience : c'est une brique de **sécurité**. Une
|
|
111
|
+
> mutation rejouable sans garde-fou est un vecteur d'abus financier (double débit provoqué), et un
|
|
112
|
+
> cache mal scopé est une fuite de données entre comptes.
|
|
113
|
+
|
|
114
|
+
## La vision Nodefony
|
|
115
|
+
|
|
116
|
+
Le constat de conception qui compte : la sémantique IETF (quels statuts, quel scope, quelle
|
|
117
|
+
empreinte) est décidée dans **un helper pur**, `evaluateIdempotency()` (`idempotency.ts:142`), qui ne
|
|
118
|
+
connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `idempotency.ts:50`)
|
|
119
|
+
que **deux** appelants traduisent dans leur monde :
|
|
120
|
+
|
|
121
|
+
- le **data plane admin** — `AdminApiController.idempotencyGate()`
|
|
122
|
+
(`AdminApiController.ts:158`) → réponse `{status, headers, body}` ;
|
|
123
|
+
- les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
|
|
124
|
+
(`Resolver.ts:460`) → `nodefonyError` typée, ou réponse rejouée.
|
|
125
|
+
|
|
126
|
+
Conséquence pratique : il est **impossible** que l'idempotence HTTP et l'idempotence WebSocket
|
|
127
|
+
divergent — elles partagent la même fonction. C'est le différenciateur du framework (HTTP + WS
|
|
128
|
+
co-citoyens dans le même contexte controller) appliqué à une brique de sécurité.
|
|
129
|
+
|
|
130
|
+
Second choix structurant : **le stockage est pluggable**. Le contrat `IIdempotencyStore` vit au
|
|
131
|
+
**CORE** (`src/nodefony/src/types/IIdempotencyStore.ts:106`), pas dans `@nodefony/framework` — pour
|
|
132
|
+
que `@nodefony/redis` et `@nodefony/drizzle`, qui sont **sous** framework dans le graphe de
|
|
133
|
+
dépendances, puissent l'implémenter sans créer de cycle.
|
|
134
|
+
|
|
135
|
+
## 🚀 Démarrage rapide
|
|
136
|
+
|
|
137
|
+
Objectif : rendre `POST /api/payments/charge` rejouable sans double débit, dans une app générée par
|
|
138
|
+
`nodefony create app`.
|
|
139
|
+
|
|
140
|
+
### 1. Le controller — une seule ligne à ajouter
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
// nodefony/controllers/PaymentController.ts — complet, compile tel quel
|
|
144
|
+
import {
|
|
145
|
+
controller,
|
|
146
|
+
Controller,
|
|
147
|
+
Post,
|
|
148
|
+
Body,
|
|
149
|
+
Idempotent,
|
|
150
|
+
} from "@nodefony/framework";
|
|
151
|
+
|
|
152
|
+
interface ChargeInput {
|
|
153
|
+
amount: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@controller("/api/payments")
|
|
157
|
+
class PaymentController extends Controller {
|
|
158
|
+
// STRICT par défaut : une mutation SANS `Idempotency-Key` est rejetée 400,
|
|
159
|
+
// AVANT que ton code ne tourne. Le rejeu de la MÊME clé renvoie la réponse
|
|
160
|
+
// mémorisée sans ré-exécuter l'action.
|
|
161
|
+
@Idempotent()
|
|
162
|
+
@Post("/charge")
|
|
163
|
+
async charge(@Body() body: ChargeInput) {
|
|
164
|
+
// ⚠️ Retourne le PAYLOAD BRUT (pas `this.renderJson(...)`) : c'est cette
|
|
165
|
+
// valeur qui est mémorisée et rejouée telle quelle.
|
|
166
|
+
return { chargeId: "ch_9F2a", amount: body.amount };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export default PaymentController;
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
(Wiring : `@controllers([PaymentController])` dans le module de l'app — `nodefony create controller`
|
|
174
|
+
le fait pour toi.)
|
|
175
|
+
|
|
176
|
+
### 2. Le store — rien à écrire en dev, un mot en cluster
|
|
177
|
+
|
|
178
|
+
Le store par défaut est **posé automatiquement** par le module framework (`@services([… ,
|
|
179
|
+
MemoryIdempotencyStore])`, `src/packages/@nodefony/framework/index.ts:40`). En mono-pod, tu n'as
|
|
180
|
+
rien à configurer. Pour un cluster multi-pod, nomme un store **distribué** :
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
// nodefony.config.ts — extrait
|
|
184
|
+
use("@nodefony/framework", {
|
|
185
|
+
idempotency: {
|
|
186
|
+
// "auto" (défaut) suit l'infra déclarée. En cluster, nommer explicitement :
|
|
187
|
+
// un nom distribué non câblé fait ÉCHOUER le boot en production (fail-loud)
|
|
188
|
+
// plutôt que dédupliquer per-pod en silence.
|
|
189
|
+
store: "redis",
|
|
190
|
+
// Purge des clés expirées — utile UNIQUEMENT pour un store SQL (drizzle).
|
|
191
|
+
gcIntervalS: 600,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### 3. Ce qu'on observe
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# 1) Sans clé : rejet AVANT le controller (mode strict)
|
|
200
|
+
curl -si -X POST http://localhost:5151/api/payments/charge \
|
|
201
|
+
-H 'Content-Type: application/json' -d '{"amount":4200}' | head -1
|
|
202
|
+
# HTTP/1.1 400 Bad Request ← "Idempotency-Key required"
|
|
203
|
+
|
|
204
|
+
# 2) 1er envoi avec une clé → l'action s'exécute
|
|
205
|
+
curl -s -X POST http://localhost:5151/api/payments/charge \
|
|
206
|
+
-H 'Idempotency-Key: 3f6a9c1e-2b7d-4a10-9d31-8e5c2f0a7b64' \
|
|
207
|
+
-H 'Content-Type: application/json' -d '{"amount":4200}'
|
|
208
|
+
# {"chargeId":"ch_9F2a","amount":4200}
|
|
209
|
+
|
|
210
|
+
# 3) Retry — MÊME clé, MÊME corps → réponse MÉMORISÉE, 0 re-débit
|
|
211
|
+
curl -s -X POST http://localhost:5151/api/payments/charge \
|
|
212
|
+
-H 'Idempotency-Key: 3f6a9c1e-2b7d-4a10-9d31-8e5c2f0a7b64' \
|
|
213
|
+
-H 'Content-Type: application/json' -d '{"amount":4200}'
|
|
214
|
+
# {"chargeId":"ch_9F2a","amount":4200} ← identique, l'action n'a PAS tourné
|
|
215
|
+
|
|
216
|
+
# 4) MÊME clé, corps DIFFÉRENT → 422 (une clé = une intention)
|
|
217
|
+
curl -si -X POST http://localhost:5151/api/payments/charge \
|
|
218
|
+
-H 'Idempotency-Key: 3f6a9c1e-2b7d-4a10-9d31-8e5c2f0a7b64' \
|
|
219
|
+
-H 'Content-Type: application/json' -d '{"amount":9900}' | head -1
|
|
220
|
+
# HTTP/1.1 422 Unprocessable Content
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
> [!WARNING]
|
|
224
|
+
> **Le piège n°1** : en verdict `fresh`, la réservation est _in-flight_ tant que `complete` **ou**
|
|
225
|
+
> `abort` n'a pas été appelé. Le seam `@Idempotent` gère ce couple pour toi (`try/catch`,
|
|
226
|
+
> `Resolver.ts:539` et `Resolver.ts:563`). Si tu appelles le store **à la main** (cas avancé), c'est
|
|
227
|
+
> **ta** responsabilité : sans `abort` sur erreur, la clé reste bloquée jusqu'à l'expiration du bail
|
|
228
|
+
> (60 s), et tout rejeu identique reçoit `409` pendant ce temps.
|
|
229
|
+
|
|
230
|
+
### Le mode souple — quand la clé est optionnelle
|
|
231
|
+
|
|
232
|
+
`@Idempotent({ required: false })` honore la clé si elle est présente et exécute sinon. Utile pour
|
|
233
|
+
une route que d'anciens clients appellent déjà sans clé, le temps de la migration :
|
|
234
|
+
|
|
235
|
+
```ts ignore
|
|
236
|
+
@Idempotent({ required: false })
|
|
237
|
+
@Post("/subscribe")
|
|
238
|
+
async subscribe(@Body() body: SubscribeInput) { /* … */ }
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:1561`), comme
|
|
242
|
+
`@UseSession`. Poser `@Idempotent()` sur la **classe** couvre toutes les mutations du controller ;
|
|
243
|
+
une méthode peut resserrer ou relâcher le mode. Les méthodes sûres (GET…) restent des no-op même
|
|
244
|
+
sous une classe décorée.
|
|
245
|
+
|
|
246
|
+
## 🔌 HTTP et WebSocket — la même porte
|
|
247
|
+
|
|
248
|
+
Une socket **reconnecte et rejoue par nature** : muter sans clé y serait un piège à double-effet
|
|
249
|
+
garanti. D'où la règle, dans le helper partagé : `requiredEffective = required || isWs`
|
|
250
|
+
(`idempotency.ts:160`). Une mutation par socket **exige toujours** une clé, même quand le mode HTTP
|
|
251
|
+
est souple.
|
|
252
|
+
|
|
253
|
+
| Situation | `@Idempotent()` (strict) | `@Idempotent({ required:false })` |
|
|
254
|
+
| -------------------------------------------- | ------------------------ | --------------------------------- |
|
|
255
|
+
| HTTP, clé présente | dédup complète | dédup complète |
|
|
256
|
+
| HTTP, clé absente | **400** | exécute sans mémoriser |
|
|
257
|
+
| WebSocket (pont `api.request`), clé présente | dédup complète | dédup complète |
|
|
258
|
+
| WebSocket, clé absente | **400** | **400** (toujours strict) |
|
|
259
|
+
|
|
260
|
+
Deux mécanismes rendent ça possible côté socket :
|
|
261
|
+
|
|
262
|
+
- **La clé voyage par l'ALS** — le pont WS la pose dans `RequestContext`, et
|
|
263
|
+
`resolveIdempotencyKey()` (`idempotency.ts:68`) donne la **priorité à l'ALS** sur l'en-tête HTTP.
|
|
264
|
+
- **La méthode logique voyage par `methodOverride`** — sur une socket, `context.method` vaut
|
|
265
|
+
`WEBSOCKET`, qui n'est **pas** une mutation. Sans override, la porte serait sautée et un rejeu de
|
|
266
|
+
frame `socket.mutate` créerait un doublon. Le Resolver teste donc
|
|
267
|
+
`isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:473`).
|
|
268
|
+
|
|
269
|
+
Le pont utilise `executeActionGuarded()` (`Resolver.ts:425`) : porte d'idempotence **sans** rendu HTTP
|
|
270
|
+
— la valeur nue est enveloppée par le peer WS, jamais écrite sur un transport HTTP.
|
|
271
|
+
|
|
272
|
+
## 🏗️ Architecture interne
|
|
273
|
+
|
|
274
|
+
```mermaid
|
|
275
|
+
sequenceDiagram
|
|
276
|
+
participant C as Client (HTTP ou WS)
|
|
277
|
+
participant R as Resolver (seam @Idempotent)
|
|
278
|
+
participant H as evaluateIdempotency (helper pur)
|
|
279
|
+
participant S as IIdempotencyStore
|
|
280
|
+
participant A as Action du controller
|
|
281
|
+
|
|
282
|
+
C->>R: mutation + Idempotency-Key
|
|
283
|
+
R->>R: isMutationMethod(methodOverride ?? method)
|
|
284
|
+
R->>R: fingerprint = sha256([route, params, body])
|
|
285
|
+
R->>H: {store, identity, clientKey, fingerprint, isWs, required}
|
|
286
|
+
H->>S: begin(clé scopée, fingerprint)
|
|
287
|
+
S-->>H: fresh | in-flight | replayed | mismatch
|
|
288
|
+
H-->>R: guarded | reject(400/409/422) | replay | execute
|
|
289
|
+
alt guarded
|
|
290
|
+
R->>A: exécute
|
|
291
|
+
A-->>R: payload
|
|
292
|
+
R->>S: complete(clé, {status, body})
|
|
293
|
+
else replay
|
|
294
|
+
R-->>C: réponse mémorisée (0 exécution)
|
|
295
|
+
else reject
|
|
296
|
+
R-->>C: nodefonyError(status)
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Le parcours d'une mutation, étape par étape
|
|
301
|
+
|
|
302
|
+
1. **Court-circuit hot path.** `callController()` (`Resolver.ts:396`) lit `meta.idempotent` sur les
|
|
303
|
+
métadonnées d'action **figées par route**. `null` sur la quasi-totalité des routes → une
|
|
304
|
+
comparaison, flux normal, **zéro** lookup de store et zéro allocation.
|
|
305
|
+
2. **No-op sur méthode sûre.** Une action `GET` sous une classe `@Idempotent` repart directement en
|
|
306
|
+
exécution (`Resolver.ts:473`).
|
|
307
|
+
3. **Empreinte du payload.** `computeFingerprint()` (`idempotency.ts:123`) hache
|
|
308
|
+
`[nom de route, params de route, corps]` (`Resolver.ts:497`). Le corps vient de l'ALS (pont WS) ou
|
|
309
|
+
du body HTTP parsé.
|
|
310
|
+
4. **Identité.** `resolveIdentity()` (`idempotency.ts:100`) dérive l'identité de `request.user`
|
|
311
|
+
(`username` → `identifier` → `id`), avec repli sur l'`userId` de l'ALS. **`null` = pas de cache** :
|
|
312
|
+
le verdict devient `execute` (`idempotency.ts:176`) — jamais de partage cross-identité.
|
|
313
|
+
5. **Réservation.** `store.begin()` compose la clé scopée et tranche.
|
|
314
|
+
6. **Mémorisation.** En succès, `complete(clé, {status, body})` où `status` est le code de réponse
|
|
315
|
+
courant et `body` la **valeur retournée** par l'action (`Resolver.ts:539`). En erreur,
|
|
316
|
+
`abort(clé)` libère la clé : **un échec ne se mémorise pas**, il doit rester réessayable.
|
|
317
|
+
|
|
318
|
+
> [!CAUTION]
|
|
319
|
+
> **La réponse mémorisée est la valeur RETOURNÉE, pas la réponse rendue.** Une action qui pilote la
|
|
320
|
+
> response elle-même (`this.renderJson(...)`, stream, `send()`) n'est pas rejouée fidèlement : le
|
|
321
|
+
> double-effet reste évité, mais le corps rejoué est vide. Pire, si le corps retourné n'est pas
|
|
322
|
+
> sérialisable (retour d'un objet Response circulaire), un store SQL/Redis lève au `stringify`. Le
|
|
323
|
+
> Resolver attrape ce cas : il **journalise un WARNING explicite** puis mémorise le statut avec un
|
|
324
|
+
> corps `null` (`Resolver.ts:549`) — la dédup est préservée plutôt que perdue en silence.
|
|
325
|
+
|
|
326
|
+
### Où la porte est câblée
|
|
327
|
+
|
|
328
|
+
| Appelant | Point d'entrée | Traduction du verdict |
|
|
329
|
+
| ---------------------------- | -------------------------------------------------------------------- | ---------------------------------- |
|
|
330
|
+
| Controller userland HTTP | `callController()` (`Resolver.ts:396`) | `nodefonyError` + rendu normal |
|
|
331
|
+
| Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:425`) | valeur nue, enveloppée par le peer |
|
|
332
|
+
| Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:158`) | `{status, headers, body}` |
|
|
333
|
+
|
|
334
|
+
## ⚙️ Configuration
|
|
335
|
+
|
|
336
|
+
Source unique = schéma Zod `idempotencySchema`
|
|
337
|
+
(`src/packages/@nodefony/framework/nodefony/config/config.ts:42`).
|
|
338
|
+
|
|
339
|
+
| Option | Type | Défaut | Effet |
|
|
340
|
+
| ------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
341
|
+
| `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` sont explicites (`config.ts:43`). |
|
|
342
|
+
| `gcIntervalS` | `int ≥ 0` | `600` | Intervalle de purge des clés expirées, **hors** hot-path. Sans effet pour `redis` (TTL natif) et `memory` (`config.ts:57`). |
|
|
343
|
+
| `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts:68`). |
|
|
344
|
+
|
|
345
|
+
### Comment `store: "auto"` se résout VRAIMENT
|
|
346
|
+
|
|
347
|
+
`Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:189`) délègue à
|
|
348
|
+
`resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts:241`), borné aux backends
|
|
349
|
+
**réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts:81`). L'ordre
|
|
350
|
+
réel est le suivant :
|
|
351
|
+
|
|
352
|
+
| Ordre | Condition | Résolution | Ancre |
|
|
353
|
+
| ----- | ------------------------------------------------------------------------ | -------------------------------------- | -------------- |
|
|
354
|
+
| 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:251` |
|
|
355
|
+
| 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts:258` |
|
|
356
|
+
| 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts:261` |
|
|
357
|
+
| 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts:274` |
|
|
358
|
+
| 5 | **Aucune infra déclarée** mais un backend local persistant est chargé | `drizzle`, puis `mongoose` | `infra.ts:288` |
|
|
359
|
+
| 6 | Rien de tout ça | `memory` (volatil) | `infra.ts:295` |
|
|
360
|
+
|
|
361
|
+
> [!NOTE]
|
|
362
|
+
> L'étape **5** surprend souvent : dans une app dev qui charge `@nodefony/drizzle` **sans** déclarer
|
|
363
|
+
> `NF_DATABASE_URL`, `auto` ne résout **pas** vers `memory` mais vers `drizzle` (SQLite local) — pour
|
|
364
|
+
> que les données survivent au redémarrage. La résolution effective est toujours **journalisée au
|
|
365
|
+
> boot** (`index.ts:207`) : lis cette ligne plutôt que de la déduire.
|
|
366
|
+
|
|
367
|
+
MongoDB mérite une mention : `mongoose` n'implémente **pas encore** de store d'idempotence. Déclarer
|
|
368
|
+
`NF_DATABASE_URL=mongodb://…` fait donc tomber la résolution en étape **4** → repli `memory` avec la
|
|
369
|
+
raison annoncée — et un repli `memory` en cluster ne déduplique plus rien entre pods : le rejeu que
|
|
370
|
+
cette brique promet d'empêcher passe sur un autre pod. En attendant le store Mongo (objectif « full
|
|
371
|
+
NoSQL », `MIGRATION_STATUS.md` P7.11), la dédup cross-pod passe par `redis`.
|
|
372
|
+
|
|
373
|
+
### Le contrat de dégradation : fail-loud, jamais silencieux
|
|
374
|
+
|
|
375
|
+
Quand un store **explicitement nommé** ne peut pas s'initialiser (nom inconnu, `redis` demandé sans
|
|
376
|
+
le module `@nodefony/redis` chargé), la politique dépend de l'environnement
|
|
377
|
+
(`index.ts:132`) :
|
|
378
|
+
|
|
379
|
+
- **production** → **boot avorté** (`index.ts:281`). En cluster multi-pod, dégrader vers un cache
|
|
380
|
+
per-pod produirait du double-effet non dédupliqué : c'est un défaut de sécurité, pas une commodité.
|
|
381
|
+
- **dev / test** (mono-pod) → **WARNING fort + repli sur le cache mémoire** déjà en place. La dédup
|
|
382
|
+
per-pod suffit hors cluster, et on ne casse pas le routeur pour une option d'infra absente en local.
|
|
383
|
+
|
|
384
|
+
Un garde-fou supplémentaire : `store: "memory"` **en production** émet un WARNING dédié
|
|
385
|
+
(`index.ts:212`) — la dédup n'y est que per-pod.
|
|
386
|
+
|
|
387
|
+
## 🧩 Les stores d'idempotence
|
|
388
|
+
|
|
389
|
+
Tous respectent le même contrat `IIdempotencyStore`
|
|
390
|
+
(`src/nodefony/src/types/IIdempotencyStore.ts:106`) : `begin` / `complete` / `abort` obligatoires,
|
|
391
|
+
`gc` **optionnel**, plus `listPage` et `size` pour l'introspection.
|
|
392
|
+
|
|
393
|
+
### Choisir en 5 secondes
|
|
394
|
+
|
|
395
|
+
| Store | Atomicité de `begin` | Expiration | `gc()` | Multi-pod | Pour… |
|
|
396
|
+
| --------- | ----------------------------------------------- | ------------------ | :----: | :-------: | ---------------------------------------- |
|
|
397
|
+
| `memory` | mono-thread JS | passive + cap FIFO | ❌ | ❌ | dev, tests, mono-pod |
|
|
398
|
+
| `redis` | `SET … NX PX` | TTL natif (`PX`) | ❌ | ✅ | cluster — le choix par défaut recommandé |
|
|
399
|
+
| `drizzle` | `INSERT … ON CONFLICT DO UPDATE … WHERE expiré` | applicative | ✅ | ✅ | cluster qui a déjà du SQL, pas de Redis |
|
|
400
|
+
|
|
401
|
+
### `memory` — le défaut per-pod, gratuit
|
|
402
|
+
|
|
403
|
+
`MemoryIdempotencyStore` (`IdempotencyStore.ts:44`) est enregistré d'office comme service DI
|
|
404
|
+
`idempotencyStore` par le manifeste `@services` du module framework — zéro configuration.
|
|
405
|
+
|
|
406
|
+
- **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:107`) lit et écrit la `Map`
|
|
407
|
+
sans point de suspension → deux `begin` concurrents ne peuvent pas se croiser.
|
|
408
|
+
- **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts:14`), bail
|
|
409
|
+
`DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts:16`), plafond `DEFAULT_CAP` = 1000 entrées
|
|
410
|
+
(`IdempotencyStore.ts:18`). Elles ne sont **pas** configurables.
|
|
411
|
+
- **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts:46`) ; aucun timer,
|
|
412
|
+
aucun listener.
|
|
413
|
+
- **Purge passive** : les entrées expirées ne sont retirées qu'à l'écriture, dans `evictIfNeeded()`
|
|
414
|
+
(`IdempotencyStore.ts:164`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
|
|
415
|
+
sous le cap. Coût nul tant qu'on n'écrit pas.
|
|
416
|
+
- **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts:134`) n'écrit **que** si la clé est
|
|
417
|
+
encore _notre_ in-flight — jamais de résurrection d'une clé déjà `abort`-ée ou évincée.
|
|
418
|
+
|
|
419
|
+
⚠️ Limite structurelle : la dédup est **affine au pod**. Un rejeu routé vers un autre pod n'est pas
|
|
420
|
+
dédupliqué.
|
|
421
|
+
|
|
422
|
+
### `redis` — le choix cluster
|
|
423
|
+
|
|
424
|
+
`RedisIdempotencyStore` (`RedisIdempotencyStore.ts:121`) vit dans `@nodefony/framework` (le
|
|
425
|
+
consommateur du contrat), pas dans `@nodefony/redis` : il résout le service `redis` **par nom** dans
|
|
426
|
+
le container (couplage structurel, zéro dépendance directe, zéro cycle). La fabrique est enregistrée
|
|
427
|
+
au chargement du module framework (`index.ts:119`).
|
|
428
|
+
|
|
429
|
+
- **`SET key … NX PX`** = réservation atomique **côté serveur** (`RedisIdempotencyStore.ts:237`) : le
|
|
430
|
+
`409` in-flight fonctionne vraiment entre pods. Deux requêtes concurrentes sur deux pods, un seul
|
|
431
|
+
`SET NX` gagne.
|
|
432
|
+
- **TTL natif** sur le bail _et_ sur la réponse mémorisée → `gc()` **superflu**, donc **non
|
|
433
|
+
implémenté** : rien à planifier.
|
|
434
|
+
- **Empreinte préservée à la complétion** : `complete()` (`RedisIdempotencyStore.ts:300`) **relit**
|
|
435
|
+
l'entrée in-flight pour reporter son empreinte dans l'entrée `done`. Sans cela, un rejeu de la clé
|
|
436
|
+
avec un autre payload **après** complétion ne serait plus détecté (le 422 serait perdu).
|
|
437
|
+
- **Course rare gérée** : si la clé expire entre le `SET NX` échoué et le `GET`, la réservation est
|
|
438
|
+
retentée **une** fois ; encore prise → `in-flight` (`RedisIdempotencyStore.ts:258`).
|
|
439
|
+
- **Namespace** : `nf:idem:<clé>` (`RedisIdempotencyStore.ts:11`).
|
|
440
|
+
- **Dégradation gracieuse** : connexion `main` fermée (boot / shutdown) → `begin` renvoie `fresh`,
|
|
441
|
+
`complete`/`abort` sont des no-op. La mutation s'exécute **sans dédup** plutôt que d'être bloquée.
|
|
442
|
+
|
|
443
|
+
> [!WARNING]
|
|
444
|
+
> Ce fail-soft est un **compromis assumé** : pendant une coupure Redis, un rejeu peut ré-exécuter la
|
|
445
|
+
> mutation. Le client rejouera sa clé au rétablissement. Si ton domaine ne tolère aucun double-effet,
|
|
446
|
+
> traite l'indisponibilité du store comme un incident bloquant en amont (readiness du pod).
|
|
447
|
+
|
|
448
|
+
### `drizzle` — le cluster sans Redis
|
|
449
|
+
|
|
450
|
+
`DrizzleIdempotencyStore` (`DrizzleIdempotencyStore.ts:102`). Motivation : un cluster qui possède
|
|
451
|
+
déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra**.
|
|
452
|
+
|
|
453
|
+
- **Réservation atomique en UNE instruction** — un `INSERT` avec
|
|
454
|
+
`onConflictDoUpdate` (`DrizzleIdempotencyStore.ts:234`) dont la garde `setWhere` ne réécrit que si
|
|
455
|
+
l'entrée est morte (`DrizzleIdempotencyStore.ts:244`). Le `returning` ne rend une ligne que si
|
|
456
|
+
l'INSERT a passé (clé neuve) ou si le `DO UPDATE` a **volé** une entrée expirée → `fresh`. Zéro
|
|
457
|
+
ligne = contention → on lit l'état réel.
|
|
458
|
+
- **Invariant capital** : le store ne renvoie **jamais** `fresh` hors réservation atomique gagnée.
|
|
459
|
+
Même la course rare « la clé a expiré entre l'upsert et le SELECT » renvoie prudemment `in-flight`
|
|
460
|
+
(`DrizzleIdempotencyStore.ts:264`), jamais `fresh`.
|
|
461
|
+
- **MySQL/MariaDB** : ni `RETURNING`, ni `WHERE` sur l'`ON DUPLICATE KEY UPDATE`, et un `affectedRows`
|
|
462
|
+
ambigu → la réservation passe par `reserveIdempotencyKeyMysql()`
|
|
463
|
+
(`DrizzleIdempotencyStore.ts:213`), qui la reconstruit en deux instructions chacune atomique.
|
|
464
|
+
- **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts:318`) = `DELETE WHERE expiresAt <= now`.
|
|
465
|
+
C'est le **seul** store qui expose `gc`, donc le seul que le framework planifie (voir plus bas).
|
|
466
|
+
- **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:276`) et `abort()`
|
|
467
|
+
(`DrizzleIdempotencyStore.ts:294`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
|
|
468
|
+
déjà mémorisée, jamais de résurrection d'une clé libérée. `complete` ne touche pas `fingerprint`.
|
|
469
|
+
- **Résolution lazy + dégradation gracieuse** : le handle Drizzle est résolu à **chaque** appel
|
|
470
|
+
(`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:172`). ORM non connecté → `begin`
|
|
471
|
+
renvoie `fresh` (sans dédup), le reste est no-op.
|
|
472
|
+
|
|
473
|
+
Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
|
|
474
|
+
(`registerStores.ts:316`). Activation = `store: "drizzle"` (ou `NF_IDEMPOTENCY_STORE=drizzle`), rien
|
|
475
|
+
d'autre à écrire.
|
|
476
|
+
|
|
477
|
+
### Le GC — armé pour un seul store
|
|
478
|
+
|
|
479
|
+
`scheduleIdempotencyGc()` (`idempotencyGc.ts:32`) arme un `GcScheduler` **uniquement si le store
|
|
480
|
+
expose `gc()`** (`idempotencyGc.ts:37`). Un store à TTL natif (`redis`) ou à purge passive (`memory`)
|
|
481
|
+
ne l'expose pas ; le brancher sur un timer serait un no-op coûteux. Le scheduler est armé au boot
|
|
482
|
+
(`nodefony/framework/index.ts:311`) et arrêté à `onTerminate`.
|
|
483
|
+
|
|
484
|
+
`gcIntervalS: 0` désarme le timer — à réserver au cas où la purge est déléguée (cron, `CronJob` k8s).
|
|
485
|
+
|
|
486
|
+
## 🗄️ Entité de persistance (store SQL)
|
|
487
|
+
|
|
488
|
+
Table `idempotency_key`, décrite par une **spec colKit** unique
|
|
489
|
+
(`idempotencyEntity.ts:66`) déclinée par dialecte via `createIdempotencyTable(dialect)`
|
|
490
|
+
(`idempotencyEntity.ts:85`).
|
|
491
|
+
|
|
492
|
+
| Colonne | Type logique | SQLite | PostgreSQL | MySQL / MariaDB | Rôle |
|
|
493
|
+
| ------------- | ------------ | ------------------ | ---------- | --------------- | ------------------------------------------------------------------------------------- |
|
|
494
|
+
| `key` | text (PK) | `text` | `text` | `varchar(512)` | Clé **déjà scopée** `[identité, clé]`. Sa contrainte d'unicité **porte** l'atomicité. |
|
|
495
|
+
| `fingerprint` | text | `text` | `text` | `text` | Empreinte du payload ; différente pour la même clé vivante ⇒ 422. |
|
|
496
|
+
| `state` | text | `text` | `text` | `text` | `if` (in-flight) \| `done` (réponse mémorisée). |
|
|
497
|
+
| `response` | json | `text mode:"json"` | `jsonb` | `json` | Réponse mémorisée `{status, headers?, body}` ; `null` tant qu'in-flight. |
|
|
498
|
+
| `expiresAt` | epoch ms | `integer` 64-bit | `bigint` | `bigint` | Bail (60 s) puis rétention (10 min). **Indexé** — accélère le `gc`. |
|
|
499
|
+
|
|
500
|
+
Deux détails qui expliquent des surprises réelles :
|
|
501
|
+
|
|
502
|
+
- **`varchar(512)` en MySQL** n'est pas un caprice : un `TEXT` InnoDB n'est pas indexable sans
|
|
503
|
+
préfixe, et 512 caractères couvrent `JSON.stringify([identité, clé ≤ 255])`.
|
|
504
|
+
- **Aucun `DEFAULT` SQL** : le DDL dérivé n'en émet pas (`idempotencyEntity.ts:38`). Toutes les
|
|
505
|
+
colonnes sont posées explicitement par le store — jamais d'INSERT cassé par un défaut manquant.
|
|
506
|
+
|
|
507
|
+
`registerIdempotencyEntities(connector, dialect)` (`idempotencyEntity.ts:146`) doit être appelé
|
|
508
|
+
**avant** `orm.connect()` (la table est créée au connect) — le module drizzle s'en charge tout seul.
|
|
509
|
+
|
|
510
|
+
> [!NOTE]
|
|
511
|
+
> **SQLite = banc de test de la sémantique.** Un fichier SQLite est mono-machine (verrou d'écriture)
|
|
512
|
+
> → aucun intérêt multi-pod (`idempotencyEntity.ts:26`). La cible réelle est PostgreSQL ou
|
|
513
|
+
> MySQL/MariaDB, où l'atomicité de l'instruction tient sous concurrence inter-pods.
|
|
514
|
+
|
|
515
|
+
## 🗃️ Dialectes et bases pris en charge
|
|
516
|
+
|
|
517
|
+
| Backing | Base | Atomicité / expiration | Multi-pod | GC applicatif |
|
|
518
|
+
| --------------- | ------------------------ | ----------------------------------------------- | :-------: | :-----------: |
|
|
519
|
+
| `redis` | Redis | `SET NX` + TTL natif (`PX`) | ✅ | n/a |
|
|
520
|
+
| `drizzle` (SQL) | PostgreSQL | `INSERT … ON CONFLICT DO UPDATE … WHERE expiré` | ✅ | ✅ |
|
|
521
|
+
| `drizzle` (SQL) | MySQL 8.4 / MariaDB 11.4 | `INSERT IGNORE` + `UPDATE … WHERE expiré` | ✅ | ✅ |
|
|
522
|
+
| `drizzle` (SQL) | SQLite | idem, mais mono-machine → **test** | ❌ | ✅ |
|
|
523
|
+
| `memory` | RAM du pod | mono-thread JS ; cap FIFO 1000 ; TTL 10 min | ❌ | n/a |
|
|
524
|
+
|
|
525
|
+
> [!CAUTION]
|
|
526
|
+
> **MongoDB (`@nodefony/mongoose`) n'implémente PAS de store d'idempotence.** Sélectionner
|
|
527
|
+
> `store: "mongoose"` échoue à la résolution (fail-loud) ; laisser `auto` avec une infra Mongo replie
|
|
528
|
+
> sur `memory` avec une raison annoncée. En cluster Mongo, la dédup passe par `redis`.
|
|
529
|
+
|
|
530
|
+
## 🧰 API publique
|
|
531
|
+
|
|
532
|
+
Signatures complètes : `.ai/symbols.json`. Ce qui compte à l'usage :
|
|
533
|
+
|
|
534
|
+
### Le décorateur
|
|
535
|
+
|
|
536
|
+
`@Idempotent(options?)` (`routerDecorators.ts:1103`) — dual **classe + méthode**. N'écrit que des
|
|
537
|
+
métadonnées (`IdempotentMeta`, `routerDecorators.ts:443`), zéro import de `@nodefony/security`, zéro
|
|
538
|
+
cycle. La porte est appliquée par le Resolver.
|
|
539
|
+
|
|
540
|
+
### Le contrat de store
|
|
541
|
+
|
|
542
|
+
| Membre | Obligatoire | Rôle |
|
|
543
|
+
| ------------------------- | :---------: | --------------------------------------------------------------------------------------------------------------- |
|
|
544
|
+
| `begin(key, fingerprint)` | ✅ | Réserve atomiquement, rend le verdict (`src/nodefony/src/types/IIdempotencyStore.ts:118`). |
|
|
545
|
+
| `complete(key, response)` | ✅ | Mémorise la réponse d'une clé in-flight → rejeux futurs = `replayed`. |
|
|
546
|
+
| `abort(key)` | ✅ | Libère une clé in-flight dont l'exécution a échoué. Rien n'est mémorisé. |
|
|
547
|
+
| `gc(now?)` | ❌ | Purge des expirées — **uniquement** sans expiration native (`src/nodefony/src/types/IIdempotencyStore.ts:139`). |
|
|
548
|
+
| `listPage(query)` | ✅ | Page de clés vivantes, pour l'introspection admin (`src/nodefony/src/types/IIdempotencyStore.ts:153`). |
|
|
549
|
+
| `size` | ✅ | Nombre d'entrées vivantes, **sync best-effort** (`src/nodefony/src/types/IIdempotencyStore.ts:159`). |
|
|
550
|
+
|
|
551
|
+
Les helpers du seam sont exportés et réutilisables : `isMutationMethod()` (`idempotency.ts:28`),
|
|
552
|
+
`resolveIdempotencyKey()` (`idempotency.ts:68`), `resolveIdentity()` (`idempotency.ts:100`),
|
|
553
|
+
`computeFingerprint()` (`idempotency.ts:123`), `evaluateIdempotency()` (`idempotency.ts:142`).
|
|
554
|
+
|
|
555
|
+
### `listPage` — capacités RÉELLES par store
|
|
556
|
+
|
|
557
|
+
Le contrat annonce **deux modes** exclusifs, chaque store déclarant celui qu'il sait faire. Voici ce
|
|
558
|
+
que le code fait, store par store — à lire avant d'écrire un client :
|
|
559
|
+
|
|
560
|
+
<!-- prettier-ignore -->
|
|
561
|
+
| Capacité | `memory` | `drizzle` (SQL) | `redis` |
|
|
562
|
+
| --- | --- | --- | --- |
|
|
563
|
+
| Mode | **offset** | **offset** | **curseur** |
|
|
564
|
+
| `offset` | ✅ | ✅ | ❌ **ignoré** |
|
|
565
|
+
| `total` (`withTotal`) | ✅ (refusable) | ✅ (refusable, `COUNT`) | ❌ **jamais** rendu |
|
|
566
|
+
| `cursor` / `nextCursor` | ❌ **ignoré** | ❌ **ignoré** | ✅ curseur composite |
|
|
567
|
+
| Ordre | `expiresAtMs` ASC | `expiresAtMs` ASC, `key` ASC | ❌ **aucun ordre garanti** |
|
|
568
|
+
| `order` (tri demandé) | ❌ ignoré | ❌ ignoré | ❌ ignoré |
|
|
569
|
+
| `q` (préfixe de clé) | ✅ `startsWith` | ✅ `LIKE` ancré, `%`/`_` échappés | ✅ descendu dans `MATCH` |
|
|
570
|
+
| `state` | ✅ | ✅ | ✅ (filtre après lecture) |
|
|
571
|
+
| Page pleine à `limit` | ✅ | ✅ | ❌ peut être plus courte |
|
|
572
|
+
| Exclusion des expirées | ✅ à la lecture | ✅ `expiresAt > now` | ✅ par TTL natif |
|
|
573
|
+
| Ancre | `IdempotencyStore.ts:72` | `DrizzleIdempotencyStore.ts:340` | `RedisIdempotencyStore.ts:166` |
|
|
574
|
+
|
|
575
|
+
Le mode **curseur** de Redis mérite une explication, parce qu'il piège : `SCAN COUNT` **n'est pas un
|
|
576
|
+
plafond** mais un indice d'effort — Redis peut rendre plus de clés que demandé. Sans précaution, la
|
|
577
|
+
page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts:108`). D'où
|
|
578
|
+
le **curseur composite** `"<consommé>:<curseurRedis>"` (`encodeCursor()`,
|
|
579
|
+
`RedisIdempotencyStore.ts:57`) : on ne rend que `limit` éléments et on mémorise combien de clés du
|
|
580
|
+
lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et reprend là.
|
|
581
|
+
|
|
582
|
+
> [!IMPORTANT]
|
|
583
|
+
> **Une clé rendue par `listPage` ne contient JAMAIS la réponse mémorisée ni l'empreinte.**
|
|
584
|
+
> `IIdempotencyKeyEntry` (`src/nodefony/src/types/IIdempotencyStore.ts:51`) n'expose que `key`,
|
|
585
|
+
> `state`, `expiresAtMs` et `hasResponse` (un booléen). Le corps mémorisé est la donnée métier d'un
|
|
586
|
+
> utilisateur : le laisser sortir par ce chemin recréerait exactement l'IDOR sur le cache que le
|
|
587
|
+
> scope de clé interdit.
|
|
588
|
+
|
|
589
|
+
Deux réserves à connaître :
|
|
590
|
+
|
|
591
|
+
- Le champ `tenantId` d'`IPageQuery` est un **slot réservé** au multi-tenant : le passer n'a
|
|
592
|
+
aujourd'hui **aucun effet de filtrage**.
|
|
593
|
+
- `size` est une **approximation per-pod** pour les stores distribués (compteur local incrémenté au
|
|
594
|
+
`fresh`, décrémenté au `complete`/`abort`), désalignée cross-pod et non décrémentée si un bail
|
|
595
|
+
expire sans complétion. La vérité cluster passe par la base ou `redis-cli`, jamais par ce getter.
|
|
596
|
+
|
|
597
|
+
## 🧩 Extension — brancher son propre store
|
|
598
|
+
|
|
599
|
+
Le registre (`idempotencyStoreRegistry.ts`) ne porte que les **overrides distribués opt-in** : le
|
|
600
|
+
défaut mémoire est posé par `@services`, jamais par le registre.
|
|
601
|
+
|
|
602
|
+
```ts ignore
|
|
603
|
+
import { registerIdempotencyStore } from "@nodefony/framework";
|
|
604
|
+
import type { IIdempotencyStore } from "nodefony";
|
|
605
|
+
|
|
606
|
+
registerIdempotencyStore("mon-backend", (ctx) => {
|
|
607
|
+
// ctx.module → container kernel (résoudre un service par NOM, jamais d'import direct)
|
|
608
|
+
// ctx.config → config framework validée + gelée
|
|
609
|
+
return new MonStore(/* … */) satisfies IIdempotencyStore;
|
|
610
|
+
});
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Trois règles héritées du code existant, à respecter sous peine de double-effet :
|
|
614
|
+
|
|
615
|
+
1. **`begin` doit être atomique côté backend.** Un `GET` puis `SET` séparés laissent deux retries
|
|
616
|
+
concurrents obtenir `fresh` — précisément ce que l'idempotence doit empêcher.
|
|
617
|
+
2. **Ne jamais rendre `fresh` hors réservation gagnée.** En cas de doute (course, état illisible),
|
|
618
|
+
rendre `in-flight` : le client réessaiera, c'est sans danger.
|
|
619
|
+
3. **`complete` doit préserver l'empreinte** de l'entrée in-flight, sinon un rejeu avec un autre
|
|
620
|
+
payload après complétion ne produit plus de 422.
|
|
621
|
+
|
|
622
|
+
Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts:48`),
|
|
623
|
+
`getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts:56`), `listIdempotencyStores()`
|
|
624
|
+
(distribués seuls, `idempotencyStoreRegistry.ts:67`), `listIdempotencyBackends()` (avec `memory`,
|
|
625
|
+
pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
|
|
626
|
+
|
|
627
|
+
## 📜 Normes appliquées
|
|
628
|
+
|
|
629
|
+
<!-- prettier-ignore -->
|
|
630
|
+
| Sujet | Norme | Ancrage |
|
|
631
|
+
| --- | --- | --- |
|
|
632
|
+
| En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-06` | `evaluateIdempotency()` (`idempotency.ts:142`) |
|
|
633
|
+
| Clé réutilisée avec un autre payload → 422 | draft §2.2 / §2.7 | `idempotency.ts:189` |
|
|
634
|
+
| Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts:197` |
|
|
635
|
+
| Clé requise absente → 400 | draft §2.7 | `idempotency.ts:165` |
|
|
636
|
+
| Méthodes non sûres = mutations | RFC 9110 §9.2.1 | `MUTATION_METHODS` (`idempotency.ts:25`) |
|
|
637
|
+
| Sémantique du 422 | RFC 9110 §15.5.21 | `IdempotencyVerdict` (`idempotency.ts:50`) |
|
|
638
|
+
| Borne de clé (convention Stripe) | 255 octets | `IDEMPOTENCY_KEY_MAX` (`idempotency.ts:36`) |
|
|
639
|
+
|
|
640
|
+
## ⚡ Performance et mémoire
|
|
641
|
+
|
|
642
|
+
Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
|
|
643
|
+
`null` (`routerDecorators.ts:1103`) : `callController()` fait **une comparaison** et repart en flux
|
|
644
|
+
normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:396`).
|
|
645
|
+
La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
|
|
646
|
+
|
|
647
|
+
Sur le chemin décoré :
|
|
648
|
+
|
|
649
|
+
- Le store mémoire n'alloue sa `Map` qu'au 1ᵉʳ `begin`, ne pose **aucun timer ni listener**, et purge
|
|
650
|
+
en passif (`IdempotencyStore.ts:164`).
|
|
651
|
+
- L'empreinte est un hash SHA-256 court → comparaison O(1), et le payload n'est jamais conservé en
|
|
652
|
+
clair.
|
|
653
|
+
- Le GC est **hors hot-path** et armé pour un seul store (SQL) ; le jitter évite que N pods purgent
|
|
654
|
+
au même instant.
|
|
655
|
+
- Un store distribué ajoute **un aller-retour réseau** par mutation (`begin`), plus un au `complete`.
|
|
656
|
+
C'est le prix de la dédup cross-pod, payé uniquement sur les routes décorées.
|
|
657
|
+
|
|
658
|
+
Constat honnête : **il n'existe pas de banc de charge dédié à l'idempotence**. La porte est un chemin
|
|
659
|
+
froid par construction ; si ton profil de trafic la place sur un chemin chaud, mesure-la avec le skill
|
|
660
|
+
`nodefony-load-test`.
|
|
661
|
+
|
|
662
|
+
## 📡 Observabilité — Studio
|
|
663
|
+
|
|
664
|
+
Trois surfaces existent aujourd'hui :
|
|
665
|
+
|
|
666
|
+
- **Playground** (`/nodefony/playground`) — chaque mutation protégée porte un badge `@Idempotent` (strict ou souple)
|
|
667
|
+
(`playground/PlaygroundFormat.tsx:69`). L'écran génère une clé par exécution et propose « Rejouer
|
|
668
|
+
même clé » dès que `action.guards.idempotent` est posé (`playground/ActionPanel.tsx:516`) : c'est la
|
|
669
|
+
façon la plus rapide de voir un rejeu, un `409` ou un `422` en vrai.
|
|
670
|
+
- **Stores** — la brique `idempotency` y apparaît avec sa nature **éphémère**, le backend configuré,
|
|
671
|
+
le backend résolu, la liste des backends disponibles et la raison de la résolution — brique
|
|
672
|
+
`idempotency` (`stores/storesModel.ts:150`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
|
|
673
|
+
croyait.
|
|
674
|
+
- **ERD** — la table `idempotency_key` est regroupée sous `@nodefony/framework`
|
|
675
|
+
(`idempotencyEntity.ts:133`), pas sous l'ORM qui l'héberge.
|
|
676
|
+
|
|
677
|
+
> [!NOTE]
|
|
678
|
+
> Il n'existe **pas encore** d'écran ni d'endpoint admin listant les clés d'idempotence vivantes :
|
|
679
|
+
> `listPage` est implémenté par les trois stores et couvert par un banc de contrat, mais aucun
|
|
680
|
+
> producteur `/nodefony/<ns>/api/*` ne l'expose. Pour inspecter le parc en attendant : `redis-cli
|
|
681
|
+
--scan --pattern 'nf:idem:*'`, ou un `SELECT` sur `idempotency_key`.
|
|
682
|
+
|
|
683
|
+
## ⚠️ Pièges
|
|
684
|
+
|
|
685
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
686
|
+
| ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
|
687
|
+
| Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:549`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
|
|
688
|
+
| `409` en boucle sur un endpoint | Action qui lève avant `complete`/`abort` → in-flight bloqué jusqu'au bail (60 s) | Le seam le gère ; en usage manuel du store, `try/finally` obligatoire |
|
|
689
|
+
| `422 Idempotency-Key is already used` | Même clé, **payload différent** (empreinte ≠, `idempotency.ts:189`) | Une clé = une intention ; nouvelle clé par requête distincte |
|
|
690
|
+
| Rien n'est dédupliqué **malgré** la clé | Pas d'identité fiable → verdict `execute` (`idempotency.ts:176`) | S'assurer que le firewall a résolu l'utilisateur **avant** la mutation |
|
|
691
|
+
| Rien n'est dédupliqué, clé « un peu longue » | Clé > 255 octets → traitée comme **absente** (`idempotency.ts:36`) | Utiliser un UUID ; en mode strict la requête part en 400, pas en silence |
|
|
692
|
+
| Dédup qui saute en cluster | `store: memory` (per-pod) | `redis` (`SET NX`) ou `drizzle` (Postgres/MySQL) |
|
|
693
|
+
| `400 Idempotency-Key required` inattendu | Mode strict par défaut, **ou** requête WebSocket (toujours stricte) | Envoyer la clé, ou `@Idempotent({ required:false })` — sans effet en WS |
|
|
694
|
+
| `auto` résout `drizzle` alors qu'on attendait `memory` | Repli local persistant quand aucune infra n'est déclarée (`infra.ts:288`) | Comportement voulu ; forcer avec `store: "memory"` ou `NF_STORE=memory` |
|
|
695
|
+
| Boot qui échoue en prod sur l'idempotence | Store distribué nommé mais non câblé → fatal (`index.ts:132`) | Charger le module manquant (`@nodefony/redis`) ou corriger le nom |
|
|
696
|
+
| Aucune purge sur un store SQL | `intervalS` à 0 → scheduler désarmé, dit dans le log de boot (`idempotencyGc.ts:49`) | Remettre un intervalle, ou assumer une purge externe (cron) |
|
|
697
|
+
| `listPage` : `total` toujours absent | Backend Redis = mode **curseur**, `total` jamais rendu | Boucler sur `nextCursor` ; ne pas coder de pagination par offset côté client |
|
|
698
|
+
|
|
699
|
+
## 🧪 Tests et couverture
|
|
700
|
+
|
|
701
|
+
Les chiffres exacts vivent dans la carte régénérée depuis vitest — jamais figés ici. Le répertoire des
|
|
702
|
+
familles couvertes :
|
|
703
|
+
|
|
704
|
+
- **Unitaires** (`@nodefony/framework`) : `idempotency.test.ts` (les verdicts, la résolution de clé,
|
|
705
|
+
l'identité, l'empreinte) · `IdempotencyStore.test.ts` (store mémoire : réservation, empreinte,
|
|
706
|
+
isolation des identités, expiration, borne mémoire) · `RedisIdempotencyStore.test.ts` (réservation
|
|
707
|
+
atomique, rejeu, libération, TTL natif, course `SET NX` puis `GET` vide) ·
|
|
708
|
+
`idempotencyStoreRegistry.test.ts` (registre) · `idempotencyGc.test.ts` (armement conditionnel du
|
|
709
|
+
GC) · `resolverIdempotency.test.ts` (le **seam** Resolver : rejeu sans ré-exécution, scope
|
|
710
|
+
d'identité).
|
|
711
|
+
- **Intégration** (`@nodefony/drizzle`) : `idempotency-store.test.ts` (sémantique séquentielle SQLite)
|
|
712
|
+
et `idempotency-pagination.test.ts` (listing déroulé sur les **trois** dialectes depuis un seul
|
|
713
|
+
fichier).
|
|
714
|
+
- **E2E base réelle** : `idempotency-mysql.e2e.test.ts` (verdicts, vol d'entrée expirée, concurrence
|
|
715
|
+
deux pods), gaté par `NF_MYSQL_URL`.
|
|
716
|
+
- **Banc de contrat** : `idempotencyPaginationContract.ts` (core) — **une** suite backend-agnostique
|
|
717
|
+
branchée sur mémoire, Redis et Drizzle × 3 dialectes. Elle porte une exigence de **sécurité** autant
|
|
718
|
+
que de pagination : un backend qui laisserait remonter la réponse mémorisée fait échouer le test
|
|
719
|
+
marqué 🔒.
|
|
720
|
+
|
|
721
|
+
Ce qui **manque**, dit franchement :
|
|
722
|
+
|
|
723
|
+
- Aucun **test de charge** dédié à la porte d'idempotence.
|
|
724
|
+
- Le mode **curseur** de Redis n'est prouvé que contre un **double déterministe** (`FakeRedis`), pas
|
|
725
|
+
contre un serveur Redis réel — alors que le curseur composite existe justement à cause d'un
|
|
726
|
+
comportement (`SCAN COUNT` n'est pas un plafond) observé sur un serveur réel.
|
|
727
|
+
- L'atomicité **cross-pod** est prouvée sur PostgreSQL et MySQL/MariaDB ; SQLite ne valide que la
|
|
728
|
+
sémantique séquentielle (mono-fichier).
|
|
729
|
+
|
|
730
|
+
Couverture : `npm run coverage` dans `@nodefony/framework` et `@nodefony/drizzle`. Skills utiles :
|
|
731
|
+
`nodefony-load-test` (charge), `nodefony-check-memory-health` (mémoire), `nodefony-security-review`
|
|
732
|
+
(revue sécurité).
|
|
733
|
+
|
|
734
|
+
## 🔗 Pour aller plus loin
|
|
735
|
+
|
|
736
|
+
- ⬆️ **Retour au hub** : [@nodefony/framework — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
737
|
+
- 🧭 **Pages sœurs** : [Pipeline de requête](../../../../../docs/architecture/pipeline-requete.md) · [Firewall (l'identité qui scope la clé)](../../security/docs/firewall.md)
|
|
738
|
+
- 🗄️ **Stores distribués** : [@nodefony/redis](../../redis/docs/index.md) · [@nodefony/drizzle](../../drizzle/docs/index.md)
|
|
739
|
+
- 📖 **Contrat de pagination** partagé par tous les stores : `IPage` / `IPageQuery`
|
|
740
|
+
(`src/nodefony/src/types/IPage.ts:18`)
|
|
741
|
+
- 🧠 **Contexte de requête** (ALS : identité, clé WS, corps) → [request-context](../../../../nodefony/docs/request-context.md)
|