@nodefony/framework 10.0.0-beta.3 → 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.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +9 -18
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controller/AdminApiController.js +38 -7
- package/dist/nodefony/controller/ApiKeyController.js +16 -8
- package/dist/nodefony/controller/BenchController.js +1 -2
- package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
- package/dist/nodefony/controller/LoginPageController.js +4 -11
- package/dist/nodefony/controller/OAuth2Controller.js +3 -4
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
- package/dist/nodefony/controller/SessionAuthController.js +13 -4
- package/dist/nodefony/controller/TokenAuthController.js +3 -4
- package/dist/nodefony/controller/TotpController.js +2 -3
- package/dist/nodefony/controller/WebAuthnController.js +2 -3
- package/dist/nodefony/controller/oauthAuthority.js +1 -2
- package/dist/nodefony/decorators/routerDecorators.js +155 -53
- 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 +49 -15
- package/dist/nodefony/src/Controller.js +22 -39
- package/dist/nodefony/src/FrameworkAdminApi.js +31 -10
- package/dist/nodefony/src/KernelAdminApi.js +7 -9
- package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
- package/dist/nodefony/src/Resolver.js +66 -34
- package/dist/nodefony/src/ResourceController.js +2 -3
- package/dist/nodefony/src/Route.js +2 -3
- package/dist/nodefony/src/SecurityApiController.js +1 -2
- 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 +1 -2
- package/dist/nodefony/src/idempotency.js +1 -2
- package/dist/nodefony/src/idempotencyGc.js +1 -2
- package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
- package/dist/nodefony/src/loginPage.js +2 -3
- package/dist/nodefony/src/requireAuthentication.js +1 -2
- 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 +5 -10
- package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +7 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +133 -32
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
- package/dist/types/nodefony/service/router.d.ts +27 -1
- package/dist/types/nodefony/src/Controller.d.ts +14 -16
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
- package/dist/types/nodefony/src/Resolver.d.ts +25 -1
- package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
- package/dist/types/nodefony/src/Route.d.ts +3 -0
- package/dist/types/nodefony/src/loginPage.d.ts +1 -1
- package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
- package/docs/admin.md +80 -80
- package/docs/controller.md +108 -109
- package/docs/decorateurs.md +208 -161
- package/docs/idempotence.md +133 -133
- package/docs/routing.md +90 -89
- package/docs/templates.md +55 -55
- package/package.json +8 -11
- package/dist/_virtual/_@oxc-project_runtime@0.153.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,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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
331
|
-
| ---------------------------- |
|
|
332
|
-
| Controller userland HTTP | `callController()` (`Resolver.ts
|
|
333
|
-
| Controller userland via WS | `executeActionGuarded()` (`Resolver.ts
|
|
334
|
-
| 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}` |
|
|
335
335
|
|
|
336
336
|
## ⚙️ Configuration
|
|
337
337
|
|
|
338
338
|
Source unique = schéma Zod `idempotencySchema`
|
|
339
|
-
(`src/packages/@nodefony/framework/nodefony/config/config.ts
|
|
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
|
|
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
|
|
345
|
-
| `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`). |
|
|
346
346
|
|
|
347
347
|
### Comment `store: "auto"` se résout VRAIMENT
|
|
348
348
|
|
|
349
|
-
`Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts
|
|
350
|
-
`resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts
|
|
351
|
-
**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
|
|
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
|
|
357
|
-
| 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts
|
|
358
|
-
| 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts
|
|
359
|
-
| 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts
|
|
360
|
-
| 5 | **Aucune infra déclarée** mais un backend local persistant est chargé | `drizzle`, puis `mongoose` | `infra.ts
|
|
361
|
-
| 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` |
|
|
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
|
|
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
|
|
379
|
+
(`index.ts`) :
|
|
380
380
|
|
|
381
|
-
- **production** → **boot avorté** (`index.ts
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
412
|
-
`DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts
|
|
413
|
-
(`IdempotencyStore.ts
|
|
414
|
-
- **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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
430
|
+
au chargement du module framework (`index.ts`).
|
|
431
431
|
|
|
432
|
-
- **`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
|
|
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
|
|
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
|
|
442
|
-
- **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`).
|
|
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
|
|
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
|
|
458
|
-
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
|
|
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
|
|
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
|
|
467
|
-
- **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`.
|
|
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
|
|
470
|
-
(`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
|
|
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
|
|
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
|
|
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
|
|
483
|
-
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`)
|
|
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
|
|
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
|
|
493
|
-
(`idempotencyEntity.ts
|
|
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
|
|
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
|
|
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
|
|
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
|
|
544
|
-
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
|
|
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
|
|
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
|
|
555
|
-
| `listPage(query)` | ✅ | Page de clés vivantes, pour l'introspection admin (`src/nodefony/src/types/IIdempotencyStore.ts
|
|
556
|
-
| `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`). |
|
|
557
557
|
|
|
558
|
-
Les helpers du seam sont exportés et réutilisables : `isMutationMethod()` (`idempotency.ts
|
|
559
|
-
`resolveIdempotencyKey()` (`idempotency.ts
|
|
560
|
-
`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`).
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -628,35 +628,35 @@ Trois règles héritées du code existant, à respecter sous peine de double-eff
|
|
|
628
628
|
3. **`complete` doit préserver l'empreinte** de l'entrée in-flight, sinon un rejeu avec un autre
|
|
629
629
|
payload après complétion ne produit plus de 422.
|
|
630
630
|
|
|
631
|
-
Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts
|
|
632
|
-
`getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts
|
|
633
|
-
(distribués seuls, `idempotencyStoreRegistry.ts
|
|
634
|
-
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`).
|
|
635
635
|
|
|
636
636
|
## 📜 Normes appliquées
|
|
637
637
|
|
|
638
638
|
<!-- prettier-ignore -->
|
|
639
639
|
| Sujet | Norme | Ancrage |
|
|
640
640
|
| --- | --- | --- |
|
|
641
|
-
| En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-06` | `evaluateIdempotency()` (`idempotency.ts
|
|
642
|
-
| Clé réutilisée avec un autre payload → 422 | draft §2.2 / §2.7 | `idempotency.ts
|
|
643
|
-
| Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts
|
|
644
|
-
| Clé requise absente → 400 | draft §2.7 | `idempotency.ts
|
|
645
|
-
| Méthodes non sûres = mutations | RFC 9110 §9.2.1 | `MUTATION_METHODS` (`idempotency.ts
|
|
646
|
-
| Sémantique du 422 | RFC 9110 §15.5.21 | `IdempotencyVerdict` (`idempotency.ts
|
|
647
|
-
| 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`) |
|
|
648
648
|
|
|
649
649
|
## ⚡ Performance et mémoire
|
|
650
650
|
|
|
651
651
|
Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
|
|
652
|
-
`null` (`routerDecorators.ts
|
|
653
|
-
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`).
|
|
654
654
|
La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
|
|
655
655
|
|
|
656
656
|
Sur le chemin décoré :
|
|
657
657
|
|
|
658
658
|
- 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
|
|
659
|
+
en passif (`IdempotencyStore.ts#MemoryIdempotencyStore.begin`).
|
|
660
660
|
- L'empreinte est un hash SHA-256 court → comparaison O(1), et le payload n'est jamais conservé en
|
|
661
661
|
clair.
|
|
662
662
|
- Le GC est **hors hot-path** et armé pour un seul store (SQL) ; le jitter évite que N pods purgent
|
|
@@ -673,15 +673,15 @@ froid par construction ; si ton profil de trafic la place sur un chemin chaud, m
|
|
|
673
673
|
Trois surfaces existent aujourd'hui :
|
|
674
674
|
|
|
675
675
|
- **Playground** (`/nodefony/playground`) — chaque mutation protégée porte un badge `@Idempotent` (strict ou souple)
|
|
676
|
-
(`playground/PlaygroundFormat.tsx
|
|
677
|
-
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
|
|
678
678
|
façon la plus rapide de voir un rejeu, un `409` ou un `422` en vrai.
|
|
679
679
|
- **Stores** — la brique `idempotency` y apparaît avec sa nature **éphémère**, le backend configuré,
|
|
680
680
|
le backend résolu, la liste des backends disponibles et la raison de la résolution — brique
|
|
681
|
-
`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
|
|
682
682
|
croyait.
|
|
683
683
|
- **ERD** — la table `idempotency_key` est regroupée sous `@nodefony/framework`
|
|
684
|
-
(`idempotencyEntity.ts
|
|
684
|
+
(`idempotencyEntity.ts#createIdempotencyEntities`), pas sous l'ORM qui l'héberge.
|
|
685
685
|
|
|
686
686
|
> [!NOTE]
|
|
687
687
|
> Il n'existe **pas encore** d'écran ni d'endpoint admin listant les clés d'idempotence vivantes :
|
|
@@ -691,19 +691,19 @@ Trois surfaces existent aujourd'hui :
|
|
|
691
691
|
|
|
692
692
|
## ⚠️ Pièges
|
|
693
693
|
|
|
694
|
-
| Symptôme | Cause (dans le code)
|
|
695
|
-
| ------------------------------------------------------ |
|
|
696
|
-
| Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts
|
|
697
|
-
| `409` en boucle sur un endpoint | Action qui lève avant `complete`/`abort` → in-flight bloqué jusqu'au bail (60 s)
|
|
698
|
-
| `422 Idempotency-Key is already used` | Même clé, **payload différent** (empreinte ≠, `idempotency.ts
|
|
699
|
-
| Rien n'est dédupliqué **malgré** la clé | Pas d'identité fiable → verdict `execute` (`idempotency.ts
|
|
700
|
-
| Rien n'est dédupliqué, clé « un peu longue » | Clé > 255 octets → traitée comme **absente** (`idempotency.ts
|
|
701
|
-
| Dédup qui saute en cluster | `store: memory` (per-pod)
|
|
702
|
-
| `400 Idempotency-Key required` inattendu | Mode strict par défaut, **ou** requête WebSocket (toujours stricte)
|
|
703
|
-
| `auto` résout `drizzle` alors qu'on attendait `memory` | Repli local persistant quand aucune infra n'est déclarée (`infra.ts
|
|
704
|
-
| Boot qui échoue en prod sur l'idempotence | Store distribué nommé mais non câblé → fatal (`index.ts
|
|
705
|
-
| Aucune purge sur un store SQL | `intervalS` à 0 → scheduler désarmé, dit dans le log de boot (`idempotencyGc.ts
|
|
706
|
-
| `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 |
|
|
707
707
|
|
|
708
708
|
## 🧪 Tests et couverture
|
|
709
709
|
|
|
@@ -746,5 +746,5 @@ Couverture : `npm run coverage` dans `@nodefony/framework` et `@nodefony/drizzle
|
|
|
746
746
|
- 🧭 **Pages sœurs** : [Pipeline de requête](../../../../../docs/architecture/pipeline-requete.md) · [Firewall (l'identité qui scope la clé)](../../security/docs/firewall.md)
|
|
747
747
|
- 🗄️ **Stores distribués** : [@nodefony/redis](../../redis/docs/index.md) · [@nodefony/drizzle](../../drizzle/docs/index.md)
|
|
748
748
|
- 📖 **Contrat de pagination** partagé par tous les stores : `IPage` / `IPageQuery`
|
|
749
|
-
(`src/nodefony/src/types/IPage.ts
|
|
749
|
+
(`src/nodefony/src/types/IPage.ts#IPageQuery`)
|
|
750
750
|
- 🧠 **Contexte de requête** (ALS : identité, clé WS, corps) → [request-context](../../../../nodefony/docs/request-context.md)
|