@nodefony/framework 10.0.0-beta.3 → 10.0.0-beta.5

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 (66) hide show
  1. package/README.md +19 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +9 -18
  4. package/dist/nodefony/config/config.js +1 -2
  5. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  6. package/dist/nodefony/controller/AdminApiController.js +39 -8
  7. package/dist/nodefony/controller/ApiKeyController.js +16 -8
  8. package/dist/nodefony/controller/BenchController.js +1 -2
  9. package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
  10. package/dist/nodefony/controller/LoginPageController.js +4 -11
  11. package/dist/nodefony/controller/OAuth2Controller.js +3 -4
  12. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
  13. package/dist/nodefony/controller/SessionAuthController.js +13 -4
  14. package/dist/nodefony/controller/TokenAuthController.js +3 -4
  15. package/dist/nodefony/controller/TotpController.js +2 -3
  16. package/dist/nodefony/controller/WebAuthnController.js +2 -3
  17. package/dist/nodefony/controller/oauthAuthority.js +1 -2
  18. package/dist/nodefony/decorators/routerDecorators.js +155 -53
  19. package/dist/nodefony/service/AdminBroker.js +4 -6
  20. package/dist/nodefony/service/Eta.js +1 -2
  21. package/dist/nodefony/service/IdempotencyStore.js +4 -6
  22. package/dist/nodefony/service/router.js +49 -15
  23. package/dist/nodefony/src/Controller.js +32 -39
  24. package/dist/nodefony/src/FrameworkAdminApi.js +31 -10
  25. package/dist/nodefony/src/KernelAdminApi.js +7 -9
  26. package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
  27. package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
  28. package/dist/nodefony/src/Resolver.js +68 -33
  29. package/dist/nodefony/src/ResourceController.js +13 -6
  30. package/dist/nodefony/src/Route.js +2 -3
  31. package/dist/nodefony/src/SecurityApiController.js +1 -2
  32. package/dist/nodefony/src/SyslogAdminApi.js +1 -2
  33. package/dist/nodefony/src/Template.js +1 -2
  34. package/dist/nodefony/src/configMutation.js +1 -2
  35. package/dist/nodefony/src/docsReader.js +1 -2
  36. package/dist/nodefony/src/idempotency.js +1 -2
  37. package/dist/nodefony/src/idempotencyGc.js +1 -2
  38. package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
  39. package/dist/nodefony/src/loginPage.js +2 -3
  40. package/dist/nodefony/src/requireAuthentication.js +1 -2
  41. package/dist/nodefony/src/routeSchema.js +81 -0
  42. package/dist/nodefony/src/scopeCatalog.js +1 -2
  43. package/dist/nodefony/src/singletonGuard.js +1 -2
  44. package/dist/nodefony/src/syslogFilters.js +7 -2
  45. package/dist/types/index.d.ts +5 -10
  46. package/dist/types/nodefony/controller/AdminApiController.d.ts +13 -1
  47. package/dist/types/nodefony/controller/SessionAuthController.d.ts +7 -0
  48. package/dist/types/nodefony/decorators/routerDecorators.d.ts +133 -32
  49. package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
  50. package/dist/types/nodefony/service/router.d.ts +27 -1
  51. package/dist/types/nodefony/src/Controller.d.ts +23 -17
  52. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
  53. package/dist/types/nodefony/src/Resolver.d.ts +33 -1
  54. package/dist/types/nodefony/src/ResourceController.d.ts +12 -4
  55. package/dist/types/nodefony/src/Route.d.ts +3 -0
  56. package/dist/types/nodefony/src/loginPage.d.ts +1 -1
  57. package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
  58. package/dist/types/nodefony/src/syslogFilters.d.ts +6 -0
  59. package/docs/admin.md +131 -82
  60. package/docs/controller.md +109 -110
  61. package/docs/decorateurs.md +214 -161
  62. package/docs/idempotence.md +154 -136
  63. package/docs/routing.md +105 -90
  64. package/docs/templates.md +55 -55
  65. package/package.json +8 -11
  66. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -37,7 +37,7 @@ coverageFiles: idempotency.ts,IdempotencyStore.ts,RedisIdempotencyStore.ts,idemp
37
37
  ## 🧠 Le modèle mental — un verrou par intention
38
38
 
39
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
40
+ verdicts (`IdempotencyOutcome`, `src/nodefony/src/types/IIdempotencyStore.ts#IdempotencyOutcome`) ; le helper les
41
41
  traduit en une décision neutre que le pipeline applique.
42
42
 
43
43
  ```mermaid
@@ -53,14 +53,14 @@ stateDiagram-v2
53
53
  ```
54
54
 
55
55
  Les quatre verdicts du store et leur traduction, décidés par `evaluateIdempotency()`
56
- (`idempotency.ts:131`) :
56
+ (`idempotency.ts#evaluateIdempotency`) :
57
57
 
58
58
  - **fresh** → tu détiens la réservation : exécute l'action, puis **obligatoirement** `complete(clé,
59
59
  réponse)` (succès) ou `abort(clé)` (échec). Verdict rendu : `guarded`.
60
60
  - **replayed** → la réponse de la 1ʳᵉ exécution est renvoyée telle quelle, l'action **n'est pas
61
61
  rejouée**. Verdict rendu : `replay`.
62
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`).
63
+ - **mismatch** → la clé a déjà servi pour un **autre** payload → `422` (`idempotency.ts#evaluateIdempotency`).
64
64
 
65
65
  S'il ne faut retenir qu'une image : `begin` est un **verrou atomique par intention**, et tout le
66
66
  reste du pipeline ne fait que traduire son verdict.
@@ -98,13 +98,13 @@ Sans ce ticket, trois choses cassent :
98
98
  donc il **doit** retenter ; c'est au serveur de rendre le retry inoffensif.
99
99
  2. **IDOR sur le cache** — si la clé stockée était la clé brute du client, il suffirait de deviner
100
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:171`) : l'anti-IDOR n'est pas un contrôle
101
+ `JSON.stringify([identité, cléClient])` (`idempotency.ts#evaluateIdempotency`) : l'anti-IDOR n'est pas un contrôle
102
102
  ajouté, il est **structurel**, encodé dans la clé de cache. Le JSON sert de frontière non ambiguë
103
103
  (aucun séparateur magique qui pourrait entrer en collision).
104
104
  3. **DoS du cache** — une clé arbitrairement longue, ou un flot de clés uniques, remplirait la
105
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:25`).
106
+ (`IDEMPOTENCY_KEY_MAX`, `idempotency.ts#IDEMPOTENCY_KEY_MAX`) et le cache mémoire est **borné** (`DEFAULT_CAP`,
107
+ `IdempotencyStore.ts#DEFAULT_CAP`).
108
108
 
109
109
  > [!IMPORTANT]
110
110
  > L'idempotence n'est pas qu'une commodité de résilience : c'est une brique de **sécurité**. Une
@@ -114,21 +114,21 @@ Sans ce ticket, trois choses cassent :
114
114
  ## La vision Nodefony
115
115
 
116
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:131`), qui ne
118
- connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `idempotency.ts:50`)
117
+ empreinte) est décidée dans **un helper pur**, `evaluateIdempotency()` (`idempotency.ts#evaluateIdempotency`), qui ne
118
+ connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `idempotency.ts#IdempotencyVerdict`)
119
119
  que **deux** appelants traduisent dans leur monde :
120
120
 
121
121
  - le **data plane admin** — `AdminApiController.idempotencyGate()`
122
- (`AdminApiController.ts:155`) → réponse `{status, headers, body}` ;
122
+ (`AdminApiController.ts#AdminApiController.idempotencyGate`) → réponse `{status, headers, body}` ;
123
123
  - les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
124
- (`Resolver.ts:687`) → `nodefonyError` typée, ou réponse rejouée.
124
+ (`Resolver.ts#Resolver._callWithIdempotency`) → `nodefonyError` typée, ou réponse rejouée.
125
125
 
126
126
  Conséquence pratique : il est **impossible** que l'idempotence HTTP et l'idempotence WebSocket
127
127
  divergent — elles partagent la même fonction. C'est le différenciateur du framework (HTTP + WS
128
128
  co-citoyens dans le même contexte controller) appliqué à une brique de sécurité.
129
129
 
130
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
131
+ **CORE** (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore`), pas dans `@nodefony/framework` — pour
132
132
  que `@nodefony/redis` et `@nodefony/drizzle`, qui sont **sous** framework dans le graphe de
133
133
  dépendances, puissent l'implémenter sans créer de cycle.
134
134
 
@@ -176,7 +176,7 @@ le fait pour toi.)
176
176
  ### 2. Le store — rien à écrire en dev, un mot en cluster
177
177
 
178
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
179
+ MemoryIdempotencyStore])`, `src/packages/@nodefony/framework/index.ts`). En mono-pod, tu n'as
180
180
  rien à configurer. Pour un cluster multi-pod, nomme un store **distribué** :
181
181
 
182
182
  ```typescript
@@ -223,7 +223,7 @@ curl -si -X POST http://localhost:5151/api/payments/charge \
223
223
  > [!WARNING]
224
224
  > **Le piège n°1** : en verdict `fresh`, la réservation est _in-flight_ tant que `complete` **ou**
225
225
  > `abort` n'a pas été appelé. Le seam `@Idempotent` gère ce couple pour toi (`try/catch`,
226
- > `Resolver.ts:764` et `Resolver.ts:788`). Si tu appelles le store **à la main** (cas avancé), c'est
226
+ > `Resolver.ts#Resolver._callWithIdempotency` et `Resolver.ts#Resolver._callWithIdempotency`). Si tu appelles le store **à la main** (cas avancé), c'est
227
227
  > **ta** responsabilité : sans `abort` sur erreur, la clé reste bloquée jusqu'à l'expiration du bail
228
228
  > (60 s), et tout rejeu identique reçoit `409` pendant ce temps.
229
229
 
@@ -238,7 +238,7 @@ une route que d'anciens clients appellent déjà sans clé, le temps de la migra
238
238
  async subscribe(@Body() body: SubscribeInput) { /* … */ }
239
239
  ```
240
240
 
241
- Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:1710`), comme
241
+ Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts#computeIdempotent`), comme
242
242
  `@UseSession`. Poser `@Idempotent()` sur la **classe** couvre toutes les mutations du controller ;
243
243
  une méthode peut resserrer ou relâcher le mode. Les méthodes sûres (GET…) restent des no-op même
244
244
  sous une classe décorée.
@@ -247,7 +247,7 @@ sous une classe décorée.
247
247
 
248
248
  Une socket **reconnecte et rejoue par nature** : muter sans clé y serait un piège à double-effet
249
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
250
+ (`idempotency.ts#evaluateIdempotency`). Une mutation par socket **exige toujours** une clé, même quand le mode HTTP
251
251
  est souple.
252
252
 
253
253
  | Situation | `@Idempotent()` (strict) | `@Idempotent({ required:false })` |
@@ -260,13 +260,13 @@ est souple.
260
260
  Deux mécanismes rendent ça possible côté socket :
261
261
 
262
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.
263
+ `resolveIdempotencyKey()` (`idempotency.ts#resolveIdempotencyKey`) donne la **priorité à l'ALS** sur l'en-tête HTTP.
264
264
  - **La méthode logique voyage par `methodOverride`** — sur une socket, `context.method` vaut
265
265
  `WEBSOCKET`, qui n'est **pas** une mutation. Sans override, la porte serait sautée et un rejeu de
266
266
  frame `socket.mutate` créerait un doublon. Le Resolver teste donc
267
- `isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:130`).
267
+ `isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts#Resolver.action`).
268
268
 
269
- Le pont utilise `executeActionGuarded()` (`Resolver.ts:665`) : porte d'idempotence **sans** rendu HTTP
269
+ Le pont utilise `executeActionGuarded()` (`Resolver.ts#Resolver.executeActionGuarded`) : porte d'idempotence **sans** rendu HTTP
270
270
  — la valeur nue est enveloppée par le peer WS, jamais écrite sur un transport HTTP.
271
271
 
272
272
  ## 🏗️ Architecture interne
@@ -299,22 +299,22 @@ sequenceDiagram
299
299
 
300
300
  ### Le parcours d'une mutation, étape par étape
301
301
 
302
- 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:618`) lit `meta.idempotent` sur les
302
+ 1. **Court-circuit hot path.** `callController()` (`Resolver.ts#Resolver.callController`) lit `meta.idempotent` sur les
303
303
  métadonnées d'action **figées par route**. `null` sur la quasi-totalité des routes → une
304
304
  comparaison, flux normal, **zéro** lookup de store et zéro allocation.
305
305
  2. **No-op sur méthode sûre.** Une action `GET` sous une classe `@Idempotent` repart directement en
306
- exécution (`Resolver.ts:693`).
307
- 3. **Empreinte du payload.** `computeFingerprint()` (`idempotency.ts:112`) hache
308
- `[nom de route, params de route, corps]` (`Resolver.ts:501`). Le corps vient de l'ALS (pont WS) ou
306
+ exécution (`Resolver.ts#Resolver._callWithIdempotency`).
307
+ 3. **Empreinte du payload.** `computeFingerprint()` (`idempotency.ts#computeFingerprint`) hache
308
+ `[nom de route, params de route, corps]` (`Resolver.ts#Resolver._enforceActionSecurity`). Le corps vient de l'ALS (pont WS) ou
309
309
  du body HTTP parsé.
310
- 4. **Identité.** `resolveIdentity()` (`idempotency.ts:102`) rend l'`identifier` de l'utilisateur
310
+ 4. **Identité.** `resolveIdentity()` (`idempotency.ts#resolveIdentity`) rend l'`identifier` de l'utilisateur
311
311
  AUTHENTIFIÉ de la requête (`RequestContext.getAuthenticatedUser()`, la règle unique du cœur).
312
312
  L'anonyme d'une zone `anonymous` n'a pas d'identité : tous les visiteurs anonymes partagent le
313
313
  même `identifier`, ils partageraient donc le même cache. **`null` = pas de cache** : le verdict
314
314
  devient `execute` — jamais de partage cross-identité.
315
315
  5. **Réservation.** `store.begin()` compose la clé scopée et tranche.
316
316
  6. **Mémorisation.** En succès, `complete(clé, {status, body})` où `status` est le code de réponse
317
- courant et `body` la **valeur retournée** par l'action (`Resolver.ts:722`). En erreur,
317
+ courant et `body` la **valeur retournée** par l'action (`Resolver.ts#Resolver._callWithIdempotency`). En erreur,
318
318
  `abort(clé)` libère la clé : **un échec ne se mémorise pas**, il doit rester réessayable.
319
319
 
320
320
  > [!CAUTION]
@@ -323,48 +323,48 @@ sequenceDiagram
323
323
  > double-effet reste évité, mais le corps rejoué est vide. Pire, si le corps retourné n'est pas
324
324
  > sérialisable (retour d'un objet Response circulaire), un store SQL/Redis lève au `stringify`. Le
325
325
  > Resolver attrape ce cas : il **journalise un WARNING explicite** puis mémorise le statut avec un
326
- > corps `null` (`Resolver.ts:554`) — la dédup est préservée plutôt que perdue en silence.
326
+ > corps `null` (`Resolver.ts#Resolver._acquireAndInvoke`) — la dédup est préservée plutôt que perdue en silence.
327
327
 
328
328
  ### Où la porte est câblée
329
329
 
330
- | Appelant | Point d'entrée | Traduction du verdict |
331
- | ---------------------------- | -------------------------------------------------------------------- | ---------------------------------- |
332
- | Controller userland HTTP | `callController()` (`Resolver.ts:618`) | `nodefonyError` + rendu normal |
333
- | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:665`) | valeur nue, enveloppée par le peer |
334
- | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:155`) | `{status, headers, body}` |
330
+ | Appelant | Point d'entrée | Traduction du verdict |
331
+ | ---------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------- |
332
+ | Controller userland HTTP | `callController()` (`Resolver.ts#Resolver.callController`) | `nodefonyError` + rendu normal |
333
+ | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts#Resolver.executeActionGuarded`) | valeur nue, enveloppée par le peer |
334
+ | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts#AdminApiController.idempotencyGate`) | `{status, headers, body}` |
335
335
 
336
336
  ## ⚙️ Configuration
337
337
 
338
338
  Source unique = schéma Zod `idempotencySchema`
339
- (`src/packages/@nodefony/framework/nodefony/config/config.ts:42`).
339
+ (`src/packages/@nodefony/framework/nodefony/config/config.ts#idempotencySchema`).
340
340
 
341
- | Option | Type | Défaut | Effet |
342
- | ------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
343
- | `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` / `mongoose` sont explicites (`config.ts:43`). |
344
- | `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`). |
345
- | `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts:68`). |
341
+ | Option | Type | Défaut | Effet |
342
+ | ------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
343
+ | `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` / `mongoose` sont explicites (`config.ts#idempotencySchema`). |
344
+ | `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#idempotencySchema`). |
345
+ | `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts#idempotencySchema`). |
346
346
 
347
347
  ### Comment `store: "auto"` se résout VRAIMENT
348
348
 
349
- `Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:230`) délègue à
350
- `resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts:289`), borné aux backends
351
- **réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts:81`). L'ordre
349
+ `Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts#Framework.onKernelBoot`) délègue à
350
+ `resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts#resolveAutoStore`), borné aux backends
351
+ **réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts#listIdempotencyBackends`). L'ordre
352
352
  réel est le suivant :
353
353
 
354
- | Ordre | Condition | Résolution | Ancre |
355
- | ----- | ------------------------------------------------------------------------ | -------------------------------------- | -------------- |
356
- | 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:343` |
357
- | 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts:258` |
358
- | 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts:261` |
359
- | 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts:274` |
360
- | 5 | **Aucune infra déclarée** mais un backend local persistant est chargé | `drizzle`, puis `mongoose` | `infra.ts:288` |
361
- | 6 | Rien de tout ça | `memory` (volatil) | `infra.ts:295` |
354
+ | Ordre | Condition | Résolution | Ancre |
355
+ | ----- | ------------------------------------------------------------------------ | -------------------------------------- | ----------------------------- |
356
+ | 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts#resolveAutoStore` |
357
+ | 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts#CONNECTED_STORES` |
358
+ | 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts#durableStoreRemedy` |
359
+ | 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts#resolveAutoStore` |
360
+ | 5 | **Aucune infra déclarée** mais un backend local persistant est chargé | `drizzle`, puis `mongoose` | `infra.ts#CONNECTED_STORES` |
361
+ | 6 | Rien de tout ça | `memory` (volatil) | `infra.ts#CONNECTED_STORES` |
362
362
 
363
363
  > [!NOTE]
364
364
  > L'étape **5** surprend souvent : dans une app dev qui charge `@nodefony/drizzle` **sans** déclarer
365
365
  > `NF_DATABASE_URL`, `auto` ne résout **pas** vers `memory` mais vers `drizzle` (SQLite local) — pour
366
366
  > que les données survivent au redémarrage. La résolution effective est toujours **journalisée au
367
- > boot** (`index.ts:207`) : lis cette ligne plutôt que de la déduire.
367
+ > boot** (`index.ts#Framework.onKernelRegister`) : lis cette ligne plutôt que de la déduire.
368
368
 
369
369
  MongoDB mérite une mention : `mongoose` porte lui aussi un store d'idempotence. Déclarer
370
370
  `NF_DATABASE_URL=mongodb://…` résout donc en étape **3** → `mongoose`, et la dédup cross-pod tient
@@ -376,20 +376,20 @@ le perdant reçoit un `E11000` : l'équivalent Mongo du `SET … NX PX` de Redis
376
376
 
377
377
  Quand un store **explicitement nommé** ne peut pas s'initialiser (nom inconnu, `redis` demandé sans
378
378
  le module `@nodefony/redis` chargé), la politique dépend de l'environnement
379
- (`index.ts:132`) :
379
+ (`index.ts`) :
380
380
 
381
- - **production** → **boot avorté** (`index.ts:281`). En cluster multi-pod, dégrader vers un cache
381
+ - **production** → **boot avorté** (`index.ts#Framework.onKernelBoot`). En cluster multi-pod, dégrader vers un cache
382
382
  per-pod produirait du double-effet non dédupliqué : c'est un défaut de sécurité, pas une commodité.
383
383
  - **dev / test** (mono-pod) → **WARNING fort + repli sur le cache mémoire** déjà en place. La dédup
384
384
  per-pod suffit hors cluster, et on ne casse pas le routeur pour une option d'infra absente en local.
385
385
 
386
386
  Un garde-fou supplémentaire : `store: "memory"` **en production** émet un WARNING dédié
387
- (`index.ts:212`) — la dédup n'y est que per-pod.
387
+ (`index.ts#Framework.onKernelRegister`) — la dédup n'y est que per-pod.
388
388
 
389
389
  ## 🧩 Les stores d'idempotence
390
390
 
391
391
  Tous respectent le même contrat `IIdempotencyStore`
392
- (`src/nodefony/src/types/IIdempotencyStore.ts:106`) : `begin` / `complete` / `abort` obligatoires,
392
+ (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore`) : `begin` / `complete` / `abort` obligatoires,
393
393
  `gc` **optionnel**, plus `listPage` et `size` pour l'introspection.
394
394
 
395
395
  ### Choisir en 5 secondes
@@ -403,20 +403,20 @@ Tous respectent le même contrat `IIdempotencyStore`
403
403
 
404
404
  ### `memory` — le défaut per-pod, gratuit
405
405
 
406
- `MemoryIdempotencyStore` (`IdempotencyStore.ts:44`) est enregistré d'office comme service DI
406
+ `MemoryIdempotencyStore` (`IdempotencyStore.ts#MemoryIdempotencyStore`) est enregistré d'office comme service DI
407
407
  `idempotencyStore` par le manifeste `@services` du module framework — zéro configuration.
408
408
 
409
- - **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:115`) lit et écrit la `Map`
409
+ - **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts#MemoryIdempotencyStore.begin`) lit et écrit la `Map`
410
410
  sans point de suspension → deux `begin` concurrents ne peuvent pas se croiser.
411
- - **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts:21`), bail
412
- `DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts:23`), plafond `DEFAULT_CAP` = 1000 entrées
413
- (`IdempotencyStore.ts:25`). Elles ne sont **pas** configurables.
414
- - **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts:115`) ; aucun timer,
411
+ - **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts#DEFAULT_TTL_MS`), bail
412
+ `DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts#DEFAULT_LEASE_MS`), plafond `DEFAULT_CAP` = 1000 entrées
413
+ (`IdempotencyStore.ts#DEFAULT_CAP`). Elles ne sont **pas** configurables.
414
+ - **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts#MemoryIdempotencyStore.begin`) ; aucun timer,
415
415
  aucun listener.
416
416
  - **Purge passive** : les entrées expirées ne sont retirées qu'à l'écriture, dans `evictIfNeeded()`
417
- (`IdempotencyStore.ts:172`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
417
+ (`IdempotencyStore.ts#MemoryIdempotencyStore.evictIfNeeded`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
418
418
  sous le cap. Coût nul tant qu'on n'écrit pas.
419
- - **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts:142`) n'écrit **que** si la clé est
419
+ - **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts#MemoryIdempotencyStore.complete`) n'écrit **que** si la clé est
420
420
  encore _notre_ in-flight — jamais de résurrection d'une clé déjà `abort`-ée ou évincée.
421
421
 
422
422
  ⚠️ Limite structurelle : la dédup est **affine au pod**. Un rejeu routé vers un autre pod n'est pas
@@ -424,22 +424,22 @@ dédupliqué.
424
424
 
425
425
  ### `redis` — le choix cluster
426
426
 
427
- `RedisIdempotencyStore` (`RedisIdempotencyStore.ts:121`) vit dans `@nodefony/framework` (le
427
+ `RedisIdempotencyStore` (`RedisIdempotencyStore.ts#RedisIdempotencyStore`) vit dans `@nodefony/framework` (le
428
428
  consommateur du contrat), pas dans `@nodefony/redis` : il résout le service `redis` **par nom** dans
429
429
  le container (couplage structurel, zéro dépendance directe, zéro cycle). La fabrique est enregistrée
430
- au chargement du module framework (`index.ts:119`).
430
+ au chargement du module framework (`index.ts`).
431
431
 
432
- - **`SET key … NX PX`** = réservation atomique **côté serveur** (`RedisIdempotencyStore.ts:237`) : le
432
+ - **`SET key … NX PX`** = réservation atomique **côté serveur** (`RedisIdempotencyStore.ts#RedisIdempotencyStore.#prefix`) : le
433
433
  `409` in-flight fonctionne vraiment entre pods. Deux requêtes concurrentes sur deux pods, un seul
434
434
  `SET NX` gagne.
435
435
  - **TTL natif** sur le bail _et_ sur la réponse mémorisée → `gc()` **superflu**, donc **non
436
436
  implémenté** : rien à planifier.
437
- - **Empreinte préservée à la complétion** : `complete()` (`RedisIdempotencyStore.ts:300`) **relit**
437
+ - **Empreinte préservée à la complétion** : `complete()` (`RedisIdempotencyStore.ts#RedisIdempotencyStore.complete`) **relit**
438
438
  l'entrée in-flight pour reporter son empreinte dans l'entrée `done`. Sans cela, un rejeu de la clé
439
439
  avec un autre payload **après** complétion ne serait plus détecté (le 422 serait perdu).
440
440
  - **Course rare gérée** : si la clé expire entre le `SET NX` échoué et le `GET`, la réservation est
441
- retentée **une** fois ; encore prise → `in-flight` (`RedisIdempotencyStore.ts:258`).
442
- - **Namespace** : `nf:idem:<clé>` (`RedisIdempotencyStore.ts:11`).
441
+ retentée **une** fois ; encore prise → `in-flight` (`RedisIdempotencyStore.ts#RedisIdempotencyStore.begin`).
442
+ - **Namespace** : `nf:idem:<clé>` (`RedisIdempotencyStore.ts#KEY_BASE`).
443
443
  - **Dégradation gracieuse** : connexion `main` fermée (boot / shutdown) → `begin` renvoie `fresh`,
444
444
  `complete`/`abort` sont des no-op. La mutation s'exécute **sans dédup** plutôt que d'être bloquée.
445
445
 
@@ -450,47 +450,47 @@ au chargement du module framework (`index.ts:119`).
450
450
 
451
451
  ### `drizzle` — le cluster sans Redis
452
452
 
453
- `DrizzleIdempotencyStore` (`DrizzleIdempotencyStore.ts:102`). Motivation : un cluster qui possède
453
+ `DrizzleIdempotencyStore` (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore`). Motivation : un cluster qui possède
454
454
  déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra**.
455
455
 
456
456
  - **Réservation atomique en UNE instruction** — un `INSERT` avec
457
- `onConflictDoUpdate` (`DrizzleIdempotencyStore.ts:234`) dont la garde `setWhere` ne réécrit que si
458
- l'entrée est morte (`DrizzleIdempotencyStore.ts:269`). Le `returning` ne rend une ligne que si
457
+ `onConflictDoUpdate` (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.begin`) dont la garde `setWhere` ne réécrit que si
458
+ l'entrée est morte (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.begin`). Le `returning` ne rend une ligne que si
459
459
  l'INSERT a passé (clé neuve) ou si le `DO UPDATE` a **volé** une entrée expirée → `fresh`. Zéro
460
460
  ligne = contention → on lit l'état réel.
461
461
  - **Invariant capital** : le store ne renvoie **jamais** `fresh` hors réservation atomique gagnée.
462
462
  Même la course rare « la clé a expiré entre l'upsert et le SELECT » renvoie prudemment `in-flight`
463
- (`DrizzleIdempotencyStore.ts:264`), jamais `fresh`.
463
+ (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.begin`), jamais `fresh`.
464
464
  - **MySQL/MariaDB** : ni `RETURNING`, ni `WHERE` sur l'`ON DUPLICATE KEY UPDATE`, et un `affectedRows`
465
465
  ambigu → la réservation passe par `reserveIdempotencyKeyMysql()`
466
- (`DrizzleIdempotencyStore.ts:233`), qui la reconstruit en deux instructions chacune atomique.
467
- - **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts:318`) = `DELETE WHERE expiresAt <= now`.
466
+ (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.begin`), qui la reconstruit en deux instructions chacune atomique.
467
+ - **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.abort`) = `DELETE WHERE expiresAt <= now`.
468
468
  C'est le **seul** store qui expose `gc`, donc le seul que le framework planifie (voir plus bas).
469
- - **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:301`) et `abort()`
470
- (`DrizzleIdempotencyStore.ts:319`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
469
+ - **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.complete`) et `abort()`
470
+ (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.abort`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
471
471
  déjà mémorisée, jamais de résurrection d'une clé libérée. `complete` ne touche pas `fingerprint`.
472
472
  - **Résolution lazy + dégradation gracieuse** : le handle Drizzle est résolu à **chaque** appel
473
- (`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:191`). ORM non connecté → `begin`
473
+ (`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.from`). ORM non connecté → `begin`
474
474
  renvoie `fresh` (sans dédup), le reste est no-op.
475
475
 
476
476
  Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
477
- (`registerStores.ts:316`). Activation = `store: "drizzle"` (ou `NF_STORE=drizzle`, qui force toutes les briques `auto`), rien
477
+ (`registerStores.ts#registerDrizzleFrameworkStores`). Activation = `store: "drizzle"` (ou `NF_STORE=drizzle`, qui force toutes les briques `auto`), rien
478
478
  d'autre à écrire.
479
479
 
480
480
  ### Le GC — armé pour un seul store
481
481
 
482
- `scheduleIdempotencyGc()` (`idempotencyGc.ts:32`) arme un `GcScheduler` **uniquement si le store
483
- expose `gc()`** (`idempotencyGc.ts:37`). Un store à TTL natif (`redis`) ou à purge passive (`memory`)
482
+ `scheduleIdempotencyGc()` (`idempotencyGc.ts#scheduleIdempotencyGc`) arme un `GcScheduler` **uniquement si le store
483
+ expose `gc()`** (`idempotencyGc.ts#scheduleIdempotencyGc`). Un store à TTL natif (`redis`) ou à purge passive (`memory`)
484
484
  ne l'expose pas ; le brancher sur un timer serait un no-op coûteux. Le scheduler est armé au boot
485
- (`nodefony/framework/index.ts:311`) et arrêté à `onTerminate`.
485
+ (`nodefony/framework/index.ts#Framework.onKernelBoot`) et arrêté à `onTerminate`.
486
486
 
487
487
  `gcIntervalS: 0` désarme le timer — à réserver au cas où la purge est déléguée (cron, `CronJob` k8s).
488
488
 
489
489
  ## 🗄️ Entité de persistance (store SQL)
490
490
 
491
491
  Table `idempotency_key`, décrite par une **spec colKit** unique
492
- (`idempotencyEntity.ts:66`) déclinée par dialecte via `createIdempotencyTable(dialect)`
493
- (`idempotencyEntity.ts:85`).
492
+ (`idempotencyEntity.ts#IDEMPOTENCY_ENTITY_NAME`) déclinée par dialecte via `createIdempotencyTable(dialect)`
493
+ (`idempotencyEntity.ts#createIdempotencyTable`).
494
494
 
495
495
  | Colonne | Type logique | SQLite | PostgreSQL | MySQL / MariaDB | Rôle |
496
496
  | ------------- | ------------ | ------------------ | ---------- | --------------- | ------------------------------------------------------------------------------------- |
@@ -504,15 +504,15 @@ Deux détails qui expliquent des surprises réelles :
504
504
 
505
505
  - **`varchar(512)` en MySQL** n'est pas un caprice : un `TEXT` InnoDB n'est pas indexable sans
506
506
  préfixe, et 512 caractères couvrent `JSON.stringify([identité, clé ≤ 255])`.
507
- - **Aucun `DEFAULT` SQL** : le DDL dérivé n'en émet pas (`idempotencyEntity.ts:38`). Toutes les
507
+ - **Aucun `DEFAULT` SQL** : le DDL dérivé n'en émet pas (`idempotencyEntity.ts#idempotencyKeySchema`). Toutes les
508
508
  colonnes sont posées explicitement par le store — jamais d'INSERT cassé par un défaut manquant.
509
509
 
510
- `registerIdempotencyEntities(connector, dialect)` (`idempotencyEntity.ts:146`) doit être appelé
510
+ `registerIdempotencyEntities(connector, dialect)` (`idempotencyEntity.ts#registerIdempotencyEntities`) doit être appelé
511
511
  **avant** `orm.connect()` (la table est créée au connect) — le module drizzle s'en charge tout seul.
512
512
 
513
513
  > [!NOTE]
514
514
  > **SQLite = banc de test de la sémantique.** Un fichier SQLite est mono-machine (verrou d'écriture)
515
- > → aucun intérêt multi-pod (`idempotencyEntity.ts:26`). La cible réelle est PostgreSQL ou
515
+ > → aucun intérêt multi-pod (`idempotencyEntity.ts#idempotencyKeySchema`). La cible réelle est PostgreSQL ou
516
516
  > MySQL/MariaDB, où l'atomicité de l'instruction tient sous concurrence inter-pods.
517
517
 
518
518
  ## 🗃️ Dialectes et bases pris en charge
@@ -540,24 +540,24 @@ Signatures complètes : `.ai/symbols.json`. Ce qui compte à l'usage :
540
540
 
541
541
  ### Le décorateur
542
542
 
543
- `@Idempotent(options?)` (`routerDecorators.ts:1171`) — dual **classe + méthode**. N'écrit que des
544
- métadonnées (`IdempotentMeta`, `routerDecorators.ts:477`), zéro import de `@nodefony/security`, zéro
543
+ `@Idempotent(options?)` (`routerDecorators.ts#Idempotent`) — dual **classe + méthode**. N'écrit que des
544
+ métadonnées (`IdempotentMeta`, `routerDecorators.ts#IdempotentMeta`), zéro import de `@nodefony/security`, zéro
545
545
  cycle. La porte est appliquée par le Resolver.
546
546
 
547
547
  ### Le contrat de store
548
548
 
549
- | Membre | Obligatoire | Rôle |
550
- | ------------------------- | :---------: | --------------------------------------------------------------------------------------------------------------- |
551
- | `begin(key, fingerprint)` | ✅ | Réserve atomiquement, rend le verdict (`src/nodefony/src/types/IIdempotencyStore.ts:118`). |
552
- | `complete(key, response)` | ✅ | Mémorise la réponse d'une clé in-flight → rejeux futurs = `replayed`. |
553
- | `abort(key)` | ✅ | Libère une clé in-flight dont l'exécution a échoué. Rien n'est mémorisé. |
554
- | `gc(now?)` | ❌ | Purge des expirées — **uniquement** sans expiration native (`src/nodefony/src/types/IIdempotencyStore.ts:139`). |
555
- | `listPage(query)` | ✅ | Page de clés vivantes, pour l'introspection admin (`src/nodefony/src/types/IIdempotencyStore.ts:153`). |
556
- | `size` | ✅ | Nombre d'entrées vivantes, **sync best-effort** (`src/nodefony/src/types/IIdempotencyStore.ts:159`). |
549
+ | Membre | Obligatoire | Rôle |
550
+ | ------------------------- | :---------: | -------------------------------------------------------------------------------------------------------------------------------- |
551
+ | `begin(key, fingerprint)` | ✅ | Réserve atomiquement, rend le verdict (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore.begin`). |
552
+ | `complete(key, response)` | ✅ | Mémorise la réponse d'une clé in-flight → rejeux futurs = `replayed`. |
553
+ | `abort(key)` | ✅ | Libère une clé in-flight dont l'exécution a échoué. Rien n'est mémorisé. |
554
+ | `gc(now?)` | ❌ | Purge des expirées — **uniquement** sans expiration native (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore.gc`). |
555
+ | `listPage(query)` | ✅ | Page de clés vivantes, pour l'introspection admin (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore.listPage`). |
556
+ | `size` | ✅ | Nombre d'entrées vivantes, **sync best-effort** (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore.size`). |
557
557
 
558
- Les helpers du seam sont exportés et réutilisables : `isMutationMethod()` (`idempotency.ts:28`),
559
- `resolveIdempotencyKey()` (`idempotency.ts:68`), `resolveIdentity()` (`idempotency.ts:102`),
560
- `computeFingerprint()` (`idempotency.ts:112`), `evaluateIdempotency()` (`idempotency.ts:131`).
558
+ Les helpers du seam sont exportés et réutilisables : `isMutationMethod()` (`idempotency.ts#isMutationMethod`),
559
+ `resolveIdempotencyKey()` (`idempotency.ts#resolveIdempotencyKey`), `resolveIdentity()` (`idempotency.ts#resolveIdentity`),
560
+ `computeFingerprint()` (`idempotency.ts#computeFingerprint`), `evaluateIdempotency()` (`idempotency.ts#evaluateIdempotency`).
561
561
 
562
562
  ### `listPage` — capacités RÉELLES par store
563
563
 
@@ -577,18 +577,18 @@ que le code fait, store par store — à lire avant d'écrire un client :
577
577
  | `state` | ✅ | ✅ | ✅ (filtre après lecture) |
578
578
  | Page pleine à `limit` | ✅ | ✅ | ❌ peut être plus courte |
579
579
  | Exclusion des expirées | ✅ à la lecture | ✅ `expiresAt > now` | ✅ par TTL natif |
580
- | Ancre | `IdempotencyStore.ts:72` | `DrizzleIdempotencyStore.ts:340` | `RedisIdempotencyStore.ts:166` |
580
+ | Ancre | `IdempotencyStore.ts#MemoryIdempotencyStore.listPage` | `DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.gc` | `RedisIdempotencyStore.ts#RedisIdempotencyStore.listPage` |
581
581
 
582
582
  Le mode **curseur** de Redis mérite une explication, parce qu'il piège : `SCAN COUNT` **n'est pas un
583
583
  plafond** mais un indice d'effort — Redis peut rendre plus de clés que demandé. Sans précaution, la
584
- page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts:108`). D'où
584
+ page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts#IPage`). D'où
585
585
  le **curseur composite** `"<consommé>:<curseurRedis>"` (`encodeCursor()`,
586
- `RedisIdempotencyStore.ts:63`) : on ne rend que `limit` éléments et on mémorise combien de clés du
586
+ `RedisIdempotencyStore.ts#encodeCursor`) : on ne rend que `limit` éléments et on mémorise combien de clés du
587
587
  lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et reprend là.
588
588
 
589
589
  > [!IMPORTANT]
590
590
  > **Une clé rendue par `listPage` ne contient JAMAIS la réponse mémorisée ni l'empreinte.**
591
- > `IIdempotencyKeyEntry` (`src/nodefony/src/types/IIdempotencyStore.ts:51`) n'expose que `key`,
591
+ > `IIdempotencyKeyEntry` (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyKeyEntry`) n'expose que `key`,
592
592
  > `state`, `expiresAtMs` et `hasResponse` (un booléen). Le corps mémorisé est la donnée métier d'un
593
593
  > utilisateur : le laisser sortir par ce chemin recréerait exactement l'IDOR sur le cache que le
594
594
  > scope de clé interdit.
@@ -619,44 +619,62 @@ registerIdempotencyStore("mon-backend", (ctx) => {
619
619
  });
620
620
  ```
621
621
 
622
- Trois règles héritées du code existant, à respecter sous peine de double-effet :
622
+ Quatre règles héritées du code existant, à respecter sous peine de double-effet :
623
623
 
624
624
  1. **`begin` doit être atomique côté backend.** Un `GET` puis `SET` séparés laissent deux retries
625
625
  concurrents obtenir `fresh` — précisément ce que l'idempotence doit empêcher.
626
626
  2. **Ne jamais rendre `fresh` hors réservation gagnée.** En cas de doute (course, état illisible),
627
- rendre `in-flight` : le client réessaiera, c'est sans danger.
627
+ rendre `in-flight` : le client réessaiera, c'est sans danger. La seule exception est la coupure
628
+ du backend, et elle se décide (règle 4).
628
629
  3. **`complete` doit préserver l'empreinte** de l'entrée in-flight, sinon un rejeu avec un autre
629
630
  payload après complétion ne produit plus de 422.
630
-
631
- Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts:48`),
632
- `getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts:56`), `listIdempotencyStores()`
633
- (distribués seuls, `idempotencyStoreRegistry.ts:67`), `listIdempotencyBackends()` (avec `memory`,
634
- pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
631
+ 4. **Une fabrique qui ne peut pas construire son store LÈVE — elle ne rend jamais un store qui se
632
+ tait.** Module manquant, client du mauvais type, configuration incohérente : tout ce qui est
633
+ certain dès le démarrage se refuse dans la fabrique. Le framework en fait un arrêt de boot en
634
+ production, et un `WARNING` suivi d'un repli sur la mémoire en développement
635
+ ([contrat de dégradation](#le-contrat-de-dégradation--fail-loud-jamais-silencieux)). Un store
636
+ construit malgré tout, qui garde chaque appel par `if (!connected) return`, échappe à ce
637
+ contrat : le registre affiche ton backend pendant que plus rien n'est dédoublonné. C'est un
638
+ échec **ouvert** — la garde a disparu, et rien ne le dit.
639
+
640
+ La connexion, elle, ne peut pas être exigée là : la fabrique est appelée à `onKernelBoot`
641
+ (`index.ts#Framework.onKernelBoot`), avant que l'ORM ne soit connecté. Elle se résout donc à
642
+ chaque appel, comme le fait `DrizzleIdempotencyStore.from()`
643
+ (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.from`), et la conduite pendant une coupure
644
+ est un **choix à écrire**. Les stores livrés rendent `fresh` — exécuter sans dédoublonnage
645
+ plutôt que bloquer — et l'annoncent comme un compromis
646
+ ([`redis`](#redis--le-choix-cluster)). Si ton domaine ne tolère aucun double effet, rends
647
+ `in-flight` (le client réessaiera) ou lève : la requête échoue, rien n'est exécuté deux fois.
648
+
649
+ Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts#registerIdempotencyStore`),
650
+ `getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts#getIdempotencyStoreFactory`), `listIdempotencyStores()`
651
+ (distribués seuls, `idempotencyStoreRegistry.ts#listIdempotencyStores`), `listIdempotencyBackends()` (avec `memory`,
652
+ pour l'affichage Studio, `idempotencyStoreRegistry.ts#listIdempotencyBackends`).
635
653
 
636
654
  ## 📜 Normes appliquées
637
655
 
638
656
  <!-- prettier-ignore -->
639
657
  | Sujet | Norme | Ancrage |
640
658
  | --- | --- | --- |
641
- | En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-06` | `evaluateIdempotency()` (`idempotency.ts:131`) |
642
- | Clé réutilisée avec un autre payload → 422 | draft §2.2 / §2.7 | `idempotency.ts:189` |
643
- | Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts:186` |
644
- | Clé requise absente → 400 | draft §2.7 | `idempotency.ts:165` |
645
- | Méthodes non sûres = mutations | RFC 9110 §9.2.1 | `MUTATION_METHODS` (`idempotency.ts:25`) |
646
- | Sémantique du 422 | RFC 9110 §15.5.21 | `IdempotencyVerdict` (`idempotency.ts:50`) |
647
- | Borne de clé (convention Stripe) | 255 octets | `IDEMPOTENCY_KEY_MAX` (`idempotency.ts:36`) |
659
+ | En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-06` | `evaluateIdempotency()` (`idempotency.ts#evaluateIdempotency`) |
660
+ | Clé réutilisée avec un autre payload → 422 | draft §2.2 / §2.7 | `idempotency.ts#evaluateIdempotency` |
661
+ | Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts#evaluateIdempotency` |
662
+ | Clé requise absente → 400 | draft §2.7 | `idempotency.ts#evaluateIdempotency` |
663
+ | Méthodes non sûres = mutations | RFC 9110 §9.2.1 | `MUTATION_METHODS` (`idempotency.ts#MUTATION_METHODS`) |
664
+ | Sémantique du 422 | RFC 9110 §15.5.21 | `IdempotencyVerdict` (`idempotency.ts#IdempotencyVerdict`) |
665
+ | Borne de clé (convention Stripe) | 255 octets | `IDEMPOTENCY_KEY_MAX` (`idempotency.ts#IDEMPOTENCY_KEY_MAX`) |
648
666
 
649
667
  ## ⚡ Performance et mémoire
650
668
 
651
669
  Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
652
- `null` (`routerDecorators.ts:1194`) : `callController()` fait **une comparaison** et repart en flux
653
- normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:618`).
670
+ `null` (`routerDecorators.ts#Idempotent`) : `callController()` fait **une comparaison** et repart en flux
671
+ normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts#Resolver.callController`).
654
672
  La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
655
673
 
656
674
  Sur le chemin décoré :
657
675
 
658
676
  - Le store mémoire n'alloue sa `Map` qu'au 1ᵉʳ `begin`, ne pose **aucun timer ni listener**, et purge
659
- en passif (`IdempotencyStore.ts:115`).
677
+ en passif (`IdempotencyStore.ts#MemoryIdempotencyStore.begin`).
660
678
  - L'empreinte est un hash SHA-256 court → comparaison O(1), et le payload n'est jamais conservé en
661
679
  clair.
662
680
  - Le GC est **hors hot-path** et armé pour un seul store (SQL) ; le jitter évite que N pods purgent
@@ -673,15 +691,15 @@ froid par construction ; si ton profil de trafic la place sur un chemin chaud, m
673
691
  Trois surfaces existent aujourd'hui :
674
692
 
675
693
  - **Playground** (`/nodefony/playground`) — chaque mutation protégée porte un badge `@Idempotent` (strict ou souple)
676
- (`playground/PlaygroundFormat.tsx:69`). L'écran génère une clé par exécution et propose « Rejouer
677
- même clé » dès que `action.guards.idempotent` est posé (`playground/ActionPanel.tsx:516`) : c'est la
694
+ (`playground/PlaygroundFormat.tsx#GuardBadges`). L'écran génère une clé par exécution et propose « Rejouer
695
+ même clé » dès que `action.guards.idempotent` est posé (`playground/ActionPanel.tsx#ActionPanel`) : c'est la
678
696
  façon la plus rapide de voir un rejeu, un `409` ou un `422` en vrai.
679
697
  - **Stores** — la brique `idempotency` y apparaît avec sa nature **éphémère**, le backend configuré,
680
698
  le backend résolu, la liste des backends disponibles et la raison de la résolution — brique
681
- `idempotency` (`stores/storesModel.ts:145`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
699
+ `idempotency` (`stores/storesModel.ts#BRICK_PURPOSE`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
682
700
  croyait.
683
701
  - **ERD** — la table `idempotency_key` est regroupée sous `@nodefony/framework`
684
- (`idempotencyEntity.ts:133`), pas sous l'ORM qui l'héberge.
702
+ (`idempotencyEntity.ts#createIdempotencyEntities`), pas sous l'ORM qui l'héberge.
685
703
 
686
704
  > [!NOTE]
687
705
  > Il n'existe **pas encore** d'écran ni d'endpoint admin listant les clés d'idempotence vivantes :
@@ -691,19 +709,19 @@ Trois surfaces existent aujourd'hui :
691
709
 
692
710
  ## ⚠️ Pièges
693
711
 
694
- | Symptôme | Cause (dans le code) | Correction |
695
- | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
696
- | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:778`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
697
- | `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 |
698
- | `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 |
699
- | 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 |
700
- | 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 |
701
- | Dédup qui saute en cluster | `store: memory` (per-pod) | `redis` (`SET NX`) ou `drizzle` (Postgres/MySQL) |
702
- | `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 |
703
- | `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` |
704
- | 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 |
705
- | 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) |
706
- | `listPage` : `total` toujours absent | Backend Redis = mode **curseur**, `total` jamais rendu | Boucler sur `nextCursor` ; ne pas coder de pagination par offset côté client |
712
+ | Symptôme | Cause (dans le code) | Correction |
713
+ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
714
+ | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts#Resolver._callWithIdempotency`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
715
+ | `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 |
716
+ | `422 Idempotency-Key is already used` | Même clé, **payload différent** (empreinte ≠, `idempotency.ts#evaluateIdempotency`) | Une clé = une intention ; nouvelle clé par requête distincte |
717
+ | Rien n'est dédupliqué **malgré** la clé | Pas d'identité fiable → verdict `execute` (`idempotency.ts#evaluateIdempotency`) | S'assurer que le firewall a résolu l'utilisateur **avant** la mutation |
718
+ | Rien n'est dédupliqué, clé « un peu longue » | Clé > 255 octets → traitée comme **absente** (`idempotency.ts#IDEMPOTENCY_KEY_MAX`) | Utiliser un UUID ; en mode strict la requête part en 400, pas en silence |
719
+ | Dédup qui saute en cluster | `store: memory` (per-pod) | `redis` (`SET NX`) ou `drizzle` (Postgres/MySQL) |
720
+ | `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 |
721
+ | `auto` résout `drizzle` alors qu'on attendait `memory` | Repli local persistant quand aucune infra n'est déclarée (`infra.ts#CONNECTED_STORES`) | Comportement voulu ; forcer avec `store: "memory"` ou `NF_STORE=memory` |
722
+ | Boot qui échoue en prod sur l'idempotence | Store distribué nommé mais non câblé → fatal (`index.ts`) | Charger le module manquant (`@nodefony/redis`) ou corriger le nom |
723
+ | Aucune purge sur un store SQL | `intervalS` à 0 → scheduler désarmé, dit dans le log de boot (`idempotencyGc.ts#IIdempotencyGcOptions.intervalS`) | Remettre un intervalle, ou assumer une purge externe (cron) |
724
+ | `listPage` : `total` toujours absent | Backend Redis = mode **curseur**, `total` jamais rendu | Boucler sur `nextCursor` ; ne pas coder de pagination par offset côté client |
707
725
 
708
726
  ## 🧪 Tests et couverture
709
727
 
@@ -746,5 +764,5 @@ Couverture : `npm run coverage` dans `@nodefony/framework` et `@nodefony/drizzle
746
764
  - 🧭 **Pages sœurs** : [Pipeline de requête](../../../../../docs/architecture/pipeline-requete.md) · [Firewall (l'identité qui scope la clé)](../../security/docs/firewall.md)
747
765
  - 🗄️ **Stores distribués** : [@nodefony/redis](../../redis/docs/index.md) · [@nodefony/drizzle](../../drizzle/docs/index.md)
748
766
  - 📖 **Contrat de pagination** partagé par tous les stores : `IPage` / `IPageQuery`
749
- (`src/nodefony/src/types/IPage.ts:18`)
767
+ (`src/nodefony/src/types/IPage.ts#IPageQuery`)
750
768
  - 🧠 **Contexte de requête** (ALS : identité, clé WS, corps) → [request-context](../../../../nodefony/docs/request-context.md)