@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.
Files changed (96) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. 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)