@alxia/redis 0.1.3 → 0.2.0

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 CHANGED
@@ -6,7 +6,7 @@ Redis for [alxia](https://www.npmjs.com/package/@alxia/core), on
6
6
  Bun's own Redis client, no driver, no dependency:
7
7
 
8
8
  - `redisStore`: a rate-limit store every process shares;
9
- - `idempotency`: routes that run once per `Idempotency-Key`;
9
+ - `idempotency`: a middleware, so routes run once per `Idempotency-Key`;
10
10
  - `redisCacheStore`: an `@alxia/cache` store every process shares;
11
11
  - `redis`: the client, typed caches and a lock in the context.
12
12
 
@@ -68,7 +68,7 @@ the response: the route runs, and the error is logged.
68
68
  ## Idempotent routes
69
69
 
70
70
  ```ts
71
- import { alxia } from '@alxia/core';
71
+ import { alxia, validate } from '@alxia/core';
72
72
  import { idempotency } from '@alxia/redis';
73
73
  import { connectRedis } from '@nxgt/redis';
74
74
  import { z } from 'zod';
@@ -78,7 +78,7 @@ const Payment = z.object({ amount: z.number().int().positive() });
78
78
 
79
79
  const app = alxia()
80
80
  .use(idempotency(connection.client, { name: 'payments', required: true }))
81
- .post('/payments', { body: Payment }, ({ body, reply }) =>
81
+ .post('/payments', validate({ body: Payment }), ({ body, reply }) =>
82
82
  reply(201, { id: crypto.randomUUID(), amount: body.amount }),
83
83
  );
84
84
  ```
@@ -95,12 +95,16 @@ the first response back — status, headers, body — with
95
95
  | no key, with `required` | `400 { error: 'idempotency_key_missing' }` |
96
96
  | the route answers a 5xx, or streams | answered, not kept: the key is free again |
97
97
 
98
- Every one is part of the guarded routes' types. Keys are scoped by the
98
+ Only the routes after the middleware answer them; a request no route matches
99
+ is not guarded: there is no route to scope its key by. What is kept is what the
100
+ route answers, an error's answer included. Keys are scoped by the
99
101
  route and by `scope(ctx)` — the client's address by default, a user id
100
102
  when there is one — so two clients choosing the same key never see each
101
- other's response. A replay never repeats `Set-Cookie`. Every response
103
+ other's response. A request with no scope — no address, no `scope` — runs
104
+ unguarded, nothing stored or replayed, and the middleware warns once. A replay never repeats `Set-Cookie`. Every response
102
105
  below 500 is kept, a 4xx included: declare a rate limit or an auth check
103
- **before** `idempotency`, or its refusal is replayed.
106
+ **before** `idempotency`, or its refusal is replayed (`app.use` in
107
+ declaration order: the guard first, then `idempotency`).
104
108
 
105
109
  | option | default | |
106
110
  | --- | --- | --- |
@@ -111,7 +115,7 @@ below 500 is kept, a 4xx included: declare a rate limit or an auth check
111
115
  | `methods` | `POST`, `PATCH` | |
112
116
  | `header` | `Idempotency-Key` | |
113
117
  | `required` | `false` | |
114
- | `scope` | the client's address | `(ctx) => string` |
118
+ | `scope` | the client's address | `(ctx) => string \| undefined`; `undefined` runs the request unguarded |
115
119
 
116
120
  ## Caches and locks in the context
117
121
 
@@ -128,7 +132,7 @@ const loadUser = async (id: string) => ({ id, name: 'Ada' }); // your database
128
132
  const touch = async (user: z.infer<typeof User>) => user;
129
133
 
130
134
  const app = alxia()
131
- .use(redis(connection.client, { caches: { users } }))
135
+ .plugin(redis(connection.client, { caches: { users } }))
132
136
  .get('/users/:id', async ({ caches, lock, params, reply }) => {
133
137
  const user = await caches.users.remember(params.id, () => loadUser(params.id)); // typed by User
134
138
  await lock(`user:${params.id}`, () => touch(user));
@@ -151,9 +155,10 @@ The package's specs run against `$REDIS_URL`, or a `redis-server` on
151
155
  | --- | --- |
152
156
  | `redisStore(client, { name })`, `RedisStoreOptions` | an `@alxia/rate-limit` store |
153
157
  | `redisCacheStore(client, { name })`, `RedisCacheStoreOptions` | an `@alxia/cache` store |
154
- | `idempotency(client, options)` | the plugin |
155
- | `redis(client, { caches? })`, `RedisContextOptions` | the plugin: `redis`, `caches`, `lock` in the context |
158
+ | `idempotency(client, options)` | the middleware, given to `app.use` |
159
+ | `redis(client, { caches? })`, `RedisContextOptions` | a plugin, given to `app.plugin`: `redis`, `caches`, `lock` in the context |
156
160
  | `IdempotencyOptions`, `IdempotencyErrorBody`, `RedisContext`, `BoundCaches` | its types |
161
+ | `IdempotencyMiddleware` | what `idempotency()` returns: a middleware that adds nothing, and may answer a 400, a 409 or a 422 |
157
162
  | `AnyCache` | any cache definition: the constraint of a function generic over the caches it hands to `redis()` |
158
163
 
159
164
  ## Documentation
package/dist/context.d.ts CHANGED
@@ -8,7 +8,7 @@ import type { z } from 'zod';
8
8
  *
9
9
  * ```ts
10
10
  * function withCaches<const Caches extends Record<string, AnyCache>>(caches: Caches) {
11
- * return alxia().use(redis(client, { caches }));
11
+ * return alxia().plugin(redis(client, { caches }));
12
12
  * }
13
13
  * ```
14
14
  */
@@ -36,9 +36,9 @@ export interface RedisContext<Caches extends Record<string, AnyCache>> {
36
36
  *
37
37
  * ```ts
38
38
  * const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: User });
39
- * app.use(redis(connection.client, { caches: { users } }))
39
+ * app.plugin(redis(connection.client, { caches: { users } }))
40
40
  * .get('/users/:id', async ({ caches, params, reply }) => reply.ok(await caches.users.remember(params.id, load)));
41
41
  * ```
42
42
  */
43
- export declare function redis<const Caches extends Record<string, AnyCache> = Record<never, never>>(client: RedisClient, options?: RedisContextOptions<Caches>): import("@alxia/core").Alxia<import("@alxia/core").Empty & RedisContext<Caches>, import("@alxia/core").Empty, "", never>;
43
+ export declare function redis<const Caches extends Record<string, AnyCache> = Record<never, never>>(client: RedisClient, options?: RedisContextOptions<Caches>): import("@alxia/core").Alxia<import("@alxia/core").Empty & RedisContext<Caches>, "", never>;
44
44
  //# sourceMappingURL=context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AACA,OAAO,EACN,KAAK,UAAU,EAEf,KAAK,eAAe,EACpB,KAAK,WAAW,EAEhB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,KAAK,CAAC;AACvC,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B;;;;;;;;;;GAUG;AACH,MAAM,MAAM,QAAQ,GAAG,eAAe,CAAC,GAAG,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;AAEvD,wDAAwD;AACxD,MAAM,MAAM,WAAW,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI;IAClE,QAAQ,EAAE,IAAI,IAAI,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,eAAe,CACpE,MAAM,MAAM,EACZ,MAAM,MAAM,CACZ,GACE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,GACrD,KAAK;CACR,CAAC;AAEF,MAAM,WAAW,mBAAmB,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC3E,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,wCAAwC;AACxC,MAAM,WAAW,YAAY,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC;IACpE,mCAAmC;IACnC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACrC,gGAAgG;IAChG,IAAI,CAAC,CAAC,EACL,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EAC1B,OAAO,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,CAAC,CAAC,CAAC;CACd;AAED;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CACpB,KAAK,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EACnE,MAAM,EAAE,WAAW,EAAE,OAAO,GAAE,mBAAmB,CAAC,MAAM,CAAM,2HAa/D"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AACA,OAAO,EACN,KAAK,UAAU,EAEf,KAAK,eAAe,EACpB,KAAK,WAAW,EAEhB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,KAAK,CAAC;AACvC,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B;;;;;;;;;;GAUG;AACH,MAAM,MAAM,QAAQ,GAAG,eAAe,CAAC,GAAG,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;AAEvD,wDAAwD;AACxD,MAAM,MAAM,WAAW,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI;IAClE,QAAQ,EAAE,IAAI,IAAI,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,eAAe,CACpE,MAAM,MAAM,EACZ,MAAM,MAAM,CACZ,GACE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,GACrD,KAAK;CACR,CAAC;AAEF,MAAM,WAAW,mBAAmB,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC3E,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,wCAAwC;AACxC,MAAM,WAAW,YAAY,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC;IACpE,mCAAmC;IACnC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACrC,gGAAgG;IAChG,IAAI,CAAC,CAAC,EACL,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EAC1B,OAAO,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,CAAC,CAAC,CAAC;CACd;AAED;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CACpB,KAAK,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EACnE,MAAM,EAAE,WAAW,EAAE,OAAO,GAAE,mBAAmB,CAAC,MAAM,CAAM,8FAa/D"}
@@ -1,4 +1,4 @@
1
- import { type BaseContext } from '@alxia/core';
1
+ import { type BaseContext, type Empty, type Middleware, type MiddlewareMark, type Reply } from '@alxia/core';
2
2
  import type { RedisClient } from 'bun';
3
3
  export interface IdempotencyOptions {
4
4
  /** Names the keys it stores. */
@@ -18,7 +18,9 @@ export interface IdempotencyOptions {
18
18
  /**
19
19
  * Whose key it is: keys are scoped by the route and by this, so two
20
20
  * clients choosing the same key never see each other's response. The
21
- * client's address by default; a user id when there is one.
21
+ * client's address by default; a user id when there is one. A request
22
+ * it gives no scope for runs unguarded — nothing stored, nothing
23
+ * replayed — with a warning, once.
22
24
  */
23
25
  readonly scope?: (ctx: BaseContext) => string | undefined;
24
26
  }
@@ -29,10 +31,18 @@ export interface IdempotencyErrorBody {
29
31
  readonly retryAfter?: number;
30
32
  }
31
33
  /**
32
- * Idempotent routes, as a plugin, with `@nxgt/redis-guard`: a `POST` or
34
+ * What `idempotency()` makes: a middleware that adds nothing to the
35
+ * context, and may answer a 400, a 409 or a 422.
36
+ */
37
+ export type IdempotencyMiddleware = Middleware<Empty, Promise<Response | Reply<400, IdempotencyErrorBody> | Reply<409, IdempotencyErrorBody> | Reply<422, IdempotencyErrorBody>>> & MiddlewareMark;
38
+ /**
39
+ * Idempotent routes, as a middleware, with `@nxgt/redis-guard`: a `POST` or
33
40
  * `PATCH` carrying an `Idempotency-Key` runs once per key, and every repeat
34
41
  * gets the first response back, marked `Idempotent-Replayed: true` — across
35
- * every process sharing the Redis. Routes declared after it are guarded.
42
+ * every process sharing the Redis. Routes declared after it are guarded; a
43
+ * request no route matches is not: there is no route to scope its key by.
44
+ * What is kept is the response the route answers, an error's answer
45
+ * included.
36
46
  *
37
47
  * A repeat while the first still runs is a 409, and the same key with
38
48
  * another request — method, path or body — a 422: both are part of every
@@ -43,5 +53,5 @@ export interface IdempotencyErrorBody {
43
53
  * app.use(idempotency(redis.client, { name: 'payments' })).post('/payments', ...);
44
54
  * ```
45
55
  */
46
- export declare function idempotency(client: RedisClient, options: IdempotencyOptions): import("@alxia/core").Alxia<import("@alxia/core").Empty, import("@alxia/core").Empty, "", import("@alxia/core").Reply<400, IdempotencyErrorBody> | import("@alxia/core").Reply<409, IdempotencyErrorBody> | import("@alxia/core").Reply<422, IdempotencyErrorBody>>;
56
+ export declare function idempotency(client: RedisClient, options: IdempotencyOptions): IdempotencyMiddleware;
47
57
  //# sourceMappingURL=idempotency.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAMtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,KAAK,CAAC;AAGvC,MAAM,WAAW,kBAAkB;IAClC,gCAAgC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,0EAA0E;IAC1E,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,0GAA0G;IAC1G,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,+FAA+F;IAC/F,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,qEAAqE;IACrE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,sFAAsF;IACtF,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,KAAK,MAAM,GAAG,SAAS,CAAC;CAC1D;AAED,6BAA6B;AAC7B,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,KAAK,EACX,yBAAyB,GACzB,yBAAyB,GACzB,yBAAyB,GACzB,wBAAwB,CAAC;IAC5B,sFAAsF;IACtF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAuBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,kBAAkB,uQAsE3E"}
1
+ {"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,WAAW,EAEhB,KAAK,KAAK,EACV,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,KAAK,EAEV,MAAM,aAAa,CAAC;AAMrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,KAAK,CAAC;AAGvC,MAAM,WAAW,kBAAkB;IAClC,gCAAgC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,0EAA0E;IAC1E,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,0GAA0G;IAC1G,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,+FAA+F;IAC/F,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,qEAAqE;IACrE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,sFAAsF;IACtF,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,KAAK,MAAM,GAAG,SAAS,CAAC;CAC1D;AAED,6BAA6B;AAC7B,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,KAAK,EACX,yBAAyB,GACzB,yBAAyB,GACzB,yBAAyB,GACzB,wBAAwB,CAAC;IAC5B,sFAAsF;IACtF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,GAAG,UAAU,CAC7C,KAAK,EACL,OAAO,CACJ,QAAQ,GACR,KAAK,CAAC,GAAG,EAAE,oBAAoB,CAAC,GAChC,KAAK,CAAC,GAAG,EAAE,oBAAoB,CAAC,GAChC,KAAK,CAAC,GAAG,EAAE,oBAAoB,CAAC,CAClC,CACD,GACA,cAAc,CAAC;AAuBhB;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CAC1B,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,kBAAkB,GACzB,qBAAqB,CA2EvB"}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { type RedisCacheStoreOptions, redisCacheStore } from './cache-store';
2
2
  export { type AnyCache, type BoundCaches, type RedisContext, type RedisContextOptions, redis, } from './context';
3
- export { type IdempotencyErrorBody, type IdempotencyOptions, idempotency, } from './idempotency';
3
+ export { type IdempotencyErrorBody, type IdempotencyMiddleware, type IdempotencyOptions, idempotency, } from './idempotency';
4
4
  export { type RedisStoreOptions, redisStore } from './store';
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,sBAAsB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC7E,OAAO,EACN,KAAK,QAAQ,EACb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,GACL,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,kBAAkB,EACvB,WAAW,GACX,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,KAAK,iBAAiB,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,sBAAsB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC7E,OAAO,EACN,KAAK,QAAQ,EACb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,GACL,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,WAAW,GACX,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,KAAK,iBAAiB,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -89,7 +89,10 @@ function redis(client, options = {}) {
89
89
  return alxia().decorate(context);
90
90
  }
91
91
  // src/idempotency.ts
92
- import { alxia as alxia2 } from "@alxia/core";
92
+ import {
93
+ defineMiddleware,
94
+ settle
95
+ } from "@alxia/core";
93
96
  import {
94
97
  bindIdempotency,
95
98
  defineIdempotency,
@@ -122,13 +125,20 @@ function idempotency(client, options) {
122
125
  ...options.lease === undefined ? {} : { lease: options.lease },
123
126
  schema: Stored2
124
127
  }));
128
+ let warned = false;
129
+ const warnUnscoped = () => {
130
+ if (warned)
131
+ return;
132
+ warned = true;
133
+ console.warn(unscopedWarning(options.name));
134
+ };
125
135
  const refuse = (error, retryAfter) => {
126
136
  const body = retryAfter === undefined ? { error } : { error, retryAfter };
127
137
  return body;
128
138
  };
129
- return alxia2().wrap(async (ctx, next) => {
130
- const { request, reply } = ctx;
131
- if (!methods.has(request.method))
139
+ return defineMiddleware(async (ctx, next) => {
140
+ const { request, reply, route } = ctx;
141
+ if (route === undefined || !methods.has(request.method))
132
142
  return next();
133
143
  const key = request.headers.get(header);
134
144
  if (key === null) {
@@ -136,15 +146,15 @@ function idempotency(client, options) {
136
146
  }
137
147
  if (!KEY.test(key))
138
148
  return reply(400, refuse("idempotency_key_invalid"));
139
- const body = new Uint8Array(await request.clone().arrayBuffer());
140
- const head = new TextEncoder().encode(`${request.method} ${ctx.url.pathname}${ctx.url.search}
141
- `);
142
- const fingerprint = new Uint8Array(head.length + body.length);
143
- fingerprint.set(head);
144
- fingerprint.set(body, head.length);
145
- const id = `${ctx.route}:${scope(ctx) ?? "anyone"}:${key}`;
149
+ const scoped = scope(ctx);
150
+ if (scoped === undefined) {
151
+ warnUnscoped();
152
+ return next();
153
+ }
154
+ const id = `${route}:${scoped}:${key}`;
155
+ const fingerprint = await fingerprintOf(request, ctx.url);
146
156
  try {
147
- const { value, replayed } = await bound.run(id, async () => store(await next()), {
157
+ const { value, replayed } = await bound.run(id, async () => store(await settle(ctx, next())), {
148
158
  fingerprint,
149
159
  ...options.wait === undefined ? {} : { wait: options.wait }
150
160
  });
@@ -165,6 +175,18 @@ function idempotency(client, options) {
165
175
  }
166
176
  });
167
177
  }
178
+ function unscopedWarning(name) {
179
+ return `idempotency "${name}": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().`;
180
+ }
181
+ async function fingerprintOf(request, url) {
182
+ const body = new Uint8Array(await request.clone().arrayBuffer());
183
+ const head = new TextEncoder().encode(`${request.method} ${url.pathname}${url.search}
184
+ `);
185
+ const fingerprint = new Uint8Array(head.length + body.length);
186
+ fingerprint.set(head);
187
+ fingerprint.set(body, head.length);
188
+ return fingerprint;
189
+ }
168
190
  async function store(response) {
169
191
  if (response.status >= 500 || response.headers.get("content-type")?.startsWith("text/event-stream")) {
170
192
  throw new Unstored(response);
@@ -249,5 +271,5 @@ export {
249
271
  redisStore
250
272
  };
251
273
 
252
- //# debugId=04C8078B823357BA64756E2164756E21
274
+ //# debugId=FF8A722FABD88B9264756E2164756E21
253
275
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -3,11 +3,11 @@
3
3
  "sources": ["../src/cache-store.ts", "../src/context.ts", "../src/idempotency.ts", "../src/store.ts"],
4
4
  "sourcesContent": [
5
5
  "import type { CachedResponse, CacheStore } from '@alxia/cache';\nimport { bindCache, defineCache } from '@nxgt/redis';\nimport type { RedisClient } from 'bun';\nimport { z } from 'zod';\n\nexport interface RedisCacheStoreOptions {\n\t/** Prepended to every key it writes: one name per app or deployment. */\n\treadonly name: string;\n}\n\nconst Stored = z.object({\n\tstatus: z.number().int(),\n\theaders: z.array(z.tuple([z.string(), z.string()])),\n\tbody: z.string(),\n\tstoredAt: z.number(),\n\tttl: z.number(),\n\tstale: z.number(),\n\ttags: z.array(z.string()),\n});\n\n/** What a Redis older than 7 answers to `EXPIRE … NX`. */\nconst REFUSED_ARGUMENTS = /wrong number of arguments|syntax error/i;\n\n/**\n * An `@alxia/cache` store in Redis, on `@nxgt/redis`'s typed caches: every\n * process sharing the Redis serves what one of them kept. A record that no\n * longer reads as a response is a miss, and is dropped. Tags are Redis sets\n * of the keys they name.\n *\n * ```ts\n * app.use(cache({ ttl: 60, store: redisCacheStore(connection.client, { name: 'shop' }) }));\n * ```\n */\nexport function redisCacheStore(\n\tclient: RedisClient,\n\toptions: RedisCacheStoreOptions,\n): CacheStore {\n\t// `@nxgt/redis` keeps a record for whole seconds; the store's own `ttl`\n\t// and `stale` decide freshness to the millisecond.\n\tconst records = bindCache(\n\t\tclient,\n\t\tdefineCache({\n\t\t\tname: `${options.name}:response`,\n\t\t\tkey: (key: string) => key,\n\t\t\tttl: 60,\n\t\t\tschema: Stored,\n\t\t}),\n\t);\n\tconst tagKey = (tag: string) => `${options.name}:tag:${tag}`;\n\t/**\n\t * Keeps a tag's set as long as its longest-kept response: `NX` gives a\n\t * new set its first expiry — `GT` alone never would, a key without one\n\t * counting as kept forever — and `GT` then only ever lengthens it. A\n\t * Redis older than 7 refuses both arguments: it gets the plain `EXPIRE`,\n\t * from then on. Any other error is the caller's.\n\t */\n\tlet flagsKnown = true;\n\tconst keepTag = async (key: string, seconds: number) => {\n\t\tconst ttl = String(seconds);\n\t\tif (flagsKnown) {\n\t\t\ttry {\n\t\t\t\tawait client.send('EXPIRE', [key, ttl, 'NX']);\n\t\t\t\tawait client.send('EXPIRE', [key, ttl, 'GT']);\n\t\t\t\treturn;\n\t\t\t} catch (error) {\n\t\t\t\tif (!REFUSED_ARGUMENTS.test(String(error))) throw error;\n\t\t\t\tflagsKnown = false;\n\t\t\t}\n\t\t}\n\t\tawait client.send('EXPIRE', [key, ttl]);\n\t};\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst record = await records.get(key);\n\t\t\tif (record === undefined) return undefined;\n\t\t\tconst response: CachedResponse = {\n\t\t\t\t...record,\n\t\t\t\tbody: new Uint8Array(Buffer.from(record.body, 'base64')),\n\t\t\t};\n\t\t\treturn response;\n\t\t},\n\t\tasync set(key, value, keepFor) {\n\t\t\tconst seconds = Math.max(1, Math.ceil(keepFor / 1000));\n\t\t\tawait records.set(\n\t\t\t\tkey,\n\t\t\t\t{\n\t\t\t\t\t...value,\n\t\t\t\t\theaders: value.headers.map(\n\t\t\t\t\t\t([name, header]) => [name, header] as [string, string],\n\t\t\t\t\t),\n\t\t\t\t\ttags: [...value.tags],\n\t\t\t\t\tbody: Buffer.from(value.body).toString('base64'),\n\t\t\t\t},\n\t\t\t\t{ ttl: seconds },\n\t\t\t);\n\t\t\tawait Promise.all(\n\t\t\t\tvalue.tags.map(async (tag) => {\n\t\t\t\t\tawait client.send('SADD', [tagKey(tag), records.keyFor(key)]);\n\t\t\t\t\tawait keepTag(tagKey(tag), seconds);\n\t\t\t\t}),\n\t\t\t);\n\t\t},\n\t\tasync delete(key) {\n\t\t\tawait records.delete(key);\n\t\t},\n\t\tasync deleteTag(tag) {\n\t\t\tconst keys = (await client.send('SMEMBERS', [tagKey(tag)])) as string[];\n\t\t\tif (keys.length > 0) await client.send('DEL', keys);\n\t\t\tawait client.send('DEL', [tagKey(tag)]);\n\t\t},\n\t};\n}\n",
6
- "import { alxia } from '@alxia/core';\nimport {\n\ttype BoundCache,\n\tbindCache,\n\ttype CacheDefinition,\n\ttype LockOptions,\n\twithLock,\n} from '@nxgt/redis';\nimport type { RedisClient } from 'bun';\nimport type { z } from 'zod';\n\n/**\n * Any `@nxgt/redis` cache definition, whatever its key parameters and\n * schema: what `redis()`'s `caches` hold. The constraint a function generic\n * over the caches it hands to `redis()` takes:\n *\n * ```ts\n * function withCaches<const Caches extends Record<string, AnyCache>>(caches: Caches) {\n * return alxia().use(redis(client, { caches }));\n * }\n * ```\n */\nexport type AnyCache = CacheDefinition<any, z.ZodType>;\n\n/** The caches of `Caches`, each bound to the client. */\nexport type BoundCaches<Caches extends Record<string, AnyCache>> = {\n\treadonly [Name in keyof Caches]: Caches[Name] extends CacheDefinition<\n\t\tinfer Params,\n\t\tinfer Schema\n\t>\n\t\t? BoundCache<Params, z.output<Schema>, z.input<Schema>>\n\t\t: never;\n};\n\nexport interface RedisContextOptions<Caches extends Record<string, AnyCache>> {\n\t/** `@nxgt/redis` cache definitions, by the name routes read them under. */\n\treadonly caches?: Caches;\n}\n\n/** What routes after `redis()` read. */\nexport interface RedisContext<Caches extends Record<string, AnyCache>> {\n\t/** Bun's own client, untouched. */\n\treadonly redis: RedisClient;\n\t/** Each cache, bound and typed by its schema: `caches.users.remember(…)`. */\n\treadonly caches: BoundCaches<Caches>;\n\t/** `work` under a lock every process sharing the Redis respects: `@nxgt/redis`'s `withLock`. */\n\tlock<T>(\n\t\tkey: string,\n\t\twork: () => Promise<T> | T,\n\t\toptions?: LockOptions,\n\t): Promise<T>;\n}\n\n/**\n * Redis in the context, as a plugin: the client, the caches bound once, and\n * a lock — typed, for every route declared after it.\n *\n * ```ts\n * const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: User });\n * app.use(redis(connection.client, { caches: { users } }))\n * .get('/users/:id', async ({ caches, params, reply }) => reply.ok(await caches.users.remember(params.id, load)));\n * ```\n */\nexport function redis<\n\tconst Caches extends Record<string, AnyCache> = Record<never, never>,\n>(client: RedisClient, options: RedisContextOptions<Caches> = {}) {\n\tconst caches = Object.fromEntries(\n\t\tObject.entries(options.caches ?? {}).map(([name, definition]) => [\n\t\t\tname,\n\t\t\tbindCache(client, definition),\n\t\t]),\n\t) as BoundCaches<Caches>;\n\tconst context: RedisContext<Caches> = {\n\t\tredis: client,\n\t\tcaches,\n\t\tlock: (key, work, lockOptions) => withLock(client, key, work, lockOptions),\n\t};\n\treturn alxia().decorate(context);\n}\n",
7
- "import { alxia, type BaseContext } from '@alxia/core';\nimport {\n\tbindIdempotency,\n\tdefineIdempotency,\n\tGuardError,\n} from '@nxgt/redis-guard';\nimport type { RedisClient } from 'bun';\nimport { z } from 'zod';\n\nexport interface IdempotencyOptions {\n\t/** Names the keys it stores. */\n\treadonly name: string;\n\t/** Seconds a finished response is kept and replayed. A day by default. */\n\treadonly ttl?: number;\n\t/** Milliseconds a running request holds its key unless renewed. `@nxgt/redis-guard`'s 10 s by default. */\n\treadonly lease?: number;\n\t/** Milliseconds a repeat waits for the first to finish before a 409. None by default. */\n\treadonly wait?: number;\n\t/** The methods it guards. `POST` and `PATCH` by default: the others are idempotent already. */\n\treadonly methods?: readonly string[];\n\t/** The header the key is read from. `Idempotency-Key` by default. */\n\treadonly header?: string;\n\t/** Whether a guarded request without a key is refused, with a 400. Off by default. */\n\treadonly required?: boolean;\n\t/**\n\t * Whose key it is: keys are scoped by the route and by this, so two\n\t * clients choosing the same key never see each other's response. The\n\t * client's address by default; a user id when there is one.\n\t */\n\treadonly scope?: (ctx: BaseContext) => string | undefined;\n}\n\n/** The body of a refusal. */\nexport interface IdempotencyErrorBody {\n\treadonly error:\n\t\t| 'idempotency_key_missing'\n\t\t| 'idempotency_key_invalid'\n\t\t| 'idempotency_in_progress'\n\t\t| 'idempotency_key_reused';\n\t/** Seconds until a running request should be over: with `idempotency_in_progress`. */\n\treadonly retryAfter?: number;\n}\n\nconst Stored = z.object({\n\tstatus: z.number().int(),\n\theaders: z.array(z.tuple([z.string(), z.string()])),\n\tbody: z.string(),\n});\ntype Stored = z.infer<typeof Stored>;\n\n/** Headers a replay never repeats: a session cookie belongs to one response. */\nconst UNSTORED_HEADERS = new Set(['set-cookie', 'date', 'content-length']);\n\nconst KEY = /^[\\x21-\\x7e]{1,255}$/;\n\n/** A response that is answered and not kept: a 5xx, a stream. */\nclass Unstored extends Error {\n\treadonly response: Response;\n\tconstructor(response: Response) {\n\t\tsuper('unstored');\n\t\tthis.response = response;\n\t}\n}\n\n/**\n * Idempotent routes, as a plugin, with `@nxgt/redis-guard`: a `POST` or\n * `PATCH` carrying an `Idempotency-Key` runs once per key, and every repeat\n * gets the first response back, marked `Idempotent-Replayed: true` — across\n * every process sharing the Redis. Routes declared after it are guarded.\n *\n * A repeat while the first still runs is a 409, and the same key with\n * another request — method, path or body — a 422: both are part of every\n * guarded route's type. A 5xx, or a stream, is answered and not kept: the\n * key is free again.\n *\n * ```ts\n * app.use(idempotency(redis.client, { name: 'payments' })).post('/payments', ...);\n * ```\n */\nexport function idempotency(client: RedisClient, options: IdempotencyOptions) {\n\tconst methods = new Set(options.methods ?? ['POST', 'PATCH']);\n\tconst header = options.header ?? 'idempotency-key';\n\tconst scope = options.scope ?? ((ctx: BaseContext) => ctx.ip);\n\tconst bound = bindIdempotency(\n\t\tclient,\n\t\tdefineIdempotency({\n\t\t\tname: options.name,\n\t\t\tkey: (key: string) => key,\n\t\t\tttl: options.ttl ?? 86_400,\n\t\t\t...(options.lease === undefined ? {} : { lease: options.lease }),\n\t\t\tschema: Stored,\n\t\t}),\n\t);\n\tconst refuse = (\n\t\terror: IdempotencyErrorBody['error'],\n\t\tretryAfter?: number,\n\t) => {\n\t\tconst body: IdempotencyErrorBody =\n\t\t\tretryAfter === undefined ? { error } : { error, retryAfter };\n\t\treturn body;\n\t};\n\n\treturn alxia().wrap(async (ctx, next) => {\n\t\tconst { request, reply } = ctx;\n\t\tif (!methods.has(request.method)) return next();\n\t\tconst key = request.headers.get(header);\n\t\tif (key === null) {\n\t\t\treturn options.required\n\t\t\t\t? reply(400, refuse('idempotency_key_missing'))\n\t\t\t\t: next();\n\t\t}\n\t\tif (!KEY.test(key)) return reply(400, refuse('idempotency_key_invalid'));\n\n\t\tconst body = new Uint8Array(await request.clone().arrayBuffer());\n\t\tconst head = new TextEncoder().encode(\n\t\t\t`${request.method} ${ctx.url.pathname}${ctx.url.search}\\n`,\n\t\t);\n\t\tconst fingerprint = new Uint8Array(head.length + body.length);\n\t\tfingerprint.set(head);\n\t\tfingerprint.set(body, head.length);\n\t\tconst id = `${ctx.route}:${scope(ctx) ?? 'anyone'}:${key}`;\n\n\t\ttry {\n\t\t\tconst { value, replayed } = await bound.run(\n\t\t\t\tid,\n\t\t\t\tasync () => store(await next()),\n\t\t\t\t{\n\t\t\t\t\tfingerprint,\n\t\t\t\t\t...(options.wait === undefined ? {} : { wait: options.wait }),\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn restore(value, replayed);\n\t\t} catch (error) {\n\t\t\tif (error instanceof Unstored) return error.response;\n\t\t\tif (error instanceof GuardError && error.code === 'IN_PROGRESS') {\n\t\t\t\tconst retryAfter = Math.max(\n\t\t\t\t\t1,\n\t\t\t\t\tMath.ceil((error.retryAfter ?? 0) / 1000),\n\t\t\t\t);\n\t\t\t\treturn reply(409, refuse('idempotency_in_progress', retryAfter), {\n\t\t\t\t\theaders: { 'retry-after': String(retryAfter) },\n\t\t\t\t});\n\t\t\t}\n\t\t\tif (error instanceof GuardError && error.code === 'MISMATCH') {\n\t\t\t\treturn reply(422, refuse('idempotency_key_reused'));\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t});\n}\n\nasync function store(response: Response): Promise<Stored> {\n\tif (\n\t\tresponse.status >= 500 ||\n\t\tresponse.headers.get('content-type')?.startsWith('text/event-stream')\n\t) {\n\t\tthrow new Unstored(response);\n\t}\n\tconst headers: [string, string][] = [];\n\tfor (const [name, value] of response.headers) {\n\t\tif (!UNSTORED_HEADERS.has(name)) headers.push([name, value]);\n\t}\n\tconst bytes = new Uint8Array(await response.arrayBuffer());\n\treturn {\n\t\tstatus: response.status,\n\t\theaders,\n\t\tbody: Buffer.from(bytes).toString('base64'),\n\t};\n}\n\nfunction restore(stored: Stored, replayed: boolean): Response {\n\tconst headers = new Headers(stored.headers);\n\tif (replayed) headers.set('idempotent-replayed', 'true');\n\tconst body = Buffer.from(stored.body, 'base64');\n\treturn new Response(\n\t\tstored.status === 204 || stored.status === 304 ? null : body,\n\t\t{ status: stored.status, headers },\n\t);\n}\n",
6
+ "import { alxia } from '@alxia/core';\nimport {\n\ttype BoundCache,\n\tbindCache,\n\ttype CacheDefinition,\n\ttype LockOptions,\n\twithLock,\n} from '@nxgt/redis';\nimport type { RedisClient } from 'bun';\nimport type { z } from 'zod';\n\n/**\n * Any `@nxgt/redis` cache definition, whatever its key parameters and\n * schema: what `redis()`'s `caches` hold. The constraint a function generic\n * over the caches it hands to `redis()` takes:\n *\n * ```ts\n * function withCaches<const Caches extends Record<string, AnyCache>>(caches: Caches) {\n * return alxia().plugin(redis(client, { caches }));\n * }\n * ```\n */\nexport type AnyCache = CacheDefinition<any, z.ZodType>;\n\n/** The caches of `Caches`, each bound to the client. */\nexport type BoundCaches<Caches extends Record<string, AnyCache>> = {\n\treadonly [Name in keyof Caches]: Caches[Name] extends CacheDefinition<\n\t\tinfer Params,\n\t\tinfer Schema\n\t>\n\t\t? BoundCache<Params, z.output<Schema>, z.input<Schema>>\n\t\t: never;\n};\n\nexport interface RedisContextOptions<Caches extends Record<string, AnyCache>> {\n\t/** `@nxgt/redis` cache definitions, by the name routes read them under. */\n\treadonly caches?: Caches;\n}\n\n/** What routes after `redis()` read. */\nexport interface RedisContext<Caches extends Record<string, AnyCache>> {\n\t/** Bun's own client, untouched. */\n\treadonly redis: RedisClient;\n\t/** Each cache, bound and typed by its schema: `caches.users.remember(…)`. */\n\treadonly caches: BoundCaches<Caches>;\n\t/** `work` under a lock every process sharing the Redis respects: `@nxgt/redis`'s `withLock`. */\n\tlock<T>(\n\t\tkey: string,\n\t\twork: () => Promise<T> | T,\n\t\toptions?: LockOptions,\n\t): Promise<T>;\n}\n\n/**\n * Redis in the context, as a plugin: the client, the caches bound once, and\n * a lock — typed, for every route declared after it.\n *\n * ```ts\n * const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: User });\n * app.plugin(redis(connection.client, { caches: { users } }))\n * .get('/users/:id', async ({ caches, params, reply }) => reply.ok(await caches.users.remember(params.id, load)));\n * ```\n */\nexport function redis<\n\tconst Caches extends Record<string, AnyCache> = Record<never, never>,\n>(client: RedisClient, options: RedisContextOptions<Caches> = {}) {\n\tconst caches = Object.fromEntries(\n\t\tObject.entries(options.caches ?? {}).map(([name, definition]) => [\n\t\t\tname,\n\t\t\tbindCache(client, definition),\n\t\t]),\n\t) as BoundCaches<Caches>;\n\tconst context: RedisContext<Caches> = {\n\t\tredis: client,\n\t\tcaches,\n\t\tlock: (key, work, lockOptions) => withLock(client, key, work, lockOptions),\n\t};\n\treturn alxia().decorate(context);\n}\n",
7
+ "import {\n\ttype BaseContext,\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\ttype MiddlewareMark,\n\ttype Reply,\n\tsettle,\n} from '@alxia/core';\nimport {\n\tbindIdempotency,\n\tdefineIdempotency,\n\tGuardError,\n} from '@nxgt/redis-guard';\nimport type { RedisClient } from 'bun';\nimport { z } from 'zod';\n\nexport interface IdempotencyOptions {\n\t/** Names the keys it stores. */\n\treadonly name: string;\n\t/** Seconds a finished response is kept and replayed. A day by default. */\n\treadonly ttl?: number;\n\t/** Milliseconds a running request holds its key unless renewed. `@nxgt/redis-guard`'s 10 s by default. */\n\treadonly lease?: number;\n\t/** Milliseconds a repeat waits for the first to finish before a 409. None by default. */\n\treadonly wait?: number;\n\t/** The methods it guards. `POST` and `PATCH` by default: the others are idempotent already. */\n\treadonly methods?: readonly string[];\n\t/** The header the key is read from. `Idempotency-Key` by default. */\n\treadonly header?: string;\n\t/** Whether a guarded request without a key is refused, with a 400. Off by default. */\n\treadonly required?: boolean;\n\t/**\n\t * Whose key it is: keys are scoped by the route and by this, so two\n\t * clients choosing the same key never see each other's response. The\n\t * client's address by default; a user id when there is one. A request\n\t * it gives no scope for runs unguarded — nothing stored, nothing\n\t * replayed — with a warning, once.\n\t */\n\treadonly scope?: (ctx: BaseContext) => string | undefined;\n}\n\n/** The body of a refusal. */\nexport interface IdempotencyErrorBody {\n\treadonly error:\n\t\t| 'idempotency_key_missing'\n\t\t| 'idempotency_key_invalid'\n\t\t| 'idempotency_in_progress'\n\t\t| 'idempotency_key_reused';\n\t/** Seconds until a running request should be over: with `idempotency_in_progress`. */\n\treadonly retryAfter?: number;\n}\n\n/**\n * What `idempotency()` makes: a middleware that adds nothing to the\n * context, and may answer a 400, a 409 or a 422.\n */\nexport type IdempotencyMiddleware = Middleware<\n\tEmpty,\n\tPromise<\n\t\t| Response\n\t\t| Reply<400, IdempotencyErrorBody>\n\t\t| Reply<409, IdempotencyErrorBody>\n\t\t| Reply<422, IdempotencyErrorBody>\n\t>\n> &\n\tMiddlewareMark;\n\nconst Stored = z.object({\n\tstatus: z.number().int(),\n\theaders: z.array(z.tuple([z.string(), z.string()])),\n\tbody: z.string(),\n});\ntype Stored = z.infer<typeof Stored>;\n\n/** Headers a replay never repeats: a session cookie belongs to one response. */\nconst UNSTORED_HEADERS = new Set(['set-cookie', 'date', 'content-length']);\n\nconst KEY = /^[\\x21-\\x7e]{1,255}$/;\n\n/** A response that is answered and not kept: a 5xx, a stream. */\nclass Unstored extends Error {\n\treadonly response: Response;\n\tconstructor(response: Response) {\n\t\tsuper('unstored');\n\t\tthis.response = response;\n\t}\n}\n\n/**\n * Idempotent routes, as a middleware, with `@nxgt/redis-guard`: a `POST` or\n * `PATCH` carrying an `Idempotency-Key` runs once per key, and every repeat\n * gets the first response back, marked `Idempotent-Replayed: true` — across\n * every process sharing the Redis. Routes declared after it are guarded; a\n * request no route matches is not: there is no route to scope its key by.\n * What is kept is the response the route answers, an error's answer\n * included.\n *\n * A repeat while the first still runs is a 409, and the same key with\n * another request — method, path or body — a 422: both are part of every\n * guarded route's type. A 5xx, or a stream, is answered and not kept: the\n * key is free again.\n *\n * ```ts\n * app.use(idempotency(redis.client, { name: 'payments' })).post('/payments', ...);\n * ```\n */\nexport function idempotency(\n\tclient: RedisClient,\n\toptions: IdempotencyOptions,\n): IdempotencyMiddleware {\n\tconst methods = new Set(options.methods ?? ['POST', 'PATCH']);\n\tconst header = options.header ?? 'idempotency-key';\n\tconst scope = options.scope ?? ((ctx: BaseContext) => ctx.ip);\n\tconst bound = bindIdempotency(\n\t\tclient,\n\t\tdefineIdempotency({\n\t\t\tname: options.name,\n\t\t\tkey: (key: string) => key,\n\t\t\tttl: options.ttl ?? 86_400,\n\t\t\t...(options.lease === undefined ? {} : { lease: options.lease }),\n\t\t\tschema: Stored,\n\t\t}),\n\t);\n\tlet warned = false;\n\tconst warnUnscoped = () => {\n\t\tif (warned) return;\n\t\twarned = true;\n\t\tconsole.warn(unscopedWarning(options.name));\n\t};\n\tconst refuse = (\n\t\terror: IdempotencyErrorBody['error'],\n\t\tretryAfter?: number,\n\t) => {\n\t\tconst body: IdempotencyErrorBody =\n\t\t\tretryAfter === undefined ? { error } : { error, retryAfter };\n\t\treturn body;\n\t};\n\n\treturn defineMiddleware(async (ctx, next) => {\n\t\tconst { request, reply, route } = ctx;\n\t\tif (route === undefined || !methods.has(request.method)) return next();\n\t\tconst key = request.headers.get(header);\n\t\tif (key === null) {\n\t\t\treturn options.required\n\t\t\t\t? reply(400, refuse('idempotency_key_missing'))\n\t\t\t\t: next();\n\t\t}\n\t\tif (!KEY.test(key)) return reply(400, refuse('idempotency_key_invalid'));\n\n\t\tconst scoped = scope(ctx);\n\t\tif (scoped === undefined) {\n\t\t\twarnUnscoped();\n\t\t\treturn next();\n\t\t}\n\t\tconst id = `${route}:${scoped}:${key}`;\n\t\tconst fingerprint = await fingerprintOf(request, ctx.url);\n\n\t\ttry {\n\t\t\tconst { value, replayed } = await bound.run(\n\t\t\t\tid,\n\t\t\t\tasync () => store(await settle(ctx, next())),\n\t\t\t\t{\n\t\t\t\t\tfingerprint,\n\t\t\t\t\t...(options.wait === undefined ? {} : { wait: options.wait }),\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn restore(value, replayed);\n\t\t} catch (error) {\n\t\t\tif (error instanceof Unstored) return error.response;\n\t\t\tif (error instanceof GuardError && error.code === 'IN_PROGRESS') {\n\t\t\t\tconst retryAfter = Math.max(\n\t\t\t\t\t1,\n\t\t\t\t\tMath.ceil((error.retryAfter ?? 0) / 1000),\n\t\t\t\t);\n\t\t\t\treturn reply(409, refuse('idempotency_in_progress', retryAfter), {\n\t\t\t\t\theaders: { 'retry-after': String(retryAfter) },\n\t\t\t\t});\n\t\t\t}\n\t\t\tif (error instanceof GuardError && error.code === 'MISMATCH') {\n\t\t\t\treturn reply(422, refuse('idempotency_key_reused'));\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t});\n}\n\n/** The warning a request with no scope prints, once per middleware. */\nfunction unscopedWarning(name: string): string {\n\treturn `idempotency \"${name}\": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().`;\n}\n\n/** What a key is bound to: the method, the path and query, the body. */\nasync function fingerprintOf(request: Request, url: URL): Promise<Uint8Array> {\n\tconst body = new Uint8Array(await request.clone().arrayBuffer());\n\tconst head = new TextEncoder().encode(\n\t\t`${request.method} ${url.pathname}${url.search}\\n`,\n\t);\n\tconst fingerprint = new Uint8Array(head.length + body.length);\n\tfingerprint.set(head);\n\tfingerprint.set(body, head.length);\n\treturn fingerprint;\n}\n\nasync function store(response: Response): Promise<Stored> {\n\tif (\n\t\tresponse.status >= 500 ||\n\t\tresponse.headers.get('content-type')?.startsWith('text/event-stream')\n\t) {\n\t\tthrow new Unstored(response);\n\t}\n\tconst headers: [string, string][] = [];\n\tfor (const [name, value] of response.headers) {\n\t\tif (!UNSTORED_HEADERS.has(name)) headers.push([name, value]);\n\t}\n\tconst bytes = new Uint8Array(await response.arrayBuffer());\n\treturn {\n\t\tstatus: response.status,\n\t\theaders,\n\t\tbody: Buffer.from(bytes).toString('base64'),\n\t};\n}\n\nfunction restore(stored: Stored, replayed: boolean): Response {\n\tconst headers = new Headers(stored.headers);\n\tif (replayed) headers.set('idempotent-replayed', 'true');\n\tconst body = Buffer.from(stored.body, 'base64');\n\treturn new Response(\n\t\tstored.status === 204 || stored.status === 304 ? null : body,\n\t\t{ status: stored.status, headers },\n\t);\n}\n",
8
8
  "import type { Decision, Policy, RateLimitStore } from '@alxia/rate-limit';\nimport {\n\ttype BoundRateLimit,\n\tbindRateLimit,\n\tdefineRateLimit,\n} from '@nxgt/redis-guard';\nimport type { RedisClient } from 'bun';\n\nexport interface RedisStoreOptions {\n\t/** Prepended to every key it counts: one name per limit, so two never share a count. */\n\treadonly name: string;\n}\n\n/**\n * An `@alxia/rate-limit` store in Redis, with `@nxgt/redis-guard`'s GCRA:\n * every process sharing the Redis counts together, timed by the Redis\n * server's clock, and a refused request counts nothing.\n *\n * ```ts\n * app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis.client, { name: 'api' }) }));\n * ```\n */\nexport function redisStore(\n\tclient: RedisClient,\n\toptions: RedisStoreOptions,\n): RateLimitStore {\n\tconst limits = new Map<string, Promise<BoundRateLimit<string>>>();\n\t// Every policy counted under this name, by any process: what `reset` forgets.\n\tconst policies = `${options.name}:policies`;\n\tconst bind = (limit: number, windowMs: number) =>\n\t\tbindRateLimit(\n\t\t\tclient,\n\t\t\tdefineRateLimit({\n\t\t\t\tname: `${options.name}:${limit}/${windowMs}`,\n\t\t\t\tkey: (key: string) => key,\n\t\t\t\tlimit,\n\t\t\t\tper: windowMs,\n\t\t\t}),\n\t\t);\n\t/** The bound limit of a policy, recorded in `policies` the first time this process counts under it. */\n\tconst limitFor = (policy: Policy) => {\n\t\tconst id = `${policy.limit}/${policy.windowMs}`;\n\t\tlet bound = limits.get(id);\n\t\tif (bound === undefined) {\n\t\t\tconst limit = bind(policy.limit, policy.windowMs);\n\t\t\tconst recorded = client.send('SADD', [policies, id]).then(() => limit);\n\t\t\trecorded.catch(() => {\n\t\t\t\tif (limits.get(id) === recorded) limits.delete(id);\n\t\t\t});\n\t\t\tlimits.set(id, recorded);\n\t\t\tbound = recorded;\n\t\t}\n\t\treturn bound;\n\t};\n\treturn {\n\t\tasync consume(key, policy): Promise<Decision> {\n\t\t\tconst result = await (await limitFor(policy)).consume(key);\n\t\t\treturn {\n\t\t\t\tallowed: result.allowed,\n\t\t\t\tremaining: result.remaining,\n\t\t\t\tresetAfter: result.resetAfter,\n\t\t\t\tretryAfter: result.retryAfter,\n\t\t\t};\n\t\t},\n\t\tasync reset(key) {\n\t\t\tconst ids = (await client.send('SMEMBERS', [policies])) as string[];\n\t\t\tawait Promise.all(\n\t\t\t\tids.map(async (id) => {\n\t\t\t\t\t// Only what `limitFor` writes: anything else in the set is not ours.\n\t\t\t\t\tconst policy = /^(\\d+)\\/(\\d+)$/.exec(id);\n\t\t\t\t\tif (policy === null) return;\n\t\t\t\t\tconst limit = Number(policy[1]);\n\t\t\t\t\tconst windowMs = Number(policy[2]);\n\t\t\t\t\tif (limit < 1 || windowMs < 1) return;\n\t\t\t\t\tawait bind(limit, windowMs).reset(key);\n\t\t\t\t}),\n\t\t\t);\n\t\t},\n\t};\n}\n"
9
9
  ],
10
- "mappings": "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",
11
- "debugId": "04C8078B823357BA64756E2164756E21",
10
+ "mappings": "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",
11
+ "debugId": "FF8A722FABD88B9264756E2164756E21",
12
12
  "names": []
13
13
  }
package/docs/README.md CHANGED
@@ -8,11 +8,11 @@ a realistic example for each.
8
8
 
9
9
  | Page | Read it when |
10
10
  | --- | --- |
11
- | [Connecting](guide/connecting.md) | opening the client every export takes, deciding when to build the plugins, failing fast when Redis is down, closing it, or naming keys so features never share them |
11
+ | [Connecting](guide/connecting.md) | opening the client every export takes, deciding when to build what takes it, failing fast when Redis is down, closing it, or naming keys so features never share them |
12
12
  | [Rate limits](guide/rate-limits.md) | sharing an `@alxia/rate-limit` count across processes, understanding why GCRA lets a burst through, choosing `name`, or resetting a key |
13
13
  | [Response cache](guide/response-cache.md) | sharing `@alxia/cache` responses across processes, invalidating them everywhere, or knowing what is stored in Redis |
14
14
  | [Idempotency](guide/idempotency.md) | making a `POST` run once per `Idempotency-Key`, reading the `409`, `422` and `400`, scoping keys by user, or ordering it with a rate limit or an auth check |
15
15
  | [Caches and locks](guide/caches-and-locks.md) | reading typed caches and a lock from the context, catching `LOCK_HELD`, or using `redis()` beside `@alxia/cache` |
16
- | [Testing](guide/testing.md) | writing specs against a real Redis, giving `app.request()` a client address, or pinning the refusals in the types |
16
+ | [Testing](guide/testing.md) | writing specs against a real Redis, giving `app.request()` a client address, or checking which routes the refusals reach |
17
17
  | [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a limit, a cache or a replay does not behave as you expected |
18
18
  | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
@@ -17,7 +17,7 @@ const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, sch
17
17
  const loadUser = async (id: string) => ({ id, name: 'Ada' }); // your database
18
18
 
19
19
  const app = alxia()
20
- .use(redis(connection.client, { caches: { users } }))
20
+ .plugin(redis(connection.client, { caches: { users } }))
21
21
  .get('/users/:id', async ({ caches, params, reply }) => {
22
22
  const user = await caches.users.remember(params.id, () => loadUser(params.id)); // typed by User
23
23
  return reply.ok(user);
@@ -66,7 +66,7 @@ import type { RedisClient } from 'bun';
66
66
 
67
67
  export function withCaches<const Caches extends Record<string, AnyCache>>(client: RedisClient, caches: Caches) {
68
68
  return alxia()
69
- .use(redis(client, { caches }))
69
+ .plugin(redis(client, { caches }))
70
70
  .get('/caches', ({ caches: bound, reply }) => reply(200, Object.keys(bound)));
71
71
  }
72
72
  ```
@@ -88,7 +88,7 @@ the name you give it in `caches`:
88
88
  A realistic case, a profile read through the cache and forgotten on write:
89
89
 
90
90
  ```ts
91
- import { alxia } from '@alxia/core';
91
+ import { alxia, validate } from '@alxia/core';
92
92
  import { redis } from '@alxia/redis';
93
93
  import { connectRedis, defineCache } from '@nxgt/redis';
94
94
  import { z } from 'zod';
@@ -100,12 +100,12 @@ const profiles = defineCache({ name: 'profile', key: (id: string) => id, ttl: 60
100
100
  const table = new Map<string, z.input<typeof Profile>>([['1', { id: '1', name: 'Ada' }]]);
101
101
 
102
102
  const app = alxia()
103
- .use(redis(connection.client, { caches: { profiles } }))
103
+ .plugin(redis(connection.client, { caches: { profiles } }))
104
104
  .get('/profiles/:id', async ({ caches, params, reply }) => {
105
105
  const profile = await caches.profiles.remember(params.id, async () => table.get(params.id) ?? { id: params.id, name: '?' });
106
106
  return reply.ok(profile); // plan is filled in: 'free'
107
107
  })
108
- .put('/profiles/:id', { body: Profile.omit({ id: true }) }, async ({ caches, params, body, reply }) => {
108
+ .put('/profiles/:id', validate({ body: Profile.omit({ id: true }) }), async ({ caches, params, body, reply }) => {
109
109
  table.set(params.id, { id: params.id, ...body });
110
110
  await caches.profiles.delete(params.id); // the next read loads it again
111
111
  return reply(204, undefined);
@@ -142,7 +142,7 @@ const connection = await connectRedis(Bun.env['REDIS_URL']!);
142
142
  const sendInvoices = async () => 12; // the work that must not run twice at once
143
143
 
144
144
  const app = alxia()
145
- .use(redis(connection.client))
145
+ .plugin(redis(connection.client))
146
146
  .post('/invoices/run', async ({ lock, reply }) => {
147
147
  try {
148
148
  const sent = await lock('invoices', sendInvoices, { ttl: 60_000 });
@@ -176,7 +176,7 @@ import { connectRedis } from '@nxgt/redis';
176
176
  const connection = await connectRedis(Bun.env['REDIS_URL']!);
177
177
 
178
178
  const app = alxia()
179
- .use(redis(connection.client))
179
+ .plugin(redis(connection.client))
180
180
  .post('/articles/:id/views', async ({ redis, params, reply }) => {
181
181
  const views = await redis.incr(`views:${params.id}`);
182
182
  return reply(200, { views });
@@ -201,7 +201,7 @@ const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, sch
201
201
  const loadUser = async (id: string) => ({ id, name: 'Ada' });
202
202
 
203
203
  const app = alxia()
204
- .use(redis(connection.client, { caches: { users } }))
204
+ .plugin(redis(connection.client, { caches: { users } }))
205
205
  .use(cache({ ttl: 60 }))
206
206
  .get('/users/:id', async ({ caches, cache, params, reply }) => {
207
207
  cache.tag(`user:${params.id}`); // the response cache
@@ -1,7 +1,7 @@
1
1
  # Connecting
2
2
 
3
3
  This page covers the one thing every export of `@alxia/redis` takes first:
4
- a Bun `RedisClient`, how to open it, when to make the plugins with it, and
4
+ a Bun `RedisClient`, how to open it, when to build what takes it, and
5
5
  how to close it.
6
6
 
7
7
  ```ts
@@ -12,7 +12,7 @@ import { connectRedis } from '@nxgt/redis';
12
12
  const connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
13
13
 
14
14
  const app = alxia()
15
- .use(redis(connection.client))
15
+ .plugin(redis(connection.client))
16
16
  .get('/ping', async ({ redis, reply }) => reply(200, await redis.ping()));
17
17
 
18
18
  app.listen({ port: 3000 });
@@ -25,8 +25,8 @@ Every export takes Bun's own `RedisClient` as its first argument:
25
25
  ```ts
26
26
  redisStore(client: RedisClient, options: RedisStoreOptions): RateLimitStore
27
27
  redisCacheStore(client: RedisClient, options: RedisCacheStoreOptions): CacheStore
28
- idempotency(client: RedisClient, options: IdempotencyOptions) // a plugin
29
- redis(client: RedisClient, options?: RedisContextOptions) // a plugin
28
+ idempotency(client: RedisClient, options: IdempotencyOptions) // a middleware
29
+ redis(client: RedisClient, options?: RedisContextOptions) // a plugin, given to app.plugin
30
30
  ```
31
31
 
32
32
  Any `RedisClient` will do. `connectRedis` from
@@ -48,7 +48,7 @@ One client is enough for the whole app: the rate-limit store, the cache
48
48
  store, idempotency and `redis()` all send ordinary commands over it. None
49
49
  of them subscribes, so none needs a connection of its own.
50
50
 
51
- ## Make the plugins after you connect
51
+ ## Build them after you connect
52
52
 
53
53
  Each export binds the client it is given when it is called. Open the
54
54
  connection first, then build the app:
@@ -1,13 +1,13 @@
1
1
  # Idempotency
2
2
 
3
- This page covers `idempotency`: a plugin that runs a `POST` or `PATCH`
3
+ This page covers `idempotency`: a middleware, given to `app.use`, that runs a `POST` or `PATCH`
4
4
  once per `Idempotency-Key`, replays its response to every repeat, and
5
5
  refuses the repeats it cannot answer — across every process sharing a
6
6
  Redis.
7
7
 
8
8
  ```ts
9
- import { alxia } from '@alxia/core';
10
- import { idempotency } from '@alxia/redis';
9
+ import { alxia, validate } from '@alxia/core';
10
+ import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
11
11
  import { connectRedis } from '@nxgt/redis';
12
12
  import { z } from 'zod';
13
13
 
@@ -16,7 +16,7 @@ const Payment = z.object({ amount: z.number().int().positive() });
16
16
 
17
17
  const app = alxia()
18
18
  .use(idempotency(connection.client, { name: 'payments' }))
19
- .post('/payments', { body: Payment }, ({ body, reply }) =>
19
+ .post('/payments', validate({ body: Payment }), ({ body, reply }) =>
20
20
  reply(201, { id: crypto.randomUUID(), amount: body.amount }),
21
21
  );
22
22
  ```
@@ -28,12 +28,14 @@ curl -X POST localhost:3000/payments -H 'idempotency-key: 4f1c' -H 'content-type
28
28
  # 201 {"id":"9a…","amount":10} Idempotent-Replayed: true — the route did not run
29
29
  ```
30
30
 
31
- Only the routes declared **after** `use(idempotency(…))` are guarded.
31
+ Only the routes declared **after** `use(idempotency(…))` are guarded. A request
32
+ no route matches is not: there is no route to scope its key by, so it passes
33
+ to the 404.
32
34
 
33
35
  ## The signature
34
36
 
35
37
  ```ts
36
- function idempotency(client: RedisClient, options: IdempotencyOptions); // a plugin
38
+ function idempotency(client: RedisClient, options: IdempotencyOptions); // a middleware
37
39
 
38
40
  interface IdempotencyOptions {
39
41
  readonly name: string;
@@ -56,7 +58,7 @@ interface IdempotencyOptions {
56
58
  | `methods` | `readonly string[]` | `['POST', 'PATCH']` | the methods it guards; the others pass through |
57
59
  | `header` | `string` | `'idempotency-key'` | the request header the key is read from (any case) |
58
60
  | `required` | `boolean` | `false` | refuse a guarded request with no key, with a `400` |
59
- | `scope` | `(ctx: BaseContext) => string \| undefined` | `ctx.ip` | whose key it is; `undefined` scopes it to everyone |
61
+ | `scope` | `(ctx: BaseContext) => string \| undefined` | `ctx.ip` | whose key it is; `undefined` leaves the request unguarded, with a warning |
60
62
 
61
63
  `name`, `ttl` and `lease` are checked when `idempotency(…)` is called, so a
62
64
  wrong one throws at startup:
@@ -84,8 +86,9 @@ guarded request a `500`, logging
84
86
  | no key, without `required` | the route runs, unguarded |
85
87
  | a method not in `methods` | the route runs, unguarded |
86
88
 
87
- Every refusal is typed as `IdempotencyErrorBody` and is part of each
88
- guarded route's type, so `@alxia/client` reads it:
89
+ Every refusal's body is an `IdempotencyErrorBody`; declare the statuses in
90
+ your OpenAPI document, and the client you generate from it (with
91
+ `@nxgt/openapi-codegen`, say) reads them typed:
89
92
 
90
93
  ```ts
91
94
  interface IdempotencyErrorBody {
@@ -100,9 +103,8 @@ interface IdempotencyErrorBody {
100
103
  ```
101
104
 
102
105
  ```ts
103
- import { client } from '@alxia/client';
104
- import { alxia } from '@alxia/core';
105
- import { idempotency } from '@alxia/redis';
106
+ import { alxia, validate } from '@alxia/core';
107
+ import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
106
108
  import { connectRedis } from '@nxgt/redis';
107
109
  import { z } from 'zod';
108
110
 
@@ -110,18 +112,19 @@ const connection = await connectRedis(Bun.env['REDIS_URL']!);
110
112
 
111
113
  const app = alxia()
112
114
  .use(idempotency(connection.client, { name: 'payments', required: true }))
113
- .post('/payments', { body: z.object({ amount: z.number() }) }, ({ body, reply }) =>
115
+ .post('/payments', validate({ body: z.object({ amount: z.number() }) }), ({ body, reply }) =>
114
116
  reply(201, { id: crypto.randomUUID(), amount: body.amount }),
115
117
  );
116
118
 
117
- const api = client(app);
118
- const result = await api.post('/payments', {
119
- body: { amount: 10 },
120
- init: { headers: { 'idempotency-key': crypto.randomUUID() } },
119
+ const result = await app.request('/payments', {
120
+ method: 'POST',
121
+ headers: { 'content-type': 'application/json', 'idempotency-key': crypto.randomUUID() },
122
+ body: JSON.stringify({ amount: 10 }),
121
123
  });
122
- // result.status: 201 | 400 | 409 | 422 | 500
124
+ // result.status: 201, or 400, 409, 422 from the guard, or 500
123
125
  if (result.status === 409) {
124
- await Bun.sleep(result.data.retryAfter! * 1000); // then send the same request again
126
+ const { retryAfter } = (await result.json()) as IdempotencyErrorBody;
127
+ await Bun.sleep(retryAfter! * 1000); // then send the same request again
125
128
  }
126
129
  ```
127
130
 
@@ -150,7 +153,7 @@ gets the replay rather than the `409`:
150
153
 
151
154
  ```ts
152
155
  import { alxia } from '@alxia/core';
153
- import { idempotency } from '@alxia/redis';
156
+ import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
154
157
  import { connectRedis } from '@nxgt/redis';
155
158
 
156
159
  const connection = await connectRedis(Bun.env['REDIS_URL']!);
@@ -165,15 +168,22 @@ const app = alxia()
165
168
  Clients choose their keys, so two clients can choose the same one. The
166
169
  stored key is `<name>:<route>:<scope>:<key>`, where `scope(ctx)` is the
167
170
  client's address by default. When it is `undefined` — no address, as with
168
- `app.request()` in a test or a server that cannot see one — the scope is
169
- `anyone`, and every client shares the key space.
171
+ `app.request()` in a test or a server that cannot see one, and no `scope`
172
+ option, or one that returns `undefined` — the request runs unguarded:
173
+ nothing is stored, nothing replayed, and each repeat runs the route again.
174
+ Sharing one key space between every client would replay one client's
175
+ response to another. The middleware warns once:
176
+
177
+ ```
178
+ idempotency "payments": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().
179
+ ```
170
180
 
171
181
  Behind a proxy, the address is the proxy's unless the app's `ip` option
172
182
  reads the forwarded header. Where requests carry a user, scope by the user:
173
183
 
174
184
  ```ts
175
185
  import { alxia } from '@alxia/core';
176
- import { idempotency } from '@alxia/redis';
186
+ import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
177
187
  import { connectRedis } from '@nxgt/redis';
178
188
 
179
189
  const connection = await connectRedis(Bun.env['REDIS_URL']!);
@@ -197,9 +207,11 @@ is two keys.
197
207
 
198
208
  ## Order: what runs inside the guard
199
209
 
200
- The plugin wraps the routes declared after it, and every route hook
210
+ The middleware wraps the routes declared after it, and every middleware
201
211
  declared after it too. Whatever those answer is kept like the route's
202
- answer. A rate limit or an authentication check declared **after**
212
+ answer: so is what an `onError` hook, an `HttpError` or a validation
213
+ refusal answers, because `idempotency` settles the rest of the request before
214
+ it keeps it. A rate limit or an authentication check declared **after**
203
215
  `idempotency` has its `429` or `401` kept and replayed — even once the
204
216
  client is allowed through. Declare them **before**:
205
217
 
@@ -222,6 +234,10 @@ Measured: with the rate limit after `idempotency`, a key refused with a
222
234
  had passed; with it before, the same repeat ran the route and answered
223
235
  `201`.
224
236
 
237
+ Declared on the app, a rate limit or a guard also runs on a request no route
238
+ matches, and answers it before `idempotency` is reached; that is not a
239
+ concern here, since `idempotency` skips that request anyway.
240
+
225
241
  A route that refuses a request it might accept later — a `401` before the
226
242
  client signs in again, a `409` on a state that changes — should answer it
227
243
  before the guard, or the client should send a new key when it retries.
@@ -145,7 +145,7 @@ and [ten years](../troubleshooting.md#typeerror-defineratelimit--would-take-long
145
145
  `store.reset(key)` forgets a key — after a successful login, say:
146
146
 
147
147
  ```ts
148
- import { alxia } from '@alxia/core';
148
+ import { alxia, validate } from '@alxia/core';
149
149
  import { rateLimit } from '@alxia/rate-limit';
150
150
  import { redisStore } from '@alxia/redis';
151
151
  import { connectRedis } from '@nxgt/redis';
@@ -158,7 +158,7 @@ const passwords = new Map([['ada', 'lovelace']]);
158
158
  const app = alxia().group('/auth', (auth) =>
159
159
  auth
160
160
  .use(rateLimit({ limit: 5, windowMs: 15 * 60_000, store: attempts }))
161
- .post('/login', { body: z.object({ name: z.string(), password: z.string() }) }, async ({ body, ip, reply }) => {
161
+ .post('/login', validate({ body: z.object({ name: z.string(), password: z.string() }) }), async ({ body, ip, reply }) => {
162
162
  if (passwords.get(body.name) !== body.password) {
163
163
  return reply(401, { error: 'invalid_credentials' as const });
164
164
  }
@@ -49,7 +49,7 @@ Everything else — `ttl`, `staleWhileRevalidate`, `key`, `vary`, `statuses`,
49
49
 
50
50
  - **A response** is one Redis string at `<name>:response:<key>`: an
51
51
  `@nxgt/redis` cache record holding the status, the headers, the body as
52
- base64, and the plugin's `storedAt`, `ttl` and `stale`. Redis expires it
52
+ base64, and the cache's `storedAt`, `ttl` and `stale`. Redis expires it
53
53
  when `ttl + staleWhileRevalidate` has passed, rounded up to the second.
54
54
  - **A tag** is a Redis set at `<name>:tag:<tag>`, of the response keys it
55
55
  names. Besides your tags, every response carries `alxia:path:<path>` —
@@ -77,7 +77,7 @@ process sharing it misses on its next request:
77
77
 
78
78
  ```ts
79
79
  import { cache } from '@alxia/cache';
80
- import { alxia } from '@alxia/core';
80
+ import { alxia, validate } from '@alxia/core';
81
81
  import { redisCacheStore } from '@alxia/redis';
82
82
  import { connectRedis } from '@nxgt/redis';
83
83
  import { z } from 'zod';
@@ -90,7 +90,7 @@ const store = redisCacheStore(connection.client, { name: 'shop' });
90
90
  const products = cache({ ttl: 60, staleWhileRevalidate: 300, store, tags: () => ['products'] });
91
91
 
92
92
  const app = alxia()
93
- .post('/products', { body: Product }, async ({ body, reply }) => {
93
+ .post('/products', validate({ body: Product }), async ({ body, reply }) => {
94
94
  catalogue.set(body.id, body);
95
95
  await products.invalidateTag('products'); // forgotten in every process
96
96
  return reply(201, body);
@@ -61,8 +61,9 @@ it, as below.
61
61
  ## Give each test a client address
62
62
 
63
63
  `app.request()` has no socket, so `ctx.ip` is `undefined`. A rate limit
64
- then counts nothing, and idempotency scopes every key to `anyone`. Pass the
65
- app an `ip` that answers, as above, or one per test to stand for two
64
+ then counts nothing, and idempotency guards nothing: each request runs, and
65
+ it warns once that no client scope could be derived. Pass the app an `ip`
66
+ that answers, as above, or one per test to stand for two
66
67
  clients:
67
68
 
68
69
  ```ts
@@ -87,14 +88,13 @@ test('two processes share one count', async () => {
87
88
  });
88
89
  ```
89
90
 
90
- ## Typed refusals
91
+ ## Refusals behind the middleware only
91
92
 
92
- The `409`, `422` and `400` of `idempotency` are part of each guarded route's
93
- type. `@alxia/client` and `expectTypeOf` pin them, with no Redis call:
93
+ The `409`, `422` and `400` of `idempotency` are answered by the routes
94
+ declared after it alone, never by a request no route matches. Send a key it refuses to both:
94
95
 
95
96
  ```ts
96
- import { expect, expectTypeOf, test } from 'bun:test';
97
- import { client } from '@alxia/client';
97
+ import { expect, test } from 'bun:test';
98
98
  import { alxia } from '@alxia/core';
99
99
  import { idempotency } from '@alxia/redis';
100
100
  import { connectRedis } from '@nxgt/redis';
@@ -106,13 +106,10 @@ const app = alxia()
106
106
  .use(idempotency(connection.client, { name: 'payments' }))
107
107
  .post('/payments', ({ reply }) => reply(201, 'ok'));
108
108
 
109
- test('only the routes after the plugin carry its refusals', () => {
110
- const types = async () => {
111
- const api = client(app);
112
- expectTypeOf((await api.post('/payments')).status).toEqualTypeOf<201 | 400 | 409 | 422 | 500>();
113
- expectTypeOf((await api.post('/open')).status).toEqualTypeOf<201 | 500>();
114
- };
115
- expect(types).toBeFunction();
109
+ test('only the routes after the middleware refuse a bad key', async () => {
110
+ const init = { method: 'POST', headers: { 'idempotency-key': 'not a key' } };
111
+ expect((await app.request('/payments', init)).status).toBe(400);
112
+ expect((await app.request('/open', init)).status).toBe(201);
116
113
  });
117
114
  ```
118
115
 
package/docs/roadmap.md CHANGED
@@ -7,7 +7,10 @@ number on it. Every release, with each change it made, is in
7
7
 
8
8
  ## Now
9
9
 
10
- Nothing scheduled yet.
10
+ - **`idempotency` as a middleware.** `app.use(idempotency(client, { name }))` is
11
+ the form; `app.plugin(idempotency(…))` keeps working, deprecated. It skips a
12
+ request no route matches, and keeps what the route answers, an error's
13
+ answer included. `redis()` stays a plugin.
11
14
 
12
15
  ## Next
13
16
 
@@ -4,7 +4,8 @@ Each entry is headed by the text you see: a TypeScript error, an exception
4
4
  at startup, an exception in the log beside a `500 {"error":"internal"}`, or
5
5
  the response a client got. `@alxia/redis` throws nothing of its own: the
6
6
  messages are `@nxgt/redis`'s, `@nxgt/redis-guard`'s and Bun's, and it lets
7
- each through. What prints nothing is under [Traps](#traps), by symptom.
7
+ each through. It prints one warning of its own, under
8
+ [Runtime: a warning in the log](#runtime-a-warning-in-the-log). What prints nothing is under [Traps](#traps), by symptom.
8
9
 
9
10
  **Install and types**
10
11
 
@@ -32,6 +33,10 @@ each through. What prints nothing is under [Traps](#traps), by symptom.
32
33
  - [`RedisError: The lock "…" expired before its work finished: it ran longer than the …ms ttl, so it may have run beside another holder`](#rediserror-the-lock--expired-before-its-work-finished-it-ran-longer-than-the-ms-ttl-so-it-may-have-run-beside-another-holder)
33
34
  - [`TypeError: undefined is not an object (evaluating 'cache.…')`](#typeerror-undefined-is-not-an-object-evaluating-cache)
34
35
 
36
+ **Runtime: a warning in the log**
37
+
38
+ - [`idempotency "…": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().`](#idempotency--no-client-scope-could-be-derived-ctxip-is-undefined-and-no-scope-option-returned-one-so-these-requests-run-unguarded-nothing-stored-or-replayed-pass-a-scope-option-ctx--a-user-id-or-an-ip-option-to-alxia)
39
+
35
40
  **Calling the store yourself**
36
41
 
37
42
  - [`TypeError: defineRateLimit: "…" has a per of …; it is a whole number of milliseconds, and must be at least 1`](#typeerror-defineratelimit--has-a-per-of--it-is-a-whole-number-of-milliseconds-and-must-be-at-least-1)
@@ -106,7 +111,7 @@ never meet `@alxia/cache`'s `cache`.
106
111
 
107
112
  ```ts
108
113
  alxia()
109
- .use(redis(connection.client, { caches: { users } }))
114
+ .plugin(redis(connection.client, { caches: { users } }))
110
115
  .get('/users/:id', async ({ caches, params, reply }) =>
111
116
  reply.ok(await caches.users.remember(params.id, () => loadUser(params.id))),
112
117
  );
@@ -277,7 +282,7 @@ with a fraction or below 0.
277
282
  TypeError: run on "payments": wait is a whole number of milliseconds, 0 or more
278
283
  ```
279
284
 
280
- **Why:** `wait` is checked when it is used, not when the plugin is made.
285
+ **Why:** `wait` is checked when it is used, not when the middleware is made.
281
286
 
282
287
  **Fix:**
283
288
 
@@ -398,6 +403,38 @@ with `undefined`.
398
403
  **Fix:** read `caches.<name>`, drop the `derive`, and typecheck:
399
404
  `tsc --noEmit` finds every place.
400
405
 
406
+ ## Runtime: a warning in the log
407
+
408
+ ### `idempotency "…": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().`
409
+
410
+ **When:** a guarded request carries an `Idempotency-Key`, and the
411
+ middleware has no client to scope it by: `ctx.ip` is `undefined` — under
412
+ `app.request()` in a test, or a server that cannot see the address — and
413
+ there is no `scope` option, or it returned `undefined`. Printed once per
414
+ `idempotency(…)`, with its `name`.
415
+
416
+ **Why:** keys are scoped by the client, so two clients choosing the same key
417
+ never see each other's response. With no client, the request runs
418
+ unguarded: the route runs, nothing is stored, a repeat runs it again.
419
+ Sharing one key space between every client would replay one client's
420
+ response to another.
421
+
422
+ **Fix:** scope by the user where there is one, or give `alxia()` an `ip`
423
+ option that reads the address:
424
+
425
+ ```ts
426
+ idempotency(connection.client, {
427
+ name: 'payments',
428
+ scope: ({ request }) => request.headers.get('x-user-id') ?? undefined,
429
+ });
430
+ ```
431
+
432
+ or, behind a proxy you trust:
433
+
434
+ ```ts
435
+ alxia({ ip: (request) => request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() });
436
+ ```
437
+
401
438
  ## Calling the store yourself
402
439
 
403
440
  A policy `rateLimit` would refuse at startup still reaches the store when
@@ -567,7 +604,8 @@ rateLimit({ limit: 5, windowMs: 60_000, store: redisStore(connection.client, { n
567
604
  ### A `401` or a `429` is replayed, with `Idempotent-Replayed: true`, after the client fixed it
568
605
 
569
606
  **When:** a request under a key was refused with a `4xx` — by the route, or
570
- by a rate limit or an authentication check declared after `idempotency` —
607
+ by a rate limit or an authentication check declared after `idempotency`,
608
+ or by an error handler —
571
609
  and the client, once allowed, retries with the same key and body.
572
610
 
573
611
  **Why:** every response below `500` is kept and replayed for `ttl`, not
@@ -596,8 +634,9 @@ behind a proxy, or with keys that are not random — `1`, `order-1`.
596
634
 
597
635
  **Why:** keys are scoped by `scope(ctx)`, the client's address by default.
598
636
  Behind a proxy that the app's `ip` option does not see through, every
599
- client has the proxy's address; with no address at all, the scope is
600
- `anyone`. Clients then share one key space.
637
+ client has the proxy's address, and they then share one key space. (With
638
+ no address at all and no `scope`, the request runs unguarded instead, with
639
+ a [warning](#runtime-a-warning-in-the-log).)
601
640
 
602
641
  **Fix:** scope by the user where there is one, and have clients send
603
642
  random keys:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/redis",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Redis for alxia on @nxgt/redis and @nxgt/redis-guard: a rate-limit store every process shares, idempotent routes, caches and locks in the context",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,19 +41,18 @@
41
41
  ]
42
42
  },
43
43
  "devDependencies": {
44
- "@alxia/cache": "^0.1.2",
45
- "@alxia/client": "^0.2.1",
46
- "@alxia/core": "^0.3.0",
47
- "@alxia/rate-limit": "^0.1.2",
44
+ "@alxia/cache": "^0.2.0",
45
+ "@alxia/core": "^0.4.0",
46
+ "@alxia/rate-limit": "^0.2.0",
48
47
  "@nxgt/redis": "^0.3.1",
49
48
  "@nxgt/redis-guard": "^0.3.1",
50
49
  "@types/bun": "^1.4.2",
51
50
  "zod": "^4.6.5"
52
51
  },
53
52
  "peerDependencies": {
54
- "@alxia/cache": "^0.1.2",
55
- "@alxia/core": "^0.3.0",
56
- "@alxia/rate-limit": "^0.1.2",
53
+ "@alxia/cache": "^0.2.0",
54
+ "@alxia/core": "^0.4.0",
55
+ "@alxia/rate-limit": "^0.2.0",
57
56
  "@nxgt/redis": "^0.3.1",
58
57
  "@nxgt/redis-guard": "^0.3.1",
59
58
  "typescript": "^6.0.3 || ^7.0.0",