@nodefony/framework 10.0.0-alpha.8 → 10.0.0-beta.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 (49) hide show
  1. package/README.md +4 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +7 -6
  5. package/dist/nodefony/controller/AdminApiController.js +4 -3
  6. package/dist/nodefony/controller/ApiKeyController.js +1 -1
  7. package/dist/nodefony/controller/OAuth2Controller.js +11 -6
  8. package/dist/nodefony/controller/SessionAuthController.js +3 -3
  9. package/dist/nodefony/controller/TokenAuthController.js +2 -2
  10. package/dist/nodefony/controller/TotpController.js +5 -4
  11. package/dist/nodefony/controller/WebAuthnController.js +4 -4
  12. package/dist/nodefony/decorators/routerDecorators.js +41 -35
  13. package/dist/nodefony/service/AdminBroker.js +2 -2
  14. package/dist/nodefony/service/IdempotencyStore.js +4 -5
  15. package/dist/nodefony/service/router.js +75 -25
  16. package/dist/nodefony/src/Controller.js +184 -78
  17. package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
  18. package/dist/nodefony/src/KernelAdminApi.js +60 -55
  19. package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
  20. package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
  21. package/dist/nodefony/src/Resolver.js +219 -63
  22. package/dist/nodefony/src/Route.js +38 -38
  23. package/dist/nodefony/src/SyslogAdminApi.js +4 -4
  24. package/dist/nodefony/src/docsReader.js +22 -18
  25. package/dist/nodefony/src/scopeCatalog.js +1 -1
  26. package/dist/nodefony/src/singletonGuard.js +76 -0
  27. package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
  28. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
  29. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
  30. package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
  31. package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
  32. package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
  33. package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
  34. package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
  35. package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
  36. package/dist/types/nodefony/service/router.d.ts +33 -7
  37. package/dist/types/nodefony/src/Controller.d.ts +97 -24
  38. package/dist/types/nodefony/src/Resolver.d.ts +154 -15
  39. package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
  40. package/dist/types/nodefony/src/Route.d.ts +24 -22
  41. package/dist/types/nodefony/src/docsReader.d.ts +3 -3
  42. package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
  43. package/docs/admin.md +26 -26
  44. package/docs/controller.md +139 -121
  45. package/docs/decorateurs.md +108 -105
  46. package/docs/idempotence.md +78 -71
  47. package/docs/routing.md +57 -51
  48. package/docs/templates.md +17 -17
  49. package/package.json +12 -12
@@ -67,21 +67,21 @@ reste du pipeline ne fait que traduire son verdict.
67
67
 
68
68
  ## 📖 Lexique
69
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. |
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`, `mongoose`) 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
85
 
86
86
  ## Qu'est-ce que c'est ?
87
87
 
@@ -104,7 +104,7 @@ Sans ce ticket, trois choses cassent :
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
106
  (`IDEMPOTENCY_KEY_MAX`, `idempotency.ts:36`) et le cache mémoire est **borné** (`DEFAULT_CAP`,
107
- `IdempotencyStore.ts:18`).
107
+ `IdempotencyStore.ts:25`).
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
@@ -119,9 +119,9 @@ connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `
119
119
  que **deux** appelants traduisent dans leur monde :
120
120
 
121
121
  - le **data plane admin** — `AdminApiController.idempotencyGate()`
122
- (`AdminApiController.ts:158`) → réponse `{status, headers, body}` ;
122
+ (`AdminApiController.ts:131`) → réponse `{status, headers, body}` ;
123
123
  - les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
124
- (`Resolver.ts:460`) → `nodefonyError` typée, ou réponse rejouée.
124
+ (`Resolver.ts:537`) → `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
@@ -187,7 +187,7 @@ use("@nodefony/framework", {
187
187
  // un nom distribué non câblé fait ÉCHOUER le boot en production (fail-loud)
188
188
  // plutôt que dédupliquer per-pod en silence.
189
189
  store: "redis",
190
- // Purge des clés expirées — utile UNIQUEMENT pour un store SQL (drizzle).
190
+ // Purge des clés expirées — utile pour les stores SANS TTL natif (drizzle, mongoose).
191
191
  gcIntervalS: 600,
192
192
  },
193
193
  });
@@ -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:539` et `Resolver.ts:563`). Si tu appelles le store **à la main** (cas avancé), c'est
226
+ > `Resolver.ts:578` et `Resolver.ts:563`). 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:1561`), comme
241
+ Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:1606`), 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.
@@ -264,9 +264,9 @@ Deux mécanismes rendent ça possible côté socket :
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:473`).
267
+ `isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:126`).
268
268
 
269
- Le pont utilise `executeActionGuarded()` (`Resolver.ts:425`) : porte d'idempotence **sans** rendu HTTP
269
+ Le pont utilise `executeActionGuarded()` (`Resolver.ts:502`) : 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,7 +299,7 @@ sequenceDiagram
299
299
 
300
300
  ### Le parcours d'une mutation, étape par étape
301
301
 
302
- 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:396`) lit `meta.idempotent` sur les
302
+ 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:484`) 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
@@ -312,7 +312,7 @@ sequenceDiagram
312
312
  le verdict devient `execute` (`idempotency.ts:176`) — jamais de partage cross-identité.
313
313
  5. **Réservation.** `store.begin()` compose la clé scopée et tranche.
314
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,
315
+ courant et `body` la **valeur retournée** par l'action (`Resolver.ts:570`). En erreur,
316
316
  `abort(clé)` libère la clé : **un échec ne se mémorise pas**, il doit rester réessayable.
317
317
 
318
318
  > [!CAUTION]
@@ -327,31 +327,31 @@ sequenceDiagram
327
327
 
328
328
  | Appelant | Point d'entrée | Traduction du verdict |
329
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}` |
330
+ | Controller userland HTTP | `callController()` (`Resolver.ts:484`) | `nodefonyError` + rendu normal |
331
+ | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:502`) | valeur nue, enveloppée par le peer |
332
+ | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:131`) | `{status, headers, body}` |
333
333
 
334
334
  ## ⚙️ Configuration
335
335
 
336
336
  Source unique = schéma Zod `idempotencySchema`
337
337
  (`src/packages/@nodefony/framework/nodefony/config/config.ts:42`).
338
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`). |
339
+ | Option | Type | Défaut | Effet |
340
+ | ------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
341
+ | `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` / `mongoose` 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
344
 
345
345
  ### Comment `store: "auto"` se résout VRAIMENT
346
346
 
347
- `Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:189`) délègue à
347
+ `Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:230`) délègue à
348
348
  `resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts:289`), borné aux backends
349
349
  **réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts:81`). L'ordre
350
350
  réel est le suivant :
351
351
 
352
352
  | Ordre | Condition | Résolution | Ancre |
353
353
  | ----- | ------------------------------------------------------------------------ | -------------------------------------- | -------------- |
354
- | 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:251` |
354
+ | 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:343` |
355
355
  | 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts:258` |
356
356
  | 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts:261` |
357
357
  | 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts:274` |
@@ -364,11 +364,11 @@ réel est le suivant :
364
364
  > que les données survivent au redémarrage. La résolution effective est toujours **journalisée au
365
365
  > boot** (`index.ts:207`) : lis cette ligne plutôt que de la déduire.
366
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 »), la dédup cross-pod passe par `redis`.
367
+ MongoDB mérite une mention : `mongoose` porte lui aussi un store d'idempotence. Déclarer
368
+ `NF_DATABASE_URL=mongodb://…` résout donc en étape **3** → `mongoose`, et la dédup cross-pod tient
369
+ sans qu'il faille ajouter Redis à l'infrastructure. Sa réservation atomique repose sur la
370
+ contrainte d'unicité de `_id` — un `findOneAndUpdate` filtré sur l'entrée morte, en `upsert`, dont
371
+ le perdant reçoit un `E11000` : l'équivalent Mongo du `SET … NX PX` de Redis.
372
372
 
373
373
  ### Le contrat de dégradation : fail-loud, jamais silencieux
374
374
 
@@ -392,28 +392,29 @@ Tous respectent le même contrat `IIdempotencyStore`
392
392
 
393
393
  ### Choisir en 5 secondes
394
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 |
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
+ | `mongoose` | `findOneAndUpdate(expiré) upsert` → `E11000` | applicative | ✅ | ✅ | cluster qui a déjà Mongo, pas de Redis |
400
401
 
401
402
  ### `memory` — le défaut per-pod, gratuit
402
403
 
403
404
  `MemoryIdempotencyStore` (`IdempotencyStore.ts:44`) est enregistré d'office comme service DI
404
405
  `idempotencyStore` par le manifeste `@services` du module framework — zéro configuration.
405
406
 
406
- - **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:107`) lit et écrit la `Map`
407
+ - **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:115`) lit et écrit la `Map`
407
408
  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,
409
+ - **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts:21`), bail
410
+ `DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts:23`), plafond `DEFAULT_CAP` = 1000 entrées
411
+ (`IdempotencyStore.ts:25`). Elles ne sont **pas** configurables.
412
+ - **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts:115`) ; aucun timer,
412
413
  aucun listener.
413
414
  - **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
+ (`IdempotencyStore.ts:172`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
415
416
  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
+ - **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts:142`) n'écrit **que** si la clé est
417
418
  encore _notre_ in-flight — jamais de résurrection d'une clé déjà `abort`-ée ou évincée.
418
419
 
419
420
  ⚠️ Limite structurelle : la dédup est **affine au pod**. Un rejeu routé vers un autre pod n'est pas
@@ -452,7 +453,7 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
452
453
 
453
454
  - **Réservation atomique en UNE instruction** — un `INSERT` avec
454
455
  `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'entrée est morte (`DrizzleIdempotencyStore.ts:269`). Le `returning` ne rend une ligne que si
456
457
  l'INSERT a passé (clé neuve) ou si le `DO UPDATE` a **volé** une entrée expirée → `fresh`. Zéro
457
458
  ligne = contention → on lit l'état réel.
458
459
  - **Invariant capital** : le store ne renvoie **jamais** `fresh` hors réservation atomique gagnée.
@@ -460,14 +461,14 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
460
461
  (`DrizzleIdempotencyStore.ts:264`), jamais `fresh`.
461
462
  - **MySQL/MariaDB** : ni `RETURNING`, ni `WHERE` sur l'`ON DUPLICATE KEY UPDATE`, et un `affectedRows`
462
463
  ambigu → la réservation passe par `reserveIdempotencyKeyMysql()`
463
- (`DrizzleIdempotencyStore.ts:213`), qui la reconstruit en deux instructions chacune atomique.
464
+ (`DrizzleIdempotencyStore.ts:233`), qui la reconstruit en deux instructions chacune atomique.
464
465
  - **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts:318`) = `DELETE WHERE expiresAt <= now`.
465
466
  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
467
+ - **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:301`) et `abort()`
468
+ (`DrizzleIdempotencyStore.ts:319`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
468
469
  déjà mémorisée, jamais de résurrection d'une clé libérée. `complete` ne touche pas `fingerprint`.
469
470
  - **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
+ (`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:191`). ORM non connecté → `begin`
471
472
  renvoie `fresh` (sans dédup), le reste est no-op.
472
473
 
473
474
  Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
@@ -522,10 +523,14 @@ Deux détails qui expliquent des surprises réelles :
522
523
  | `drizzle` (SQL) | SQLite | idem, mais mono-machine → **test** | ❌ | ✅ |
523
524
  | `memory` | RAM du pod | mono-thread JS ; cap FIFO 1000 ; TTL 10 min | ❌ | n/a |
524
525
 
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`.
526
+ > [!NOTE]
527
+ > **MongoDB (`@nodefony/mongoose`) porte un store d'idempotence.** `store: "mongoose"` est
528
+ > sélectionnable dès que le module est chargé, et `auto` avec une infra Mongo y résout tout seul.
529
+ > En cluster Mongo, la dédup cross-pod n'exige donc pas d'ajouter Redis.
530
+ >
531
+ > Comme la variante SQL, il n'a **pas de TTL natif** : le framework arme un `GcScheduler` parce
532
+ > que le store expose `gc()`. L'échéance d'une entrée bascule du bail à la rétention, ce qu'un
533
+ > index TTL ne saurait pas suivre.
529
534
 
530
535
  ## 🧰 API publique
531
536
 
@@ -533,8 +538,8 @@ Signatures complètes : `.ai/symbols.json`. Ce qui compte à l'usage :
533
538
 
534
539
  ### Le décorateur
535
540
 
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
541
+ `@Idempotent(options?)` (`routerDecorators.ts:1171`) — dual **classe + méthode**. N'écrit que des
542
+ métadonnées (`IdempotentMeta`, `routerDecorators.ts:477`), zéro import de `@nodefony/security`, zéro
538
543
  cycle. La porte est appliquée par le Resolver.
539
544
 
540
545
  ### Le contrat de store
@@ -576,7 +581,7 @@ Le mode **curseur** de Redis mérite une explication, parce qu'il piège : `SCAN
576
581
  plafond** mais un indice d'effort — Redis peut rendre plus de clés que demandé. Sans précaution, la
577
582
  page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts:108`). D'où
578
583
  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
584
+ `RedisIdempotencyStore.ts:63`) : on ne rend que `limit` éléments et on mémorise combien de clés du
580
585
  lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et reprend là.
581
586
 
582
587
  > [!IMPORTANT]
@@ -588,8 +593,10 @@ lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et repre
588
593
 
589
594
  Deux réserves à connaître :
590
595
 
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**.
596
+ - Le champ `tenantId` d'`IPageQuery` est un **emplacement réservé** : il appartient au contrat de
597
+ pagination commun à tous les stores listables, et servira à isoler les données par organisation
598
+ quand le framework saura servir plusieurs organisations. Ici, le passer n'a **aucun effet de
599
+ filtrage** : ce store l'ignore.
593
600
  - `size` est une **approximation per-pod** pour les stores distribués (compteur local incrémenté au
594
601
  `fresh`, décrémenté au `complete`/`abort`), désalignée cross-pod et non décrémentée si un bail
595
602
  expire sans complétion. La vérité cluster passe par la base ou `redis-cli`, jamais par ce getter.
@@ -640,14 +647,14 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
640
647
  ## ⚡ Performance et mémoire
641
648
 
642
649
  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`).
650
+ `null` (`routerDecorators.ts:1151`) : `callController()` fait **une comparaison** et repart en flux
651
+ normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:473`).
645
652
  La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
646
653
 
647
654
  Sur le chemin décoré :
648
655
 
649
656
  - 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`).
657
+ en passif (`IdempotencyStore.ts:115`).
651
658
  - L'empreinte est un hash SHA-256 court → comparaison O(1), et le payload n'est jamais conservé en
652
659
  clair.
653
660
  - Le GC est **hors hot-path** et armé pour un seul store (SQL) ; le jitter évite que N pods purgent
@@ -669,7 +676,7 @@ Trois surfaces existent aujourd'hui :
669
676
  façon la plus rapide de voir un rejeu, un `409` ou un `422` en vrai.
670
677
  - **Stores** — la brique `idempotency` y apparaît avec sa nature **éphémère**, le backend configuré,
671
678
  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
679
+ `idempotency` (`stores/storesModel.ts:145`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
673
680
  croyait.
674
681
  - **ERD** — la table `idempotency_key` est regroupée sous `@nodefony/framework`
675
682
  (`idempotencyEntity.ts:133`), pas sous l'ORM qui l'héberge.
@@ -684,7 +691,7 @@ Trois surfaces existent aujourd'hui :
684
691
 
685
692
  | Symptôme | Cause (dans le code) | Correction |
686
693
  | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
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 |
694
+ | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:628`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
688
695
  | `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
696
  | `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
697
  | 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 |