@nodefony/framework 10.0.0-alpha.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/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- package/package.json +83 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { extractActionScopes, resolveActionMeta } from "../decorators/routerDecorators.js";
|
|
2
|
+
import router_default from "../service/router.js";
|
|
3
|
+
import AdminApiController from "../controller/AdminApiController.js";
|
|
4
|
+
//#region nodefony/src/PlaygroundAdminApi.ts
|
|
5
|
+
/** Normalise `requirements.methods` (string | string[]) → tableau majuscule. */
|
|
6
|
+
function methodsOf(route) {
|
|
7
|
+
const m = route.requirements?.methods ?? route.method;
|
|
8
|
+
if (Array.isArray(m)) return m.map((x) => String(x).toUpperCase());
|
|
9
|
+
if (typeof m === "string") return m.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
|
|
10
|
+
return ["ANY"];
|
|
11
|
+
}
|
|
12
|
+
/** Défauts de la route sans la clé interne `controller` (jamais un input user). */
|
|
13
|
+
function publicDefaults(route) {
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [k, v] of Object.entries(route.defaults)) if (k !== "controller") out[k] = v;
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
/** Sérialise une route en action playground (métadonnées figées, JSON-safe). */
|
|
19
|
+
function serializeAction(route) {
|
|
20
|
+
const meta = resolveActionMeta(route);
|
|
21
|
+
const methods = methodsOf(route);
|
|
22
|
+
const scopes = route.controller && route.classMethod ? extractActionScopes(route.controller, route.classMethod) : [];
|
|
23
|
+
return {
|
|
24
|
+
route: route.name,
|
|
25
|
+
path: route.path ?? null,
|
|
26
|
+
methods,
|
|
27
|
+
duplex: methods.includes("WEBSOCKET"),
|
|
28
|
+
action: route.classMethod ?? null,
|
|
29
|
+
variables: [...route.variables],
|
|
30
|
+
defaults: publicDefaults(route),
|
|
31
|
+
params: meta.paramsMeta?.map((p) => ({
|
|
32
|
+
source: p.source,
|
|
33
|
+
key: p.key ?? null,
|
|
34
|
+
index: p.index,
|
|
35
|
+
stream: p.stream === true
|
|
36
|
+
})).sort((a, b) => a.index - b.index) ?? [],
|
|
37
|
+
guards: {
|
|
38
|
+
security: meta.security ? { clauses: meta.security.clauses.map((c) => ({
|
|
39
|
+
anyOf: [...c.anyOf],
|
|
40
|
+
subjectParam: c.subjectParam ?? null
|
|
41
|
+
})) } : null,
|
|
42
|
+
scopes,
|
|
43
|
+
idempotent: meta.idempotent ? { required: meta.idempotent.required } : null,
|
|
44
|
+
csrfProtect: meta.csrfProtect,
|
|
45
|
+
csrfExempt: meta.csrfExempt,
|
|
46
|
+
session: meta.sessionIntent,
|
|
47
|
+
bypassFirewall: route.bypassFirewall === true
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Construit le snapshot playground : toutes les routes à controller, groupées
|
|
53
|
+
* par classe de controller, triées (module puis nom, actions par path).
|
|
54
|
+
*
|
|
55
|
+
* Exclut les routes du **pont admin** (`AdminApiController.dispatch`) : le data
|
|
56
|
+
* plane a déjà son catalogue (`GET /nodefony/framework/api/admin`) et ses ~50
|
|
57
|
+
* routes techniques noieraient les controllers applicatifs.
|
|
58
|
+
*
|
|
59
|
+
* @returns la liste des controllers jouables, prête pour la page Studio.
|
|
60
|
+
*/
|
|
61
|
+
function buildPlaygroundSnapshot() {
|
|
62
|
+
const byController = /* @__PURE__ */ new Map();
|
|
63
|
+
for (const route of router_default.routes) {
|
|
64
|
+
const ctor = route.controller;
|
|
65
|
+
if (!ctor || !route.classMethod) continue;
|
|
66
|
+
if (ctor === AdminApiController) continue;
|
|
67
|
+
let group = byController.get(ctor);
|
|
68
|
+
if (!group) {
|
|
69
|
+
group = {
|
|
70
|
+
name: ctor.name ?? "Controller",
|
|
71
|
+
module: route.module?.name ?? null,
|
|
72
|
+
actions: []
|
|
73
|
+
};
|
|
74
|
+
byController.set(ctor, group);
|
|
75
|
+
}
|
|
76
|
+
group.actions.push(serializeAction(route));
|
|
77
|
+
}
|
|
78
|
+
const controllers = [...byController.values()];
|
|
79
|
+
for (const c of controllers) c.actions.sort((a, b) => (a.path ?? "").localeCompare(b.path ?? ""));
|
|
80
|
+
controllers.sort((a, b) => (a.module ?? "~").localeCompare(b.module ?? "~") || a.name.localeCompare(b.name));
|
|
81
|
+
return { controllers };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Endpoints playground à greffer au producteur `framework` (dev uniquement —
|
|
85
|
+
* cf gating dans `createFrameworkAdminApi`).
|
|
86
|
+
*
|
|
87
|
+
* @returns `GET /nodefony/framework/api/playground/routes`.
|
|
88
|
+
*/
|
|
89
|
+
function createPlaygroundEndpoints() {
|
|
90
|
+
return [{
|
|
91
|
+
path: "playground/routes",
|
|
92
|
+
summary: "Playground (dev) — controllers + actions + transports + params + guards, form-ready",
|
|
93
|
+
handler: () => buildPlaygroundSnapshot()
|
|
94
|
+
}];
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
export { buildPlaygroundSnapshot, createPlaygroundEndpoints };
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { assertPageQuery } from "nodefony";
|
|
2
|
+
//#region nodefony/src/RedisIdempotencyStore.ts
|
|
3
|
+
/** Préfixe namespacé des clés d'idempotence dans Redis. */
|
|
4
|
+
/**
|
|
5
|
+
* Préfixe HISTORIQUE des clés d'idempotence. Utilisé tel quel par une application
|
|
6
|
+
* sans cloison ; sinon le service Redis y insère le nom de l'application (cf
|
|
7
|
+
* `RedisService.keyPrefix`).
|
|
8
|
+
*/
|
|
9
|
+
const KEY_BASE = "nf:idem";
|
|
10
|
+
/**
|
|
11
|
+
* Bail par défaut d'une entrée *in-flight* : 60 s (au-delà = exécution réputée
|
|
12
|
+
* abandonnée → la clé redevient réservable). Porté par le TTL natif (`PX`).
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_LEASE_MS = 6e4;
|
|
15
|
+
/**
|
|
16
|
+
* Rétention par défaut d'une réponse mémorisée : 10 min (un rejeu plausible
|
|
17
|
+
* reste dans cette fenêtre). Porté par le TTL natif (`PX`).
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_TTL_MS = 6e5;
|
|
20
|
+
/**
|
|
21
|
+
* Encode le curseur composite `"<consommé>:<curseurRedis>"` — cf
|
|
22
|
+
* {@link RedisIdempotencyStore.listPage} pour le pourquoi.
|
|
23
|
+
*/
|
|
24
|
+
function encodeCursor(scanCursor, skip) {
|
|
25
|
+
return `${skip}:${scanCursor}`;
|
|
26
|
+
}
|
|
27
|
+
/** Inverse d'{@link encodeCursor} — tolère un curseur absent, vide ou malformé. */
|
|
28
|
+
function decodeCursor(cursor) {
|
|
29
|
+
if (!cursor) return {
|
|
30
|
+
scanCursor: "0",
|
|
31
|
+
skip: 0
|
|
32
|
+
};
|
|
33
|
+
const sep = cursor.indexOf(":");
|
|
34
|
+
if (sep === -1) return {
|
|
35
|
+
scanCursor: cursor,
|
|
36
|
+
skip: 0
|
|
37
|
+
};
|
|
38
|
+
const skip = Number.parseInt(cursor.slice(0, sep), 10);
|
|
39
|
+
return {
|
|
40
|
+
scanCursor: cursor.slice(sep + 1) || "0",
|
|
41
|
+
skip: Number.isFinite(skip) && skip > 0 ? skip : 0
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Store d'idempotence **Redis** (node-redis v6) — implémentation distribuée
|
|
46
|
+
* d'{@link IIdempotencyStore} pour le cluster (dédup des mutations rejouées
|
|
47
|
+
* PARTAGÉE cross-pod, là où l'impl mémoire par défaut reste affine à un pod).
|
|
48
|
+
*
|
|
49
|
+
* **Pourquoi Redis est le bon backing** (modèle Stripe `Idempotency-Key`) :
|
|
50
|
+
* - `SET key … NX PX` = **réservation atomique côté serveur** → le `409`
|
|
51
|
+
* in-flight marche VRAIMENT entre pods (deux requêtes concurrentes sur deux
|
|
52
|
+
* pods : un seul `SET NX` gagne, l'autre voit l'entrée → conflit) ;
|
|
53
|
+
* - **TTL natif** (`PX`) sur le bail in-flight ET la réponse mémorisée → `gc()`
|
|
54
|
+
* superflu (zéro balayage applicatif, ≠ Drizzle).
|
|
55
|
+
*
|
|
56
|
+
* **Placement** : vit dans `@nodefony/framework` (le consommateur du contrat),
|
|
57
|
+
* PAS dans `@nodefony/redis` — calqué sur `RedisBackplane` (`@nodefony/realtime`)
|
|
58
|
+
* qui possède son adaptateur Redis et résout le service `redis` par nom (couplage
|
|
59
|
+
* structurel, 0 dépendance directe → 0 cycle). Le contrat `IIdempotencyStore`
|
|
60
|
+
* vit au CORE (`nodefony`), consommé en `import type`.
|
|
61
|
+
*
|
|
62
|
+
* **Modèle de clés** (préfixe `nf:idem`, cloisonné par application) : `<prefix>:<key>` = string JSON de
|
|
63
|
+
* l'{@link Entry}. La `<key>` est DÉJÀ scopée à l'identité par l'appelant
|
|
64
|
+
* (`evaluateIdempotency` compose `[identity, clientKey]`) → anti-IDOR garanti en
|
|
65
|
+
* amont ; le store reste agnostique au scope.
|
|
66
|
+
*
|
|
67
|
+
* **Empreinte préservée à la complétion** : `complete()` ne reçoit pas le
|
|
68
|
+
* fingerprint → il **relit** l'entrée *in-flight* pour reporter son `f` dans
|
|
69
|
+
* l'entrée *done*. Sans ça, un rejeu de la clé avec un AUTRE payload après
|
|
70
|
+
* complétion ne serait pas détecté (`mismatch` 422 perdu, draft §2.7).
|
|
71
|
+
*
|
|
72
|
+
* **Dégradation gracieuse** : si la connexion `main` n'est pas (ou plus) ouverte
|
|
73
|
+
* (boot/shutdown), `begin` renvoie `fresh` (la mutation s'exécute, **sans**
|
|
74
|
+
* dédup) et `complete`/`abort` sont des no-op — l'idempotence est temporairement
|
|
75
|
+
* inactive plutôt que de bloquer la mutation (fail-soft sur la dispo, comme le
|
|
76
|
+
* session/token store Redis). Trade-off assumé : un rejeu pendant une coupure
|
|
77
|
+
* Redis peut ré-exécuter (le client rejoue alors sa clé au rétablissement).
|
|
78
|
+
*/
|
|
79
|
+
var RedisIdempotencyStore = class {
|
|
80
|
+
#resolveClient;
|
|
81
|
+
/** Fournit le préfixe cloisonné par application (lazy : le service arrive au boot). */
|
|
82
|
+
#resolvePrefix;
|
|
83
|
+
/** Préfixe mémoïsé — il est lu à chaque clé. */
|
|
84
|
+
#prefixCache = null;
|
|
85
|
+
#leaseMs;
|
|
86
|
+
#ttlMs;
|
|
87
|
+
/** Compteur LOCAL best-effort d'entrées réservées par CE pod (cf {@link size}). */
|
|
88
|
+
#pending = 0;
|
|
89
|
+
/**
|
|
90
|
+
* @param resolveClient - résolveur **lazy** du client Redis (l'ordre de boot
|
|
91
|
+
* n'est pas garanti à la construction ; `null` = connexion indisponible).
|
|
92
|
+
* @param leaseMs - bail d'une entrée *in-flight* (ms).
|
|
93
|
+
* @param ttlMs - rétention d'une réponse mémorisée (ms).
|
|
94
|
+
*/
|
|
95
|
+
constructor(resolveClient, leaseMs = DEFAULT_LEASE_MS, ttlMs = DEFAULT_TTL_MS, resolvePrefix = () => KEY_BASE) {
|
|
96
|
+
this.#resolveClient = resolveClient;
|
|
97
|
+
this.#resolvePrefix = resolvePrefix;
|
|
98
|
+
this.#leaseMs = leaseMs;
|
|
99
|
+
this.#ttlMs = ttlMs;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Approximation **per-pod, best-effort** : compteur local des réservations
|
|
103
|
+
* faites par CE pod, non décrémenté si le bail expire sans `complete`/`abort`,
|
|
104
|
+
* et désaligné cross-pod (un `begin` sur un pod, un `complete` sur un autre).
|
|
105
|
+
* La vérité cluster passe par `redis-cli` (`SCAN nf:idem:*`/`DBSIZE`), jamais
|
|
106
|
+
* ce getter (un `SCAN` à chaque lecture serait cher). Borné à ≥ 0.
|
|
107
|
+
*/
|
|
108
|
+
get size() {
|
|
109
|
+
return this.#pending < 0 ? 0 : this.#pending;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* {@inheritDoc IIdempotencyStore.listPage}
|
|
113
|
+
*
|
|
114
|
+
* **Curseur SCAN** : au plus UN passage par appel (cold-path admin). Capacité
|
|
115
|
+
* réduite ASSUMÉE — pas de `total` (compter exigerait un SCAN complet du
|
|
116
|
+
* keyspace, précisément ce qu'on refuse), pas d'ordre global, et la page peut
|
|
117
|
+
* compter moins que `limit` (le filtre s'applique au batch scanné). Le client
|
|
118
|
+
* boucle tant que `hasNext` en repassant `nextCursor`.
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ **`COUNT` n'est PAS un plafond** mais un indice d'effort : Redis peut
|
|
121
|
+
* rendre plus de clés que demandé (petit keyspace en listpack → tout arrive
|
|
122
|
+
* d'un coup). Sans précaution la page dépasserait `limit` et violerait
|
|
123
|
+
* `IPage`. D'où le **curseur composite** `"<consommé>:<curseurRedis>"` : on ne
|
|
124
|
+
* rend que `limit` éléments et on mémorise combien de clés du batch ont été
|
|
125
|
+
* consommées ; la page suivante rejoue le MÊME `SCAN` et reprend là. Bug
|
|
126
|
+
* réel, invisible contre un double — trouvé sur serveur Redis réel.
|
|
127
|
+
*/
|
|
128
|
+
async listPage(query) {
|
|
129
|
+
assertPageQuery(query, "cursor");
|
|
130
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
131
|
+
const client = this.#client();
|
|
132
|
+
if (!client) return {
|
|
133
|
+
items: [],
|
|
134
|
+
limit,
|
|
135
|
+
hasNext: false,
|
|
136
|
+
nextCursor: null
|
|
137
|
+
};
|
|
138
|
+
const { scanCursor, skip } = decodeCursor(query.cursor);
|
|
139
|
+
const match = query.q !== void 0 && query.q.length > 0 ? `${this.#prefix()}:${query.q}*` : `${this.#prefix()}:*`;
|
|
140
|
+
const res = await client.scan(scanCursor, {
|
|
141
|
+
MATCH: match,
|
|
142
|
+
COUNT: limit
|
|
143
|
+
});
|
|
144
|
+
const next = String(res.cursor);
|
|
145
|
+
const prefixLen = this.#prefix().length + 1;
|
|
146
|
+
const items = [];
|
|
147
|
+
let consumed = 0;
|
|
148
|
+
for (const key of res.keys.slice(skip)) {
|
|
149
|
+
if (items.length >= limit) break;
|
|
150
|
+
consumed += 1;
|
|
151
|
+
const entry = this.#parse(await client.get(key));
|
|
152
|
+
if (entry === null) continue;
|
|
153
|
+
const state = entry.s === "if" ? "in-flight" : "done";
|
|
154
|
+
if (query.state !== void 0 && state !== query.state) continue;
|
|
155
|
+
const ttl = await client.pTTL(key);
|
|
156
|
+
items.push({
|
|
157
|
+
key: key.slice(prefixLen),
|
|
158
|
+
state,
|
|
159
|
+
expiresAtMs: ttl > 0 ? Date.now() + ttl : 0,
|
|
160
|
+
hasResponse: entry.s === "d"
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const nextCursor = skip + consumed < res.keys.length ? encodeCursor(scanCursor, skip + consumed) : next === "0" ? null : encodeCursor(next, 0);
|
|
164
|
+
return {
|
|
165
|
+
items,
|
|
166
|
+
limit,
|
|
167
|
+
hasNext: nextCursor !== null,
|
|
168
|
+
nextCursor
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
#client() {
|
|
172
|
+
return this.#resolveClient();
|
|
173
|
+
}
|
|
174
|
+
/** Préfixe effectif des clés, cloisonné par application (mémoïsé). */
|
|
175
|
+
#prefix() {
|
|
176
|
+
if (this.#prefixCache === null) this.#prefixCache = this.#resolvePrefix();
|
|
177
|
+
return this.#prefixCache;
|
|
178
|
+
}
|
|
179
|
+
#key(key) {
|
|
180
|
+
return `${this.#prefix()}:${key}`;
|
|
181
|
+
}
|
|
182
|
+
/** Parse défensif d'une valeur de clé ; `null` si absente/corrompue. */
|
|
183
|
+
#parse(raw) {
|
|
184
|
+
if (raw === null) return null;
|
|
185
|
+
try {
|
|
186
|
+
const v = JSON.parse(raw);
|
|
187
|
+
return v && (v.s === "if" || v.s === "d") ? v : null;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async begin(key, fingerprint) {
|
|
193
|
+
const client = this.#client();
|
|
194
|
+
if (!client) return { state: "fresh" };
|
|
195
|
+
const k = this.#key(key);
|
|
196
|
+
const reservation = JSON.stringify({
|
|
197
|
+
s: "if",
|
|
198
|
+
f: fingerprint
|
|
199
|
+
});
|
|
200
|
+
if (await client.set(k, reservation, {
|
|
201
|
+
NX: true,
|
|
202
|
+
PX: this.#leaseMs
|
|
203
|
+
}) !== null) {
|
|
204
|
+
this.#pending++;
|
|
205
|
+
return { state: "fresh" };
|
|
206
|
+
}
|
|
207
|
+
const existing = this.#parse(await client.get(k));
|
|
208
|
+
if (existing === null) {
|
|
209
|
+
if (await client.set(k, reservation, {
|
|
210
|
+
NX: true,
|
|
211
|
+
PX: this.#leaseMs
|
|
212
|
+
}) !== null) {
|
|
213
|
+
this.#pending++;
|
|
214
|
+
return { state: "fresh" };
|
|
215
|
+
}
|
|
216
|
+
return { state: "in-flight" };
|
|
217
|
+
}
|
|
218
|
+
if (existing.f !== fingerprint) return { state: "mismatch" };
|
|
219
|
+
if (existing.s === "d") return {
|
|
220
|
+
state: "replayed",
|
|
221
|
+
response: existing.r
|
|
222
|
+
};
|
|
223
|
+
return { state: "in-flight" };
|
|
224
|
+
}
|
|
225
|
+
async complete(key, response) {
|
|
226
|
+
const client = this.#client();
|
|
227
|
+
if (!client) return;
|
|
228
|
+
const k = this.#key(key);
|
|
229
|
+
const existing = this.#parse(await client.get(k));
|
|
230
|
+
if (existing === null || existing.s !== "if") {
|
|
231
|
+
this.#dec();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const done = JSON.stringify({
|
|
235
|
+
s: "d",
|
|
236
|
+
f: existing.f,
|
|
237
|
+
r: response
|
|
238
|
+
});
|
|
239
|
+
await client.set(k, done, { PX: this.#ttlMs });
|
|
240
|
+
this.#dec();
|
|
241
|
+
}
|
|
242
|
+
async abort(key) {
|
|
243
|
+
const client = this.#client();
|
|
244
|
+
if (!client) return;
|
|
245
|
+
await client.del(this.#key(key));
|
|
246
|
+
this.#dec();
|
|
247
|
+
}
|
|
248
|
+
/** Décrémente le compteur local borné à 0. */
|
|
249
|
+
#dec() {
|
|
250
|
+
if (this.#pending > 0) this.#pending--;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
//#endregion
|
|
254
|
+
export { RedisIdempotencyStore };
|