@nodefony/framework 10.0.0-alpha.8 → 10.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +7 -6
- package/dist/nodefony/controller/AdminApiController.js +4 -3
- package/dist/nodefony/controller/ApiKeyController.js +1 -1
- package/dist/nodefony/controller/OAuth2Controller.js +11 -6
- package/dist/nodefony/controller/SessionAuthController.js +3 -3
- package/dist/nodefony/controller/TokenAuthController.js +2 -2
- package/dist/nodefony/controller/TotpController.js +5 -4
- package/dist/nodefony/controller/WebAuthnController.js +4 -4
- package/dist/nodefony/decorators/routerDecorators.js +41 -35
- package/dist/nodefony/service/AdminBroker.js +2 -2
- package/dist/nodefony/service/IdempotencyStore.js +4 -5
- package/dist/nodefony/service/router.js +75 -25
- package/dist/nodefony/src/Controller.js +184 -78
- package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
- package/dist/nodefony/src/KernelAdminApi.js +60 -55
- package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
- package/dist/nodefony/src/Resolver.js +219 -63
- package/dist/nodefony/src/Route.js +38 -38
- package/dist/nodefony/src/SyslogAdminApi.js +4 -4
- package/dist/nodefony/src/docsReader.js +22 -18
- package/dist/nodefony/src/scopeCatalog.js +1 -1
- package/dist/nodefony/src/singletonGuard.js +76 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
- package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
- package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
- package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
- package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
- package/dist/types/nodefony/service/router.d.ts +33 -7
- package/dist/types/nodefony/src/Controller.d.ts +97 -24
- package/dist/types/nodefony/src/Resolver.d.ts +154 -15
- package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
- package/dist/types/nodefony/src/Route.d.ts +24 -22
- package/dist/types/nodefony/src/docsReader.d.ts +3 -3
- package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
- package/docs/admin.md +26 -26
- package/docs/controller.md +139 -121
- package/docs/decorateurs.md +108 -105
- package/docs/idempotence.md +78 -71
- package/docs/routing.md +57 -51
- package/docs/templates.md +17 -17
- package/package.json +12 -12
package/docs/idempotence.md
CHANGED
|
@@ -67,21 +67,21 @@ reste du pipeline ne fait que traduire son verdict.
|
|
|
67
67
|
|
|
68
68
|
## 📖 Lexique
|
|
69
69
|
|
|
70
|
-
| Terme | Sens (dans ce module)
|
|
71
|
-
| ----------------- |
|
|
72
|
-
| Mutation | Méthode non sûre : `POST`/`PUT`/`PATCH`/`DELETE` (RFC 9110 §9.2.1). GET/HEAD/OPTIONS = no-op.
|
|
73
|
-
| `Idempotency-Key` | En-tête client : un identifiant par **intention** d'écriture (convention Stripe, draft IETF).
|
|
74
|
-
| Empreinte | SHA-256 de `route + params + corps` — détecte une clé réutilisée pour autre chose.
|
|
75
|
-
| Bail (_lease_) | Durée pendant laquelle une réservation _in-flight_ tient sans `complete`/`abort` (60 s).
|
|
76
|
-
| Rétention (TTL) | Durée pendant laquelle une réponse mémorisée reste rejouable (10 min).
|
|
77
|
-
| in-flight | Réservation posée, pas encore complétée ni abandonnée.
|
|
78
|
-
| Verdict | Décision neutre du helper : `execute` / `guarded` / `replay` / `reject`.
|
|
79
|
-
| Scope de clé | La clé réellement stockée = `[identité, clé client]` → anti-IDOR.
|
|
80
|
-
| IDOR | _Insecure Direct Object Reference_ : lire la donnée d'autrui en devinant son identifiant.
|
|
81
|
-
| Store | Backend qui porte les clés (`memory`, `redis`, `drizzle`) derrière le contrat `IIdempotencyStore`. |
|
|
82
|
-
| GC | _Garbage Collection_ : purge périodique des clés expirées — nécessaire seulement sans TTL natif.
|
|
83
|
-
| Fail-soft | Le store indisponible n'échoue pas la mutation : elle s'exécute **sans** dédup.
|
|
84
|
-
| Fail-loud | Un store distribué demandé mais non câblé fait échouer le boot plutôt que dédupliquer en silence.
|
|
70
|
+
| Terme | Sens (dans ce module) |
|
|
71
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
72
|
+
| Mutation | Méthode non sûre : `POST`/`PUT`/`PATCH`/`DELETE` (RFC 9110 §9.2.1). GET/HEAD/OPTIONS = no-op. |
|
|
73
|
+
| `Idempotency-Key` | En-tête client : un identifiant par **intention** d'écriture (convention Stripe, draft IETF). |
|
|
74
|
+
| Empreinte | SHA-256 de `route + params + corps` — détecte une clé réutilisée pour autre chose. |
|
|
75
|
+
| Bail (_lease_) | Durée pendant laquelle une réservation _in-flight_ tient sans `complete`/`abort` (60 s). |
|
|
76
|
+
| Rétention (TTL) | Durée pendant laquelle une réponse mémorisée reste rejouable (10 min). |
|
|
77
|
+
| in-flight | Réservation posée, pas encore complétée ni abandonnée. |
|
|
78
|
+
| Verdict | Décision neutre du helper : `execute` / `guarded` / `replay` / `reject`. |
|
|
79
|
+
| Scope de clé | La clé réellement stockée = `[identité, clé client]` → anti-IDOR. |
|
|
80
|
+
| IDOR | _Insecure Direct Object Reference_ : lire la donnée d'autrui en devinant son identifiant. |
|
|
81
|
+
| Store | Backend qui porte les clés (`memory`, `redis`, `drizzle`, `mongoose`) derrière le contrat `IIdempotencyStore`. |
|
|
82
|
+
| GC | _Garbage Collection_ : purge périodique des clés expirées — nécessaire seulement sans TTL natif. |
|
|
83
|
+
| Fail-soft | Le store indisponible n'échoue pas la mutation : elle s'exécute **sans** dédup. |
|
|
84
|
+
| Fail-loud | Un store distribué demandé mais non câblé fait échouer le boot plutôt que dédupliquer en silence. |
|
|
85
85
|
|
|
86
86
|
## Qu'est-ce que c'est ?
|
|
87
87
|
|
|
@@ -104,7 +104,7 @@ Sans ce ticket, trois choses cassent :
|
|
|
104
104
|
3. **DoS du cache** — une clé arbitrairement longue, ou un flot de clés uniques, remplirait la
|
|
105
105
|
mémoire. Une clé > 255 octets est traitée comme **absente** plutôt que stockée
|
|
106
106
|
(`IDEMPOTENCY_KEY_MAX`, `idempotency.ts:36`) et le cache mémoire est **borné** (`DEFAULT_CAP`,
|
|
107
|
-
`IdempotencyStore.ts:
|
|
107
|
+
`IdempotencyStore.ts:25`).
|
|
108
108
|
|
|
109
109
|
> [!IMPORTANT]
|
|
110
110
|
> L'idempotence n'est pas qu'une commodité de résilience : c'est une brique de **sécurité**. Une
|
|
@@ -119,9 +119,9 @@ connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `
|
|
|
119
119
|
que **deux** appelants traduisent dans leur monde :
|
|
120
120
|
|
|
121
121
|
- le **data plane admin** — `AdminApiController.idempotencyGate()`
|
|
122
|
-
(`AdminApiController.ts:
|
|
122
|
+
(`AdminApiController.ts:131`) → réponse `{status, headers, body}` ;
|
|
123
123
|
- les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
|
|
124
|
-
(`Resolver.ts:
|
|
124
|
+
(`Resolver.ts:537`) → `nodefonyError` typée, ou réponse rejouée.
|
|
125
125
|
|
|
126
126
|
Conséquence pratique : il est **impossible** que l'idempotence HTTP et l'idempotence WebSocket
|
|
127
127
|
divergent — elles partagent la même fonction. C'est le différenciateur du framework (HTTP + WS
|
|
@@ -187,7 +187,7 @@ use("@nodefony/framework", {
|
|
|
187
187
|
// un nom distribué non câblé fait ÉCHOUER le boot en production (fail-loud)
|
|
188
188
|
// plutôt que dédupliquer per-pod en silence.
|
|
189
189
|
store: "redis",
|
|
190
|
-
// Purge des clés expirées — utile
|
|
190
|
+
// Purge des clés expirées — utile pour les stores SANS TTL natif (drizzle, mongoose).
|
|
191
191
|
gcIntervalS: 600,
|
|
192
192
|
},
|
|
193
193
|
});
|
|
@@ -223,7 +223,7 @@ curl -si -X POST http://localhost:5151/api/payments/charge \
|
|
|
223
223
|
> [!WARNING]
|
|
224
224
|
> **Le piège n°1** : en verdict `fresh`, la réservation est _in-flight_ tant que `complete` **ou**
|
|
225
225
|
> `abort` n'a pas été appelé. Le seam `@Idempotent` gère ce couple pour toi (`try/catch`,
|
|
226
|
-
> `Resolver.ts:
|
|
226
|
+
> `Resolver.ts:578` et `Resolver.ts:563`). Si tu appelles le store **à la main** (cas avancé), c'est
|
|
227
227
|
> **ta** responsabilité : sans `abort` sur erreur, la clé reste bloquée jusqu'à l'expiration du bail
|
|
228
228
|
> (60 s), et tout rejeu identique reçoit `409` pendant ce temps.
|
|
229
229
|
|
|
@@ -238,7 +238,7 @@ une route que d'anciens clients appellent déjà sans clé, le temps de la migra
|
|
|
238
238
|
async subscribe(@Body() body: SubscribeInput) { /* … */ }
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
-
Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:
|
|
241
|
+
Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:1606`), comme
|
|
242
242
|
`@UseSession`. Poser `@Idempotent()` sur la **classe** couvre toutes les mutations du controller ;
|
|
243
243
|
une méthode peut resserrer ou relâcher le mode. Les méthodes sûres (GET…) restent des no-op même
|
|
244
244
|
sous une classe décorée.
|
|
@@ -264,9 +264,9 @@ Deux mécanismes rendent ça possible côté socket :
|
|
|
264
264
|
- **La méthode logique voyage par `methodOverride`** — sur une socket, `context.method` vaut
|
|
265
265
|
`WEBSOCKET`, qui n'est **pas** une mutation. Sans override, la porte serait sautée et un rejeu de
|
|
266
266
|
frame `socket.mutate` créerait un doublon. Le Resolver teste donc
|
|
267
|
-
`isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:
|
|
267
|
+
`isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:126`).
|
|
268
268
|
|
|
269
|
-
Le pont utilise `executeActionGuarded()` (`Resolver.ts:
|
|
269
|
+
Le pont utilise `executeActionGuarded()` (`Resolver.ts:502`) : porte d'idempotence **sans** rendu HTTP
|
|
270
270
|
— la valeur nue est enveloppée par le peer WS, jamais écrite sur un transport HTTP.
|
|
271
271
|
|
|
272
272
|
## 🏗️ Architecture interne
|
|
@@ -299,7 +299,7 @@ sequenceDiagram
|
|
|
299
299
|
|
|
300
300
|
### Le parcours d'une mutation, étape par étape
|
|
301
301
|
|
|
302
|
-
1. **Court-circuit hot path.** `callController()` (`Resolver.ts:
|
|
302
|
+
1. **Court-circuit hot path.** `callController()` (`Resolver.ts:484`) lit `meta.idempotent` sur les
|
|
303
303
|
métadonnées d'action **figées par route**. `null` sur la quasi-totalité des routes → une
|
|
304
304
|
comparaison, flux normal, **zéro** lookup de store et zéro allocation.
|
|
305
305
|
2. **No-op sur méthode sûre.** Une action `GET` sous une classe `@Idempotent` repart directement en
|
|
@@ -312,7 +312,7 @@ sequenceDiagram
|
|
|
312
312
|
le verdict devient `execute` (`idempotency.ts:176`) — jamais de partage cross-identité.
|
|
313
313
|
5. **Réservation.** `store.begin()` compose la clé scopée et tranche.
|
|
314
314
|
6. **Mémorisation.** En succès, `complete(clé, {status, body})` où `status` est le code de réponse
|
|
315
|
-
courant et `body` la **valeur retournée** par l'action (`Resolver.ts:
|
|
315
|
+
courant et `body` la **valeur retournée** par l'action (`Resolver.ts:570`). En erreur,
|
|
316
316
|
`abort(clé)` libère la clé : **un échec ne se mémorise pas**, il doit rester réessayable.
|
|
317
317
|
|
|
318
318
|
> [!CAUTION]
|
|
@@ -327,31 +327,31 @@ sequenceDiagram
|
|
|
327
327
|
|
|
328
328
|
| Appelant | Point d'entrée | Traduction du verdict |
|
|
329
329
|
| ---------------------------- | -------------------------------------------------------------------- | ---------------------------------- |
|
|
330
|
-
| Controller userland HTTP | `callController()` (`Resolver.ts:
|
|
331
|
-
| Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:
|
|
332
|
-
| Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:
|
|
330
|
+
| Controller userland HTTP | `callController()` (`Resolver.ts:484`) | `nodefonyError` + rendu normal |
|
|
331
|
+
| Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:502`) | valeur nue, enveloppée par le peer |
|
|
332
|
+
| Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:131`) | `{status, headers, body}` |
|
|
333
333
|
|
|
334
334
|
## ⚙️ Configuration
|
|
335
335
|
|
|
336
336
|
Source unique = schéma Zod `idempotencySchema`
|
|
337
337
|
(`src/packages/@nodefony/framework/nodefony/config/config.ts:42`).
|
|
338
338
|
|
|
339
|
-
| Option | Type | Défaut | Effet
|
|
340
|
-
| ------------- | --------- | -------- |
|
|
341
|
-
| `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` sont explicites (`config.ts:43`).
|
|
342
|
-
| `gcIntervalS` | `int ≥ 0` | `600` | Intervalle de purge des clés expirées, **hors** hot-path. Sans effet pour `redis` (TTL natif) et `memory` (`config.ts:57`).
|
|
343
|
-
| `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts:68`).
|
|
339
|
+
| Option | Type | Défaut | Effet |
|
|
340
|
+
| ------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
341
|
+
| `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` / `mongoose` sont explicites (`config.ts:43`). |
|
|
342
|
+
| `gcIntervalS` | `int ≥ 0` | `600` | Intervalle de purge des clés expirées, **hors** hot-path. Sans effet pour `redis` (TTL natif) et `memory` (`config.ts:57`). |
|
|
343
|
+
| `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts:68`). |
|
|
344
344
|
|
|
345
345
|
### Comment `store: "auto"` se résout VRAIMENT
|
|
346
346
|
|
|
347
|
-
`Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:
|
|
347
|
+
`Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:230`) délègue à
|
|
348
348
|
`resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts:289`), borné aux backends
|
|
349
349
|
**réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts:81`). L'ordre
|
|
350
350
|
réel est le suivant :
|
|
351
351
|
|
|
352
352
|
| Ordre | Condition | Résolution | Ancre |
|
|
353
353
|
| ----- | ------------------------------------------------------------------------ | -------------------------------------- | -------------- |
|
|
354
|
-
| 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:
|
|
354
|
+
| 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:343` |
|
|
355
355
|
| 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts:258` |
|
|
356
356
|
| 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts:261` |
|
|
357
357
|
| 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts:274` |
|
|
@@ -364,11 +364,11 @@ réel est le suivant :
|
|
|
364
364
|
> que les données survivent au redémarrage. La résolution effective est toujours **journalisée au
|
|
365
365
|
> boot** (`index.ts:207`) : lis cette ligne plutôt que de la déduire.
|
|
366
366
|
|
|
367
|
-
MongoDB mérite une mention : `mongoose`
|
|
368
|
-
`NF_DATABASE_URL=mongodb://…`
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
367
|
+
MongoDB mérite une mention : `mongoose` porte lui aussi un store d'idempotence. Déclarer
|
|
368
|
+
`NF_DATABASE_URL=mongodb://…` résout donc en étape **3** → `mongoose`, et la dédup cross-pod tient
|
|
369
|
+
sans qu'il faille ajouter Redis à l'infrastructure. Sa réservation atomique repose sur la
|
|
370
|
+
contrainte d'unicité de `_id` — un `findOneAndUpdate` filtré sur l'entrée morte, en `upsert`, dont
|
|
371
|
+
le perdant reçoit un `E11000` : l'équivalent Mongo du `SET … NX PX` de Redis.
|
|
372
372
|
|
|
373
373
|
### Le contrat de dégradation : fail-loud, jamais silencieux
|
|
374
374
|
|
|
@@ -392,28 +392,29 @@ Tous respectent le même contrat `IIdempotencyStore`
|
|
|
392
392
|
|
|
393
393
|
### Choisir en 5 secondes
|
|
394
394
|
|
|
395
|
-
| Store
|
|
396
|
-
|
|
|
397
|
-
| `memory`
|
|
398
|
-
| `redis`
|
|
399
|
-
| `drizzle`
|
|
395
|
+
| Store | Atomicité de `begin` | Expiration | `gc()` | Multi-pod | Pour… |
|
|
396
|
+
| ---------- | ----------------------------------------------- | ------------------ | :----: | :-------: | ---------------------------------------- |
|
|
397
|
+
| `memory` | mono-thread JS | passive + cap FIFO | ❌ | ❌ | dev, tests, mono-pod |
|
|
398
|
+
| `redis` | `SET … NX PX` | TTL natif (`PX`) | ❌ | ✅ | cluster — le choix par défaut recommandé |
|
|
399
|
+
| `drizzle` | `INSERT … ON CONFLICT DO UPDATE … WHERE expiré` | applicative | ✅ | ✅ | cluster qui a déjà du SQL, pas de Redis |
|
|
400
|
+
| `mongoose` | `findOneAndUpdate(expiré) upsert` → `E11000` | applicative | ✅ | ✅ | cluster qui a déjà Mongo, pas de Redis |
|
|
400
401
|
|
|
401
402
|
### `memory` — le défaut per-pod, gratuit
|
|
402
403
|
|
|
403
404
|
`MemoryIdempotencyStore` (`IdempotencyStore.ts:44`) est enregistré d'office comme service DI
|
|
404
405
|
`idempotencyStore` par le manifeste `@services` du module framework — zéro configuration.
|
|
405
406
|
|
|
406
|
-
- **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:
|
|
407
|
+
- **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:115`) lit et écrit la `Map`
|
|
407
408
|
sans point de suspension → deux `begin` concurrents ne peuvent pas se croiser.
|
|
408
|
-
- **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts:
|
|
409
|
-
`DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts:
|
|
410
|
-
(`IdempotencyStore.ts:
|
|
411
|
-
- **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts:
|
|
409
|
+
- **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts:21`), bail
|
|
410
|
+
`DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts:23`), plafond `DEFAULT_CAP` = 1000 entrées
|
|
411
|
+
(`IdempotencyStore.ts:25`). Elles ne sont **pas** configurables.
|
|
412
|
+
- **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts:115`) ; aucun timer,
|
|
412
413
|
aucun listener.
|
|
413
414
|
- **Purge passive** : les entrées expirées ne sont retirées qu'à l'écriture, dans `evictIfNeeded()`
|
|
414
|
-
(`IdempotencyStore.ts:
|
|
415
|
+
(`IdempotencyStore.ts:172`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
|
|
415
416
|
sous le cap. Coût nul tant qu'on n'écrit pas.
|
|
416
|
-
- **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts:
|
|
417
|
+
- **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts:142`) n'écrit **que** si la clé est
|
|
417
418
|
encore _notre_ in-flight — jamais de résurrection d'une clé déjà `abort`-ée ou évincée.
|
|
418
419
|
|
|
419
420
|
⚠️ Limite structurelle : la dédup est **affine au pod**. Un rejeu routé vers un autre pod n'est pas
|
|
@@ -452,7 +453,7 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
|
|
|
452
453
|
|
|
453
454
|
- **Réservation atomique en UNE instruction** — un `INSERT` avec
|
|
454
455
|
`onConflictDoUpdate` (`DrizzleIdempotencyStore.ts:234`) dont la garde `setWhere` ne réécrit que si
|
|
455
|
-
l'entrée est morte (`DrizzleIdempotencyStore.ts:
|
|
456
|
+
l'entrée est morte (`DrizzleIdempotencyStore.ts:269`). Le `returning` ne rend une ligne que si
|
|
456
457
|
l'INSERT a passé (clé neuve) ou si le `DO UPDATE` a **volé** une entrée expirée → `fresh`. Zéro
|
|
457
458
|
ligne = contention → on lit l'état réel.
|
|
458
459
|
- **Invariant capital** : le store ne renvoie **jamais** `fresh` hors réservation atomique gagnée.
|
|
@@ -460,14 +461,14 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
|
|
|
460
461
|
(`DrizzleIdempotencyStore.ts:264`), jamais `fresh`.
|
|
461
462
|
- **MySQL/MariaDB** : ni `RETURNING`, ni `WHERE` sur l'`ON DUPLICATE KEY UPDATE`, et un `affectedRows`
|
|
462
463
|
ambigu → la réservation passe par `reserveIdempotencyKeyMysql()`
|
|
463
|
-
(`DrizzleIdempotencyStore.ts:
|
|
464
|
+
(`DrizzleIdempotencyStore.ts:233`), qui la reconstruit en deux instructions chacune atomique.
|
|
464
465
|
- **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts:318`) = `DELETE WHERE expiresAt <= now`.
|
|
465
466
|
C'est le **seul** store qui expose `gc`, donc le seul que le framework planifie (voir plus bas).
|
|
466
|
-
- **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:
|
|
467
|
-
(`DrizzleIdempotencyStore.ts:
|
|
467
|
+
- **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:301`) et `abort()`
|
|
468
|
+
(`DrizzleIdempotencyStore.ts:319`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
|
|
468
469
|
déjà mémorisée, jamais de résurrection d'une clé libérée. `complete` ne touche pas `fingerprint`.
|
|
469
470
|
- **Résolution lazy + dégradation gracieuse** : le handle Drizzle est résolu à **chaque** appel
|
|
470
|
-
(`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:
|
|
471
|
+
(`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:191`). ORM non connecté → `begin`
|
|
471
472
|
renvoie `fresh` (sans dédup), le reste est no-op.
|
|
472
473
|
|
|
473
474
|
Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
|
|
@@ -522,10 +523,14 @@ Deux détails qui expliquent des surprises réelles :
|
|
|
522
523
|
| `drizzle` (SQL) | SQLite | idem, mais mono-machine → **test** | ❌ | ✅ |
|
|
523
524
|
| `memory` | RAM du pod | mono-thread JS ; cap FIFO 1000 ; TTL 10 min | ❌ | n/a |
|
|
524
525
|
|
|
525
|
-
> [!
|
|
526
|
-
> **MongoDB (`@nodefony/mongoose`)
|
|
527
|
-
>
|
|
528
|
-
>
|
|
526
|
+
> [!NOTE]
|
|
527
|
+
> **MongoDB (`@nodefony/mongoose`) porte un store d'idempotence.** `store: "mongoose"` est
|
|
528
|
+
> sélectionnable dès que le module est chargé, et `auto` avec une infra Mongo y résout tout seul.
|
|
529
|
+
> En cluster Mongo, la dédup cross-pod n'exige donc pas d'ajouter Redis.
|
|
530
|
+
>
|
|
531
|
+
> Comme la variante SQL, il n'a **pas de TTL natif** : le framework arme un `GcScheduler` parce
|
|
532
|
+
> que le store expose `gc()`. L'échéance d'une entrée bascule du bail à la rétention, ce qu'un
|
|
533
|
+
> index TTL ne saurait pas suivre.
|
|
529
534
|
|
|
530
535
|
## 🧰 API publique
|
|
531
536
|
|
|
@@ -533,8 +538,8 @@ Signatures complètes : `.ai/symbols.json`. Ce qui compte à l'usage :
|
|
|
533
538
|
|
|
534
539
|
### Le décorateur
|
|
535
540
|
|
|
536
|
-
`@Idempotent(options?)` (`routerDecorators.ts:
|
|
537
|
-
métadonnées (`IdempotentMeta`, `routerDecorators.ts:
|
|
541
|
+
`@Idempotent(options?)` (`routerDecorators.ts:1171`) — dual **classe + méthode**. N'écrit que des
|
|
542
|
+
métadonnées (`IdempotentMeta`, `routerDecorators.ts:477`), zéro import de `@nodefony/security`, zéro
|
|
538
543
|
cycle. La porte est appliquée par le Resolver.
|
|
539
544
|
|
|
540
545
|
### Le contrat de store
|
|
@@ -576,7 +581,7 @@ Le mode **curseur** de Redis mérite une explication, parce qu'il piège : `SCAN
|
|
|
576
581
|
plafond** mais un indice d'effort — Redis peut rendre plus de clés que demandé. Sans précaution, la
|
|
577
582
|
page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts:108`). D'où
|
|
578
583
|
le **curseur composite** `"<consommé>:<curseurRedis>"` (`encodeCursor()`,
|
|
579
|
-
`RedisIdempotencyStore.ts:
|
|
584
|
+
`RedisIdempotencyStore.ts:63`) : on ne rend que `limit` éléments et on mémorise combien de clés du
|
|
580
585
|
lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et reprend là.
|
|
581
586
|
|
|
582
587
|
> [!IMPORTANT]
|
|
@@ -588,8 +593,10 @@ lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et repre
|
|
|
588
593
|
|
|
589
594
|
Deux réserves à connaître :
|
|
590
595
|
|
|
591
|
-
- Le champ `tenantId` d'`IPageQuery` est un **
|
|
592
|
-
|
|
596
|
+
- Le champ `tenantId` d'`IPageQuery` est un **emplacement réservé** : il appartient au contrat de
|
|
597
|
+
pagination commun à tous les stores listables, et servira à isoler les données par organisation
|
|
598
|
+
quand le framework saura servir plusieurs organisations. Ici, le passer n'a **aucun effet de
|
|
599
|
+
filtrage** : ce store l'ignore.
|
|
593
600
|
- `size` est une **approximation per-pod** pour les stores distribués (compteur local incrémenté au
|
|
594
601
|
`fresh`, décrémenté au `complete`/`abort`), désalignée cross-pod et non décrémentée si un bail
|
|
595
602
|
expire sans complétion. La vérité cluster passe par la base ou `redis-cli`, jamais par ce getter.
|
|
@@ -640,14 +647,14 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
|
|
|
640
647
|
## ⚡ Performance et mémoire
|
|
641
648
|
|
|
642
649
|
Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
|
|
643
|
-
`null` (`routerDecorators.ts:
|
|
644
|
-
normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:
|
|
650
|
+
`null` (`routerDecorators.ts:1151`) : `callController()` fait **une comparaison** et repart en flux
|
|
651
|
+
normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:473`).
|
|
645
652
|
La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
|
|
646
653
|
|
|
647
654
|
Sur le chemin décoré :
|
|
648
655
|
|
|
649
656
|
- Le store mémoire n'alloue sa `Map` qu'au 1ᵉʳ `begin`, ne pose **aucun timer ni listener**, et purge
|
|
650
|
-
en passif (`IdempotencyStore.ts:
|
|
657
|
+
en passif (`IdempotencyStore.ts:115`).
|
|
651
658
|
- L'empreinte est un hash SHA-256 court → comparaison O(1), et le payload n'est jamais conservé en
|
|
652
659
|
clair.
|
|
653
660
|
- Le GC est **hors hot-path** et armé pour un seul store (SQL) ; le jitter évite que N pods purgent
|
|
@@ -669,7 +676,7 @@ Trois surfaces existent aujourd'hui :
|
|
|
669
676
|
façon la plus rapide de voir un rejeu, un `409` ou un `422` en vrai.
|
|
670
677
|
- **Stores** — la brique `idempotency` y apparaît avec sa nature **éphémère**, le backend configuré,
|
|
671
678
|
le backend résolu, la liste des backends disponibles et la raison de la résolution — brique
|
|
672
|
-
`idempotency` (`stores/storesModel.ts:
|
|
679
|
+
`idempotency` (`stores/storesModel.ts:145`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
|
|
673
680
|
croyait.
|
|
674
681
|
- **ERD** — la table `idempotency_key` est regroupée sous `@nodefony/framework`
|
|
675
682
|
(`idempotencyEntity.ts:133`), pas sous l'ORM qui l'héberge.
|
|
@@ -684,7 +691,7 @@ Trois surfaces existent aujourd'hui :
|
|
|
684
691
|
|
|
685
692
|
| Symptôme | Cause (dans le code) | Correction |
|
|
686
693
|
| ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
|
687
|
-
| Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:
|
|
694
|
+
| Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:628`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
|
|
688
695
|
| `409` en boucle sur un endpoint | Action qui lève avant `complete`/`abort` → in-flight bloqué jusqu'au bail (60 s) | Le seam le gère ; en usage manuel du store, `try/finally` obligatoire |
|
|
689
696
|
| `422 Idempotency-Key is already used` | Même clé, **payload différent** (empreinte ≠, `idempotency.ts:189`) | Une clé = une intention ; nouvelle clé par requête distincte |
|
|
690
697
|
| Rien n'est dédupliqué **malgré** la clé | Pas d'identité fiable → verdict `execute` (`idempotency.ts:176`) | S'assurer que le firewall a résolu l'utilisateur **avant** la mutation |
|