@nodefony/framework 10.0.0-beta.2 → 10.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +15 -20
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controller/AdminApiController.js +45 -11
- package/dist/nodefony/controller/ApiKeyController.js +18 -10
- package/dist/nodefony/controller/BenchController.js +1 -2
- package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
- package/dist/nodefony/controller/LoginPageController.js +117 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -17
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
- package/dist/nodefony/controller/SessionAuthController.js +50 -13
- package/dist/nodefony/controller/TokenAuthController.js +14 -8
- package/dist/nodefony/controller/TotpController.js +7 -8
- package/dist/nodefony/controller/WebAuthnController.js +28 -12
- package/dist/nodefony/controller/oauthAuthority.js +1 -2
- package/dist/nodefony/decorators/routerDecorators.js +268 -65
- package/dist/nodefony/service/AdminBroker.js +4 -6
- package/dist/nodefony/service/Eta.js +1 -2
- package/dist/nodefony/service/IdempotencyStore.js +4 -6
- package/dist/nodefony/service/router.js +97 -34
- package/dist/nodefony/src/Controller.js +71 -43
- package/dist/nodefony/src/FrameworkAdminApi.js +33 -11
- package/dist/nodefony/src/KernelAdminApi.js +82 -18
- package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
- package/dist/nodefony/src/Resolver.js +105 -36
- package/dist/nodefony/src/ResourceController.js +2 -3
- package/dist/nodefony/src/Route.js +54 -8
- package/dist/nodefony/src/SecurityApiController.js +23 -0
- package/dist/nodefony/src/SyslogAdminApi.js +1 -2
- package/dist/nodefony/src/Template.js +1 -2
- package/dist/nodefony/src/configMutation.js +1 -2
- package/dist/nodefony/src/docsReader.js +39 -23
- package/dist/nodefony/src/idempotency.js +14 -22
- package/dist/nodefony/src/idempotencyGc.js +1 -2
- package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
- package/dist/nodefony/src/loginPage.js +430 -0
- package/dist/nodefony/src/requireAuthentication.js +35 -0
- package/dist/nodefony/src/routeSchema.js +81 -0
- package/dist/nodefony/src/scopeCatalog.js +1 -2
- package/dist/nodefony/src/singletonGuard.js +1 -2
- package/dist/nodefony/src/syslogFilters.js +1 -2
- package/dist/types/index.d.ts +8 -12
- package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
- package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +46 -8
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
- package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +221 -35
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
- package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
- package/dist/types/nodefony/service/router.d.ts +40 -1
- package/dist/types/nodefony/src/Controller.d.ts +54 -17
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
- package/dist/types/nodefony/src/Resolver.d.ts +44 -1
- package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
- package/dist/types/nodefony/src/Route.d.ts +21 -1
- package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
- package/dist/types/nodefony/src/docsReader.d.ts +10 -6
- package/dist/types/nodefony/src/idempotency.d.ts +12 -10
- package/dist/types/nodefony/src/loginPage.d.ts +144 -0
- package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
- package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
- package/docs/admin.md +83 -78
- package/docs/controller.md +112 -111
- package/docs/decorateurs.md +212 -163
- package/docs/idempotence.md +137 -135
- package/docs/routing.md +96 -95
- package/docs/templates.md +55 -55
- package/package.json +10 -13
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/idempotence.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
107
|
-
`IdempotencyStore.ts
|
|
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
|
|
118
|
-
connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `idempotency.ts
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
267
|
+
`isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts#Resolver.action`).
|
|
268
268
|
|
|
269
|
-
Le pont utilise `executeActionGuarded()` (`Resolver.ts
|
|
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,20 +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
|
|
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
|
|
307
|
-
3. **Empreinte du payload.** `computeFingerprint()` (`idempotency.ts
|
|
308
|
-
`[nom de route, params de route, corps]` (`Resolver.ts
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
310
|
+
4. **Identité.** `resolveIdentity()` (`idempotency.ts#resolveIdentity`) rend l'`identifier` de l'utilisateur
|
|
311
|
+
AUTHENTIFIÉ de la requête (`RequestContext.getAuthenticatedUser()`, la règle unique du cœur).
|
|
312
|
+
L'anonyme d'une zone `anonymous` n'a pas d'identité : tous les visiteurs anonymes partagent le
|
|
313
|
+
même `identifier`, ils partageraient donc le même cache. **`null` = pas de cache** : le verdict
|
|
314
|
+
devient `execute` — jamais de partage cross-identité.
|
|
313
315
|
5. **Réservation.** `store.begin()` compose la clé scopée et tranche.
|
|
314
316
|
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
|
|
317
|
+
courant et `body` la **valeur retournée** par l'action (`Resolver.ts#Resolver._callWithIdempotency`). En erreur,
|
|
316
318
|
`abort(clé)` libère la clé : **un échec ne se mémorise pas**, il doit rester réessayable.
|
|
317
319
|
|
|
318
320
|
> [!CAUTION]
|
|
@@ -321,48 +323,48 @@ sequenceDiagram
|
|
|
321
323
|
> double-effet reste évité, mais le corps rejoué est vide. Pire, si le corps retourné n'est pas
|
|
322
324
|
> sérialisable (retour d'un objet Response circulaire), un store SQL/Redis lève au `stringify`. Le
|
|
323
325
|
> Resolver attrape ce cas : il **journalise un WARNING explicite** puis mémorise le statut avec un
|
|
324
|
-
> corps `null` (`Resolver.ts
|
|
326
|
+
> corps `null` (`Resolver.ts#Resolver._acquireAndInvoke`) — la dédup est préservée plutôt que perdue en silence.
|
|
325
327
|
|
|
326
328
|
### Où la porte est câblée
|
|
327
329
|
|
|
328
|
-
| Appelant | Point d'entrée
|
|
329
|
-
| ---------------------------- |
|
|
330
|
-
| Controller userland HTTP | `callController()` (`Resolver.ts
|
|
331
|
-
| Controller userland via WS | `executeActionGuarded()` (`Resolver.ts
|
|
332
|
-
| Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts
|
|
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}` |
|
|
333
335
|
|
|
334
336
|
## ⚙️ Configuration
|
|
335
337
|
|
|
336
338
|
Source unique = schéma Zod `idempotencySchema`
|
|
337
|
-
(`src/packages/@nodefony/framework/nodefony/config/config.ts
|
|
339
|
+
(`src/packages/@nodefony/framework/nodefony/config/config.ts#idempotencySchema`).
|
|
338
340
|
|
|
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
|
|
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
|
|
343
|
-
| `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts
|
|
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`). |
|
|
344
346
|
|
|
345
347
|
### Comment `store: "auto"` se résout VRAIMENT
|
|
346
348
|
|
|
347
|
-
`Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts
|
|
348
|
-
`resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts
|
|
349
|
-
**réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts
|
|
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
|
|
350
352
|
réel est le suivant :
|
|
351
353
|
|
|
352
|
-
| Ordre | Condition | Résolution | Ancre
|
|
353
|
-
| ----- | ------------------------------------------------------------------------ | -------------------------------------- |
|
|
354
|
-
| 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts
|
|
355
|
-
| 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts
|
|
356
|
-
| 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts
|
|
357
|
-
| 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts
|
|
358
|
-
| 5 | **Aucune infra déclarée** mais un backend local persistant est chargé | `drizzle`, puis `mongoose` | `infra.ts
|
|
359
|
-
| 6 | Rien de tout ça | `memory` (volatil) | `infra.ts
|
|
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` |
|
|
360
362
|
|
|
361
363
|
> [!NOTE]
|
|
362
364
|
> L'étape **5** surprend souvent : dans une app dev qui charge `@nodefony/drizzle` **sans** déclarer
|
|
363
365
|
> `NF_DATABASE_URL`, `auto` ne résout **pas** vers `memory` mais vers `drizzle` (SQLite local) — pour
|
|
364
366
|
> que les données survivent au redémarrage. La résolution effective est toujours **journalisée au
|
|
365
|
-
> boot** (`index.ts
|
|
367
|
+
> boot** (`index.ts#Framework.onKernelRegister`) : lis cette ligne plutôt que de la déduire.
|
|
366
368
|
|
|
367
369
|
MongoDB mérite une mention : `mongoose` porte lui aussi un store d'idempotence. Déclarer
|
|
368
370
|
`NF_DATABASE_URL=mongodb://…` résout donc en étape **3** → `mongoose`, et la dédup cross-pod tient
|
|
@@ -374,20 +376,20 @@ le perdant reçoit un `E11000` : l'équivalent Mongo du `SET … NX PX` de Redis
|
|
|
374
376
|
|
|
375
377
|
Quand un store **explicitement nommé** ne peut pas s'initialiser (nom inconnu, `redis` demandé sans
|
|
376
378
|
le module `@nodefony/redis` chargé), la politique dépend de l'environnement
|
|
377
|
-
(`index.ts
|
|
379
|
+
(`index.ts`) :
|
|
378
380
|
|
|
379
|
-
- **production** → **boot avorté** (`index.ts
|
|
381
|
+
- **production** → **boot avorté** (`index.ts#Framework.onKernelBoot`). En cluster multi-pod, dégrader vers un cache
|
|
380
382
|
per-pod produirait du double-effet non dédupliqué : c'est un défaut de sécurité, pas une commodité.
|
|
381
383
|
- **dev / test** (mono-pod) → **WARNING fort + repli sur le cache mémoire** déjà en place. La dédup
|
|
382
384
|
per-pod suffit hors cluster, et on ne casse pas le routeur pour une option d'infra absente en local.
|
|
383
385
|
|
|
384
386
|
Un garde-fou supplémentaire : `store: "memory"` **en production** émet un WARNING dédié
|
|
385
|
-
(`index.ts
|
|
387
|
+
(`index.ts#Framework.onKernelRegister`) — la dédup n'y est que per-pod.
|
|
386
388
|
|
|
387
389
|
## 🧩 Les stores d'idempotence
|
|
388
390
|
|
|
389
391
|
Tous respectent le même contrat `IIdempotencyStore`
|
|
390
|
-
(`src/nodefony/src/types/IIdempotencyStore.ts
|
|
392
|
+
(`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore`) : `begin` / `complete` / `abort` obligatoires,
|
|
391
393
|
`gc` **optionnel**, plus `listPage` et `size` pour l'introspection.
|
|
392
394
|
|
|
393
395
|
### Choisir en 5 secondes
|
|
@@ -401,20 +403,20 @@ Tous respectent le même contrat `IIdempotencyStore`
|
|
|
401
403
|
|
|
402
404
|
### `memory` — le défaut per-pod, gratuit
|
|
403
405
|
|
|
404
|
-
`MemoryIdempotencyStore` (`IdempotencyStore.ts
|
|
406
|
+
`MemoryIdempotencyStore` (`IdempotencyStore.ts#MemoryIdempotencyStore`) est enregistré d'office comme service DI
|
|
405
407
|
`idempotencyStore` par le manifeste `@services` du module framework — zéro configuration.
|
|
406
408
|
|
|
407
|
-
- **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts
|
|
409
|
+
- **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts#MemoryIdempotencyStore.begin`) lit et écrit la `Map`
|
|
408
410
|
sans point de suspension → deux `begin` concurrents ne peuvent pas se croiser.
|
|
409
|
-
- **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts
|
|
410
|
-
`DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts
|
|
411
|
-
(`IdempotencyStore.ts
|
|
412
|
-
- **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts
|
|
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,
|
|
413
415
|
aucun listener.
|
|
414
416
|
- **Purge passive** : les entrées expirées ne sont retirées qu'à l'écriture, dans `evictIfNeeded()`
|
|
415
|
-
(`IdempotencyStore.ts
|
|
417
|
+
(`IdempotencyStore.ts#MemoryIdempotencyStore.evictIfNeeded`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
|
|
416
418
|
sous le cap. Coût nul tant qu'on n'écrit pas.
|
|
417
|
-
- **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts
|
|
419
|
+
- **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts#MemoryIdempotencyStore.complete`) n'écrit **que** si la clé est
|
|
418
420
|
encore _notre_ in-flight — jamais de résurrection d'une clé déjà `abort`-ée ou évincée.
|
|
419
421
|
|
|
420
422
|
⚠️ Limite structurelle : la dédup est **affine au pod**. Un rejeu routé vers un autre pod n'est pas
|
|
@@ -422,22 +424,22 @@ dédupliqué.
|
|
|
422
424
|
|
|
423
425
|
### `redis` — le choix cluster
|
|
424
426
|
|
|
425
|
-
`RedisIdempotencyStore` (`RedisIdempotencyStore.ts
|
|
427
|
+
`RedisIdempotencyStore` (`RedisIdempotencyStore.ts#RedisIdempotencyStore`) vit dans `@nodefony/framework` (le
|
|
426
428
|
consommateur du contrat), pas dans `@nodefony/redis` : il résout le service `redis` **par nom** dans
|
|
427
429
|
le container (couplage structurel, zéro dépendance directe, zéro cycle). La fabrique est enregistrée
|
|
428
|
-
au chargement du module framework (`index.ts
|
|
430
|
+
au chargement du module framework (`index.ts`).
|
|
429
431
|
|
|
430
|
-
- **`SET key … NX PX`** = réservation atomique **côté serveur** (`RedisIdempotencyStore.ts
|
|
432
|
+
- **`SET key … NX PX`** = réservation atomique **côté serveur** (`RedisIdempotencyStore.ts#RedisIdempotencyStore.#prefix`) : le
|
|
431
433
|
`409` in-flight fonctionne vraiment entre pods. Deux requêtes concurrentes sur deux pods, un seul
|
|
432
434
|
`SET NX` gagne.
|
|
433
435
|
- **TTL natif** sur le bail _et_ sur la réponse mémorisée → `gc()` **superflu**, donc **non
|
|
434
436
|
implémenté** : rien à planifier.
|
|
435
|
-
- **Empreinte préservée à la complétion** : `complete()` (`RedisIdempotencyStore.ts
|
|
437
|
+
- **Empreinte préservée à la complétion** : `complete()` (`RedisIdempotencyStore.ts#RedisIdempotencyStore.complete`) **relit**
|
|
436
438
|
l'entrée in-flight pour reporter son empreinte dans l'entrée `done`. Sans cela, un rejeu de la clé
|
|
437
439
|
avec un autre payload **après** complétion ne serait plus détecté (le 422 serait perdu).
|
|
438
440
|
- **Course rare gérée** : si la clé expire entre le `SET NX` échoué et le `GET`, la réservation est
|
|
439
|
-
retentée **une** fois ; encore prise → `in-flight` (`RedisIdempotencyStore.ts
|
|
440
|
-
- **Namespace** : `nf:idem:<clé>` (`RedisIdempotencyStore.ts
|
|
441
|
+
retentée **une** fois ; encore prise → `in-flight` (`RedisIdempotencyStore.ts#RedisIdempotencyStore.begin`).
|
|
442
|
+
- **Namespace** : `nf:idem:<clé>` (`RedisIdempotencyStore.ts#KEY_BASE`).
|
|
441
443
|
- **Dégradation gracieuse** : connexion `main` fermée (boot / shutdown) → `begin` renvoie `fresh`,
|
|
442
444
|
`complete`/`abort` sont des no-op. La mutation s'exécute **sans dédup** plutôt que d'être bloquée.
|
|
443
445
|
|
|
@@ -448,47 +450,47 @@ au chargement du module framework (`index.ts:119`).
|
|
|
448
450
|
|
|
449
451
|
### `drizzle` — le cluster sans Redis
|
|
450
452
|
|
|
451
|
-
`DrizzleIdempotencyStore` (`DrizzleIdempotencyStore.ts
|
|
453
|
+
`DrizzleIdempotencyStore` (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore`). Motivation : un cluster qui possède
|
|
452
454
|
déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra**.
|
|
453
455
|
|
|
454
456
|
- **Réservation atomique en UNE instruction** — un `INSERT` avec
|
|
455
|
-
`onConflictDoUpdate` (`DrizzleIdempotencyStore.ts
|
|
456
|
-
l'entrée est morte (`DrizzleIdempotencyStore.ts
|
|
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
|
|
457
459
|
l'INSERT a passé (clé neuve) ou si le `DO UPDATE` a **volé** une entrée expirée → `fresh`. Zéro
|
|
458
460
|
ligne = contention → on lit l'état réel.
|
|
459
461
|
- **Invariant capital** : le store ne renvoie **jamais** `fresh` hors réservation atomique gagnée.
|
|
460
462
|
Même la course rare « la clé a expiré entre l'upsert et le SELECT » renvoie prudemment `in-flight`
|
|
461
|
-
(`DrizzleIdempotencyStore.ts
|
|
463
|
+
(`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.begin`), jamais `fresh`.
|
|
462
464
|
- **MySQL/MariaDB** : ni `RETURNING`, ni `WHERE` sur l'`ON DUPLICATE KEY UPDATE`, et un `affectedRows`
|
|
463
465
|
ambigu → la réservation passe par `reserveIdempotencyKeyMysql()`
|
|
464
|
-
(`DrizzleIdempotencyStore.ts
|
|
465
|
-
- **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts
|
|
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`.
|
|
466
468
|
C'est le **seul** store qui expose `gc`, donc le seul que le framework planifie (voir plus bas).
|
|
467
|
-
- **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts
|
|
468
|
-
(`DrizzleIdempotencyStore.ts
|
|
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
|
|
469
471
|
déjà mémorisée, jamais de résurrection d'une clé libérée. `complete` ne touche pas `fingerprint`.
|
|
470
472
|
- **Résolution lazy + dégradation gracieuse** : le handle Drizzle est résolu à **chaque** appel
|
|
471
|
-
(`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts
|
|
473
|
+
(`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.from`). ORM non connecté → `begin`
|
|
472
474
|
renvoie `fresh` (sans dédup), le reste est no-op.
|
|
473
475
|
|
|
474
476
|
Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
|
|
475
|
-
(`registerStores.ts
|
|
477
|
+
(`registerStores.ts#registerDrizzleFrameworkStores`). Activation = `store: "drizzle"` (ou `NF_STORE=drizzle`, qui force toutes les briques `auto`), rien
|
|
476
478
|
d'autre à écrire.
|
|
477
479
|
|
|
478
480
|
### Le GC — armé pour un seul store
|
|
479
481
|
|
|
480
|
-
`scheduleIdempotencyGc()` (`idempotencyGc.ts
|
|
481
|
-
expose `gc()`** (`idempotencyGc.ts
|
|
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`)
|
|
482
484
|
ne l'expose pas ; le brancher sur un timer serait un no-op coûteux. Le scheduler est armé au boot
|
|
483
|
-
(`nodefony/framework/index.ts
|
|
485
|
+
(`nodefony/framework/index.ts#Framework.onKernelBoot`) et arrêté à `onTerminate`.
|
|
484
486
|
|
|
485
487
|
`gcIntervalS: 0` désarme le timer — à réserver au cas où la purge est déléguée (cron, `CronJob` k8s).
|
|
486
488
|
|
|
487
489
|
## 🗄️ Entité de persistance (store SQL)
|
|
488
490
|
|
|
489
491
|
Table `idempotency_key`, décrite par une **spec colKit** unique
|
|
490
|
-
(`idempotencyEntity.ts
|
|
491
|
-
(`idempotencyEntity.ts
|
|
492
|
+
(`idempotencyEntity.ts#IDEMPOTENCY_ENTITY_NAME`) déclinée par dialecte via `createIdempotencyTable(dialect)`
|
|
493
|
+
(`idempotencyEntity.ts#createIdempotencyTable`).
|
|
492
494
|
|
|
493
495
|
| Colonne | Type logique | SQLite | PostgreSQL | MySQL / MariaDB | Rôle |
|
|
494
496
|
| ------------- | ------------ | ------------------ | ---------- | --------------- | ------------------------------------------------------------------------------------- |
|
|
@@ -502,15 +504,15 @@ Deux détails qui expliquent des surprises réelles :
|
|
|
502
504
|
|
|
503
505
|
- **`varchar(512)` en MySQL** n'est pas un caprice : un `TEXT` InnoDB n'est pas indexable sans
|
|
504
506
|
préfixe, et 512 caractères couvrent `JSON.stringify([identité, clé ≤ 255])`.
|
|
505
|
-
- **Aucun `DEFAULT` SQL** : le DDL dérivé n'en émet pas (`idempotencyEntity.ts
|
|
507
|
+
- **Aucun `DEFAULT` SQL** : le DDL dérivé n'en émet pas (`idempotencyEntity.ts#idempotencyKeySchema`). Toutes les
|
|
506
508
|
colonnes sont posées explicitement par le store — jamais d'INSERT cassé par un défaut manquant.
|
|
507
509
|
|
|
508
|
-
`registerIdempotencyEntities(connector, dialect)` (`idempotencyEntity.ts
|
|
510
|
+
`registerIdempotencyEntities(connector, dialect)` (`idempotencyEntity.ts#registerIdempotencyEntities`) doit être appelé
|
|
509
511
|
**avant** `orm.connect()` (la table est créée au connect) — le module drizzle s'en charge tout seul.
|
|
510
512
|
|
|
511
513
|
> [!NOTE]
|
|
512
514
|
> **SQLite = banc de test de la sémantique.** Un fichier SQLite est mono-machine (verrou d'écriture)
|
|
513
|
-
> → aucun intérêt multi-pod (`idempotencyEntity.ts
|
|
515
|
+
> → aucun intérêt multi-pod (`idempotencyEntity.ts#idempotencyKeySchema`). La cible réelle est PostgreSQL ou
|
|
514
516
|
> MySQL/MariaDB, où l'atomicité de l'instruction tient sous concurrence inter-pods.
|
|
515
517
|
|
|
516
518
|
## 🗃️ Dialectes et bases pris en charge
|
|
@@ -538,24 +540,24 @@ Signatures complètes : `.ai/symbols.json`. Ce qui compte à l'usage :
|
|
|
538
540
|
|
|
539
541
|
### Le décorateur
|
|
540
542
|
|
|
541
|
-
`@Idempotent(options?)` (`routerDecorators.ts
|
|
542
|
-
métadonnées (`IdempotentMeta`, `routerDecorators.ts
|
|
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
|
|
543
545
|
cycle. La porte est appliquée par le Resolver.
|
|
544
546
|
|
|
545
547
|
### Le contrat de store
|
|
546
548
|
|
|
547
|
-
| Membre | Obligatoire | Rôle
|
|
548
|
-
| ------------------------- | :---------: |
|
|
549
|
-
| `begin(key, fingerprint)` | ✅ | Réserve atomiquement, rend le verdict (`src/nodefony/src/types/IIdempotencyStore.ts
|
|
550
|
-
| `complete(key, response)` | ✅ | Mémorise la réponse d'une clé in-flight → rejeux futurs = `replayed`.
|
|
551
|
-
| `abort(key)` | ✅ | Libère une clé in-flight dont l'exécution a échoué. Rien n'est mémorisé.
|
|
552
|
-
| `gc(now?)` | ❌ | Purge des expirées — **uniquement** sans expiration native (`src/nodefony/src/types/IIdempotencyStore.ts
|
|
553
|
-
| `listPage(query)` | ✅ | Page de clés vivantes, pour l'introspection admin (`src/nodefony/src/types/IIdempotencyStore.ts
|
|
554
|
-
| `size` | ✅ | Nombre d'entrées vivantes, **sync best-effort** (`src/nodefony/src/types/IIdempotencyStore.ts
|
|
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`). |
|
|
555
557
|
|
|
556
|
-
Les helpers du seam sont exportés et réutilisables : `isMutationMethod()` (`idempotency.ts
|
|
557
|
-
`resolveIdempotencyKey()` (`idempotency.ts
|
|
558
|
-
`computeFingerprint()` (`idempotency.ts
|
|
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`).
|
|
559
561
|
|
|
560
562
|
### `listPage` — capacités RÉELLES par store
|
|
561
563
|
|
|
@@ -575,18 +577,18 @@ que le code fait, store par store — à lire avant d'écrire un client :
|
|
|
575
577
|
| `state` | ✅ | ✅ | ✅ (filtre après lecture) |
|
|
576
578
|
| Page pleine à `limit` | ✅ | ✅ | ❌ peut être plus courte |
|
|
577
579
|
| Exclusion des expirées | ✅ à la lecture | ✅ `expiresAt > now` | ✅ par TTL natif |
|
|
578
|
-
| Ancre | `IdempotencyStore.ts
|
|
580
|
+
| Ancre | `IdempotencyStore.ts#MemoryIdempotencyStore.listPage` | `DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.gc` | `RedisIdempotencyStore.ts#RedisIdempotencyStore.listPage` |
|
|
579
581
|
|
|
580
582
|
Le mode **curseur** de Redis mérite une explication, parce qu'il piège : `SCAN COUNT` **n'est pas un
|
|
581
583
|
plafond** mais un indice d'effort — Redis peut rendre plus de clés que demandé. Sans précaution, la
|
|
582
|
-
page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts
|
|
584
|
+
page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts#IPage`). D'où
|
|
583
585
|
le **curseur composite** `"<consommé>:<curseurRedis>"` (`encodeCursor()`,
|
|
584
|
-
`RedisIdempotencyStore.ts
|
|
586
|
+
`RedisIdempotencyStore.ts#encodeCursor`) : on ne rend que `limit` éléments et on mémorise combien de clés du
|
|
585
587
|
lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et reprend là.
|
|
586
588
|
|
|
587
589
|
> [!IMPORTANT]
|
|
588
590
|
> **Une clé rendue par `listPage` ne contient JAMAIS la réponse mémorisée ni l'empreinte.**
|
|
589
|
-
> `IIdempotencyKeyEntry` (`src/nodefony/src/types/IIdempotencyStore.ts
|
|
591
|
+
> `IIdempotencyKeyEntry` (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyKeyEntry`) n'expose que `key`,
|
|
590
592
|
> `state`, `expiresAtMs` et `hasResponse` (un booléen). Le corps mémorisé est la donnée métier d'un
|
|
591
593
|
> utilisateur : le laisser sortir par ce chemin recréerait exactement l'IDOR sur le cache que le
|
|
592
594
|
> scope de clé interdit.
|
|
@@ -626,35 +628,35 @@ Trois règles héritées du code existant, à respecter sous peine de double-eff
|
|
|
626
628
|
3. **`complete` doit préserver l'empreinte** de l'entrée in-flight, sinon un rejeu avec un autre
|
|
627
629
|
payload après complétion ne produit plus de 422.
|
|
628
630
|
|
|
629
|
-
Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts
|
|
630
|
-
`getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts
|
|
631
|
-
(distribués seuls, `idempotencyStoreRegistry.ts
|
|
632
|
-
pour l'affichage Studio, `idempotencyStoreRegistry.ts
|
|
631
|
+
Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts#registerIdempotencyStore`),
|
|
632
|
+
`getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts#getIdempotencyStoreFactory`), `listIdempotencyStores()`
|
|
633
|
+
(distribués seuls, `idempotencyStoreRegistry.ts#listIdempotencyStores`), `listIdempotencyBackends()` (avec `memory`,
|
|
634
|
+
pour l'affichage Studio, `idempotencyStoreRegistry.ts#listIdempotencyBackends`).
|
|
633
635
|
|
|
634
636
|
## 📜 Normes appliquées
|
|
635
637
|
|
|
636
638
|
<!-- prettier-ignore -->
|
|
637
639
|
| Sujet | Norme | Ancrage |
|
|
638
640
|
| --- | --- | --- |
|
|
639
|
-
| En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-06` | `evaluateIdempotency()` (`idempotency.ts
|
|
640
|
-
| Clé réutilisée avec un autre payload → 422 | draft §2.2 / §2.7 | `idempotency.ts
|
|
641
|
-
| Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts
|
|
642
|
-
| Clé requise absente → 400 | draft §2.7 | `idempotency.ts
|
|
643
|
-
| Méthodes non sûres = mutations | RFC 9110 §9.2.1 | `MUTATION_METHODS` (`idempotency.ts
|
|
644
|
-
| Sémantique du 422 | RFC 9110 §15.5.21 | `IdempotencyVerdict` (`idempotency.ts
|
|
645
|
-
| Borne de clé (convention Stripe) | 255 octets | `IDEMPOTENCY_KEY_MAX` (`idempotency.ts
|
|
641
|
+
| En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-06` | `evaluateIdempotency()` (`idempotency.ts#evaluateIdempotency`) |
|
|
642
|
+
| Clé réutilisée avec un autre payload → 422 | draft §2.2 / §2.7 | `idempotency.ts#evaluateIdempotency` |
|
|
643
|
+
| Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts#evaluateIdempotency` |
|
|
644
|
+
| Clé requise absente → 400 | draft §2.7 | `idempotency.ts#evaluateIdempotency` |
|
|
645
|
+
| Méthodes non sûres = mutations | RFC 9110 §9.2.1 | `MUTATION_METHODS` (`idempotency.ts#MUTATION_METHODS`) |
|
|
646
|
+
| Sémantique du 422 | RFC 9110 §15.5.21 | `IdempotencyVerdict` (`idempotency.ts#IdempotencyVerdict`) |
|
|
647
|
+
| Borne de clé (convention Stripe) | 255 octets | `IDEMPOTENCY_KEY_MAX` (`idempotency.ts#IDEMPOTENCY_KEY_MAX`) |
|
|
646
648
|
|
|
647
649
|
## ⚡ Performance et mémoire
|
|
648
650
|
|
|
649
651
|
Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
|
|
650
|
-
`null` (`routerDecorators.ts
|
|
651
|
-
normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts
|
|
652
|
+
`null` (`routerDecorators.ts#Idempotent`) : `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#Resolver.callController`).
|
|
652
654
|
La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
|
|
653
655
|
|
|
654
656
|
Sur le chemin décoré :
|
|
655
657
|
|
|
656
658
|
- Le store mémoire n'alloue sa `Map` qu'au 1ᵉʳ `begin`, ne pose **aucun timer ni listener**, et purge
|
|
657
|
-
en passif (`IdempotencyStore.ts
|
|
659
|
+
en passif (`IdempotencyStore.ts#MemoryIdempotencyStore.begin`).
|
|
658
660
|
- L'empreinte est un hash SHA-256 court → comparaison O(1), et le payload n'est jamais conservé en
|
|
659
661
|
clair.
|
|
660
662
|
- Le GC est **hors hot-path** et armé pour un seul store (SQL) ; le jitter évite que N pods purgent
|
|
@@ -671,15 +673,15 @@ froid par construction ; si ton profil de trafic la place sur un chemin chaud, m
|
|
|
671
673
|
Trois surfaces existent aujourd'hui :
|
|
672
674
|
|
|
673
675
|
- **Playground** (`/nodefony/playground`) — chaque mutation protégée porte un badge `@Idempotent` (strict ou souple)
|
|
674
|
-
(`playground/PlaygroundFormat.tsx
|
|
675
|
-
même clé » dès que `action.guards.idempotent` est posé (`playground/ActionPanel.tsx
|
|
676
|
+
(`playground/PlaygroundFormat.tsx#GuardBadges`). 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#ActionPanel`) : c'est la
|
|
676
678
|
façon la plus rapide de voir un rejeu, un `409` ou un `422` en vrai.
|
|
677
679
|
- **Stores** — la brique `idempotency` y apparaît avec sa nature **éphémère**, le backend configuré,
|
|
678
680
|
le backend résolu, la liste des backends disponibles et la raison de la résolution — brique
|
|
679
|
-
`idempotency` (`stores/storesModel.ts
|
|
681
|
+
`idempotency` (`stores/storesModel.ts#BRICK_PURPOSE`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
|
|
680
682
|
croyait.
|
|
681
683
|
- **ERD** — la table `idempotency_key` est regroupée sous `@nodefony/framework`
|
|
682
|
-
(`idempotencyEntity.ts
|
|
684
|
+
(`idempotencyEntity.ts#createIdempotencyEntities`), pas sous l'ORM qui l'héberge.
|
|
683
685
|
|
|
684
686
|
> [!NOTE]
|
|
685
687
|
> Il n'existe **pas encore** d'écran ni d'endpoint admin listant les clés d'idempotence vivantes :
|
|
@@ -689,19 +691,19 @@ Trois surfaces existent aujourd'hui :
|
|
|
689
691
|
|
|
690
692
|
## ⚠️ Pièges
|
|
691
693
|
|
|
692
|
-
| Symptôme | Cause (dans le code)
|
|
693
|
-
| ------------------------------------------------------ |
|
|
694
|
-
| Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts
|
|
695
|
-
| `409` en boucle sur un endpoint | Action qui lève avant `complete`/`abort` → in-flight bloqué jusqu'au bail (60 s)
|
|
696
|
-
| `422 Idempotency-Key is already used` | Même clé, **payload différent** (empreinte ≠, `idempotency.ts
|
|
697
|
-
| Rien n'est dédupliqué **malgré** la clé | Pas d'identité fiable → verdict `execute` (`idempotency.ts
|
|
698
|
-
| Rien n'est dédupliqué, clé « un peu longue » | Clé > 255 octets → traitée comme **absente** (`idempotency.ts
|
|
699
|
-
| Dédup qui saute en cluster | `store: memory` (per-pod)
|
|
700
|
-
| `400 Idempotency-Key required` inattendu | Mode strict par défaut, **ou** requête WebSocket (toujours stricte)
|
|
701
|
-
| `auto` résout `drizzle` alors qu'on attendait `memory` | Repli local persistant quand aucune infra n'est déclarée (`infra.ts
|
|
702
|
-
| Boot qui échoue en prod sur l'idempotence | Store distribué nommé mais non câblé → fatal (`index.ts
|
|
703
|
-
| Aucune purge sur un store SQL | `intervalS` à 0 → scheduler désarmé, dit dans le log de boot (`idempotencyGc.ts
|
|
704
|
-
| `listPage` : `total` toujours absent | Backend Redis = mode **curseur**, `total` jamais rendu
|
|
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#Resolver._callWithIdempotency`) | 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#evaluateIdempotency`) | 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#evaluateIdempotency`) | 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#IDEMPOTENCY_KEY_MAX`) | 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#CONNECTED_STORES`) | 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`) | 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#IIdempotencyGcOptions.intervalS`) | 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 |
|
|
705
707
|
|
|
706
708
|
## 🧪 Tests et couverture
|
|
707
709
|
|
|
@@ -744,5 +746,5 @@ Couverture : `npm run coverage` dans `@nodefony/framework` et `@nodefony/drizzle
|
|
|
744
746
|
- 🧭 **Pages sœurs** : [Pipeline de requête](../../../../../docs/architecture/pipeline-requete.md) · [Firewall (l'identité qui scope la clé)](../../security/docs/firewall.md)
|
|
745
747
|
- 🗄️ **Stores distribués** : [@nodefony/redis](../../redis/docs/index.md) · [@nodefony/drizzle](../../drizzle/docs/index.md)
|
|
746
748
|
- 📖 **Contrat de pagination** partagé par tous les stores : `IPage` / `IPageQuery`
|
|
747
|
-
(`src/nodefony/src/types/IPage.ts
|
|
749
|
+
(`src/nodefony/src/types/IPage.ts#IPageQuery`)
|
|
748
750
|
- 🧠 **Contexte de requête** (ALS : identité, clé WS, corps) → [request-context](../../../../nodefony/docs/request-context.md)
|