@alxia/redis 0.1.3 → 0.3.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/docs/README.md CHANGED
@@ -8,11 +8,19 @@ 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, naming keys so features never share them, or giving every export one `@nxgt/redis` handle: a prefix on every key, a health check, closing on stop |
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 |
19
+
20
+ ## Recipes
21
+
22
+ A task that crosses packages, in [the repository's recipes](https://github.com/softistx/alxia/blob/develop/docs/recipes/README.md), each with a complete example:
23
+
24
+ - [Caching and rate limiting with Redis](https://github.com/softistx/alxia/blob/develop/docs/recipes/caching-and-rate-limiting.md): a limit and a response cache shared by every process
25
+ - [Test an alxia app](https://github.com/softistx/alxia/blob/develop/docs/recipes/testing.md): `app.request`, a middleware alone, the typed client, sockets, Redis
26
+ - [Health checks and graceful shutdown](https://github.com/softistx/alxia/blob/develop/docs/recipes/health-and-shutdown.md): probes, the drain, Docker and Kubernetes
@@ -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
@@ -213,6 +213,20 @@ Code written for the earlier name — `ctx.cache.users` — no longer compiles;
213
213
  [Troubleshooting](../troubleshooting.md#property-cache-does-not-exist-on-type---rediscontext-) has the
214
214
  message and the rename.
215
215
 
216
+ ## From an `@nxgt/redis` handle
217
+
218
+ `redis(handle)` takes the handle `openRedis(defineRedis({ …, caches }))` gives
219
+ instead of a client and a `caches` option: the context's `caches` are the
220
+ handle's `cache` scope, the `lock` is under its `prefix`, and the handle is
221
+ closed when the app stops. See [Connecting](connecting.md#with-an-nxgtredis-handle).
222
+
223
+ ```ts
224
+ const handle = await openRedis(defineRedis({ uri: Bun.env['REDIS_URL']!, prefix: 'shop', caches: { users } }));
225
+ alxia()
226
+ .plugin(redis(handle))
227
+ .get('/users/:id', ({ caches, params, reply }) => reply.ok(caches.users.get(params.id))); // shop:user:<id>
228
+ ```
229
+
216
230
  ## Next
217
231
 
218
232
  - [Connecting](connecting.md) — the client, and closing it.
@@ -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:
@@ -143,6 +143,135 @@ process.on('SIGTERM', async () => {
143
143
  `closeRedis()` from `@nxgt/redis` closes every client the process opened
144
144
  through `connectRedis` — the end of a test file, say.
145
145
 
146
+ ## With an `@nxgt/redis` handle
147
+
148
+ `openRedis(defineRedis({ … }))` opens the client and wires the caches, a
149
+ `prefix` and a lock in one object, the handle. Every export that takes a
150
+ client takes the handle too, and the deployment's `prefix` is then in front of
151
+ every key it writes:
152
+
153
+ ```ts
154
+ import { cache } from '@alxia/cache';
155
+ import { alxia, health } from '@alxia/core';
156
+ import { idempotency, redis, redisCacheStore, redisCheck } from '@alxia/redis';
157
+ import { defineCache, defineRedis, openRedis } from '@nxgt/redis';
158
+ import { z } from 'zod';
159
+
160
+ const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: z.object({ id: z.string(), name: z.string() }) });
161
+ const handle = await openRedis(
162
+ defineRedis({ uri: Bun.env['REDIS_URL']!, prefix: 'shop', caches: { users } }),
163
+ );
164
+
165
+ const app = alxia()
166
+ .plugin(health({ checks: { redis: redisCheck(handle) } }))
167
+ .plugin(redis(handle))
168
+ .use(idempotency(handle, { name: 'orders' }))
169
+ .use(cache({ ttl: 60, store: redisCacheStore(handle, { name: 'pages' }) }))
170
+ .get('/users/:id', ({ caches, params, reply }) => reply.ok(caches.users.get(params.id)));
171
+ ```
172
+
173
+ | Export | Keys under `prefix: 'shop'` |
174
+ | --- | --- |
175
+ | `redisStore(handle, { name: 'api' })` | `shop:api:<limit>/<windowMs>:<key>`, `shop:api:policies` |
176
+ | `redisCacheStore(handle, { name: 'pages' })` | `shop:pages:response:<key>`, `shop:pages:tag:<tag>` |
177
+ | `idempotency(handle, { name: 'orders' })` | `shop:orders:<route>:<scope>:<Idempotency-Key>` |
178
+ | `redis(handle)` | `shop:<cache name>:<key>`, `lock:shop:<key>` |
179
+
180
+ The lock is `@nxgt/redis`'s, which writes `lock:` first and the prefix inside
181
+ it. `redis(handle)` puts `redis` (the client), `caches`, `lock` and `prefix` in
182
+ the context; `caches` is the handle's own `cache` scope, typed by each schema.
183
+
184
+ **Closing.** `redis(handle)` closes the handle in core's `onStop`, once, after
185
+ the requests in flight drained, so `listen`'s graceful shutdown ends with the
186
+ connection closed. Opt out with `redis(handle, { close: false })` when
187
+ something else owns the handle. The stores and `idempotency` never close it;
188
+ an app that does not mount `redis(handle)` closes the handle itself. A
189
+ `client` that `defineRedis` was given is never closed by `@nxgt/redis`.
190
+
191
+ **Health.** `redisCheck(handle)` is a check for `health({ checks })`: it
192
+ passes while every instance answers a `PING` and is down when one does not;
193
+ given a handle, `{ timeout }` bounds each instance's ping, in milliseconds
194
+ (2 s by default). It takes a bare client too, whose `PING` has no bound of
195
+ its own: `health({ timeout })` bounds the check.
196
+
197
+ **One instance.** The handle must wire exactly one Redis instance; a handle of
198
+ several is refused with a `TypeError` naming them. Give a bare client,
199
+ `handle.clients.<name>`, to the factory that lives on one of them, with the
200
+ prefix in its `name`.
201
+
202
+ **Defined once.** With `@nxgt/redis` 0.6 the rate limit and the idempotent
203
+ operation can be wired by `defineRedis` too, and `redisStore` and `idempotency`
204
+ take what it wired: [Defined once, in `defineRedis`](#defined-once-in-defineredis).
205
+
206
+ **Switching from a bare client** changes the keys: a rate-limit count, a kept
207
+ response or a replayable response written without the prefix is not found
208
+ under it, and the new keys start empty.
209
+
210
+ ## Defined once, in `defineRedis`
211
+
212
+ `defineRedis` takes `limits` and `idempotency` next to `caches`, and the handle
213
+ exposes each as `handle.limits.<name>` and `handle.idempotency.<name>`, typed
214
+ from the definitions and writing `<prefix>:<name>:<key>`, the layout every
215
+ `@nxgt/redis` consumer of the deployment shares. Hand the wired entry to the
216
+ store and to the middleware instead of a name:
217
+
218
+ ```ts
219
+ import { alxia } from '@alxia/core';
220
+ import { rateLimit } from '@alxia/rate-limit';
221
+ import { idempotency, idempotencyResult, redisStore } from '@alxia/redis';
222
+ import { defineIdempotency, defineRateLimit, defineRedis, openRedis } from '@nxgt/redis';
223
+
224
+ const api = defineRateLimit({ name: 'api', key: (ip: string) => ip, limit: 100, per: 60_000 });
225
+ const orders = defineIdempotency({ name: 'orders', key: (id: string) => id, ttl: 86_400, schema: idempotencyResult });
226
+
227
+ const handle = await openRedis(
228
+ defineRedis({ uri: Bun.env['REDIS_URL']!, prefix: 'shop', limits: { api }, idempotency: { orders } }),
229
+ );
230
+
231
+ const app = alxia()
232
+ .use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(handle.limits.api) })) // shop:api:<address>
233
+ .use(idempotency(handle.idempotency.orders)) // shop:orders:<route>:<scope>:<key>
234
+ .post('/orders', ({ reply }) => reply(201, { id: crypto.randomUUID() }));
235
+ ```
236
+
237
+ | Wired | Keys under `prefix: 'shop'` | Also written by |
238
+ | --- | --- | --- |
239
+ | `redisStore(handle.limits.api)` | `shop:api:<key>` | `handle.limits.api.consume(key)` anywhere |
240
+ | `idempotency(handle.idempotency.orders)` | `shop:orders:<route>:<scope>:<Idempotency-Key>` | `idempotency(handle, { name: 'orders' })`: the same keys |
241
+
242
+ What to know:
243
+
244
+ - **The definition is the one place.** The name, `limit`, `per` and `burst` of
245
+ a rate limit, the name, `ttl` and `lease` of an idempotency, are the
246
+ definition's. `idempotency(handle.idempotency.orders)` takes the middleware's
247
+ options (`required`, `scope`, `wait`, `methods`, `header`) but not `name`,
248
+ `ttl` or `lease`: the types refuse them, and a script that passes them gets a
249
+ `TypeError`.
250
+ - **The rate is the definition's, and `rateLimit` repeats it for its
251
+ headers.** The store counts by the wired limit's own rate, so `limit` and
252
+ `windowMs` on `rateLimit` only write `RateLimit-*`: give them the definition's
253
+ numbers.
254
+ - **The limit counts by a string.** `rateLimit` counts by the string its `key`
255
+ returns, so the wired limit's key must take one, as `key: (ip: string) => ip`.
256
+ `redisStore(handle.limits.byIp)` for a limit keyed by `{ ip }` does not
257
+ compile.
258
+ - **The idempotency keeps a response.** Its `schema` must be
259
+ `idempotencyResult`, the shape `@alxia/redis` stores (status, headers, body),
260
+ and its key must take a string; the middleware builds
261
+ `<route>:<scope>:<Idempotency-Key>` itself. Another schema does not compile.
262
+ - **A guard bound by hand works too**: `redisStore(bindRateLimit(client, api))`,
263
+ with no prefix.
264
+ - **Same name, one kind.** One name shared by a cache and a limit or an
265
+ idempotency on one instance is refused when the handle is wired:
266
+ [troubleshooting](../troubleshooting.md#typeerror-defineredis-instance-default-wires-the-cache-users-and-the-rate-limit-login-under-one-name-user-they-would-share-every-key-in-redis-give-one-of-them-a-name-of-its-own).
267
+ - **Moving from the by-name form.** An idempotency keeps its keys. A rate limit
268
+ does not: the by-name store counts under `shop:api:<limit>/<windowMs>:<key>`,
269
+ the wired one under `shop:api:<key>`, so counts restart:
270
+ [troubleshooting](../troubleshooting.md#counts-restart-after-moving-a-rate-limit-to-the-wired-form).
271
+ - **Both forms stay**, and need `@nxgt/redis` 0.6 only for `handle.limits` and
272
+ `handle.idempotency`: a limit or an idempotency bound by hand with 0.5 is
273
+ accepted the same.
274
+
146
275
  ## Naming keys
147
276
 
148
277
  Every export that writes takes a `name`, prepended to its keys, so several
@@ -155,7 +284,8 @@ apps and several features can share one Redis:
155
284
  | `idempotency(client, { name })` | `<name>:<route>:<scope>:<Idempotency-Key>` |
156
285
  | `redis(client, { caches })` | each cache's own `<name>:<key>`, and `lock:<key>` |
157
286
 
158
- Two features given the same `name` share their keys; give each its own.
287
+ Two features given the same `name` share their keys; give each its own. Given a
288
+ [handle](#with-an-nxgtredis-handle), each key also starts with its `prefix`.
159
289
 
160
290
  ## Next
161
291
 
@@ -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,15 @@ 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(target: RedisClient | Redis<any>, options: IdempotencyOptions); // a middleware
39
+ function idempotency(wired: WiredIdempotency, options?: WiredIdempotencyOptions); // wired by defineRedis
37
40
 
38
41
  interface IdempotencyOptions {
39
42
  readonly name: string;
@@ -56,7 +59,7 @@ interface IdempotencyOptions {
56
59
  | `methods` | `readonly string[]` | `['POST', 'PATCH']` | the methods it guards; the others pass through |
57
60
  | `header` | `string` | `'idempotency-key'` | the request header the key is read from (any case) |
58
61
  | `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 |
62
+ | `scope` | `(ctx: BaseContext) => string \| undefined` | `ctx.ip` | whose key it is; `undefined` leaves the request unguarded, with a warning |
60
63
 
61
64
  `name`, `ttl` and `lease` are checked when `idempotency(…)` is called, so a
62
65
  wrong one throws at startup:
@@ -71,6 +74,12 @@ TypeError: defineIdempotency: an idempotent operation needs a name, for its keys
71
74
  guarded request a `500`, logging
72
75
  `TypeError: run on "payments": wait is a whole number of milliseconds, 0 or more`.
73
76
 
77
+ `idempotency(handle.idempotency.orders)` takes an idempotency wired by
78
+ `defineRedis` instead: its definition holds the `name`, `ttl` and `lease`, and
79
+ its `schema` is `idempotencyResult`. The options are the others of the table
80
+ (`WiredIdempotencyOptions`). See
81
+ [Defined once, in `defineRedis`](connecting.md#defined-once-in-defineredis).
82
+
74
83
  ## What each request gets
75
84
 
76
85
  | Request | Answer |
@@ -84,8 +93,9 @@ guarded request a `500`, logging
84
93
  | no key, without `required` | the route runs, unguarded |
85
94
  | a method not in `methods` | the route runs, unguarded |
86
95
 
87
- Every refusal is typed as `IdempotencyErrorBody` and is part of each
88
- guarded route's type, so `@alxia/client` reads it:
96
+ Every refusal's body is an `IdempotencyErrorBody`; declare the statuses in
97
+ your OpenAPI document, and the client you generate from it (with
98
+ `@nxgt/openapi-codegen`, say) reads them typed:
89
99
 
90
100
  ```ts
91
101
  interface IdempotencyErrorBody {
@@ -100,9 +110,8 @@ interface IdempotencyErrorBody {
100
110
  ```
101
111
 
102
112
  ```ts
103
- import { client } from '@alxia/client';
104
- import { alxia } from '@alxia/core';
105
- import { idempotency } from '@alxia/redis';
113
+ import { alxia, validate } from '@alxia/core';
114
+ import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
106
115
  import { connectRedis } from '@nxgt/redis';
107
116
  import { z } from 'zod';
108
117
 
@@ -110,18 +119,19 @@ const connection = await connectRedis(Bun.env['REDIS_URL']!);
110
119
 
111
120
  const app = alxia()
112
121
  .use(idempotency(connection.client, { name: 'payments', required: true }))
113
- .post('/payments', { body: z.object({ amount: z.number() }) }, ({ body, reply }) =>
122
+ .post('/payments', validate({ body: z.object({ amount: z.number() }) }), ({ body, reply }) =>
114
123
  reply(201, { id: crypto.randomUUID(), amount: body.amount }),
115
124
  );
116
125
 
117
- const api = client(app);
118
- const result = await api.post('/payments', {
119
- body: { amount: 10 },
120
- init: { headers: { 'idempotency-key': crypto.randomUUID() } },
126
+ const result = await app.request('/payments', {
127
+ method: 'POST',
128
+ headers: { 'content-type': 'application/json', 'idempotency-key': crypto.randomUUID() },
129
+ body: JSON.stringify({ amount: 10 }),
121
130
  });
122
- // result.status: 201 | 400 | 409 | 422 | 500
131
+ // result.status: 201, or 400, 409, 422 from the guard, or 500
123
132
  if (result.status === 409) {
124
- await Bun.sleep(result.data.retryAfter! * 1000); // then send the same request again
133
+ const { retryAfter } = (await result.json()) as IdempotencyErrorBody;
134
+ await Bun.sleep(retryAfter! * 1000); // then send the same request again
125
135
  }
126
136
  ```
127
137
 
@@ -150,7 +160,7 @@ gets the replay rather than the `409`:
150
160
 
151
161
  ```ts
152
162
  import { alxia } from '@alxia/core';
153
- import { idempotency } from '@alxia/redis';
163
+ import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
154
164
  import { connectRedis } from '@nxgt/redis';
155
165
 
156
166
  const connection = await connectRedis(Bun.env['REDIS_URL']!);
@@ -165,15 +175,22 @@ const app = alxia()
165
175
  Clients choose their keys, so two clients can choose the same one. The
166
176
  stored key is `<name>:<route>:<scope>:<key>`, where `scope(ctx)` is the
167
177
  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.
178
+ `app.request()` in a test or a server that cannot see one, and no `scope`
179
+ option, or one that returns `undefined` — the request runs unguarded:
180
+ nothing is stored, nothing replayed, and each repeat runs the route again.
181
+ Sharing one key space between every client would replay one client's
182
+ response to another. The middleware warns once:
183
+
184
+ ```
185
+ 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().
186
+ ```
170
187
 
171
188
  Behind a proxy, the address is the proxy's unless the app's `ip` option
172
189
  reads the forwarded header. Where requests carry a user, scope by the user:
173
190
 
174
191
  ```ts
175
192
  import { alxia } from '@alxia/core';
176
- import { idempotency } from '@alxia/redis';
193
+ import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
177
194
  import { connectRedis } from '@nxgt/redis';
178
195
 
179
196
  const connection = await connectRedis(Bun.env['REDIS_URL']!);
@@ -197,9 +214,11 @@ is two keys.
197
214
 
198
215
  ## Order: what runs inside the guard
199
216
 
200
- The plugin wraps the routes declared after it, and every route hook
217
+ The middleware wraps the routes declared after it, and every middleware
201
218
  declared after it too. Whatever those answer is kept like the route's
202
- answer. A rate limit or an authentication check declared **after**
219
+ answer: so is what a try/catch middleware, an `HttpError` or a validation
220
+ refusal answers, because `idempotency` settles the rest of the request before
221
+ it keeps it. A rate limit or an authentication check declared **after**
203
222
  `idempotency` has its `429` or `401` kept and replayed — even once the
204
223
  client is allowed through. Declare them **before**:
205
224
 
@@ -222,6 +241,10 @@ Measured: with the rate limit after `idempotency`, a key refused with a
222
241
  had passed; with it before, the same repeat ran the route and answered
223
242
  `201`.
224
243
 
244
+ Declared on the app, a rate limit or a guard also runs on a request no route
245
+ matches, and answers it before `idempotency` is reached; that is not a
246
+ concern here, since `idempotency` skips that request anyway.
247
+
225
248
  A route that refuses a request it might accept later — a `401` before the
226
249
  client signs in again, a `409` on a state that changes — should answer it
227
250
  before the guard, or the client should send a new key when it retries.
@@ -251,5 +274,5 @@ Yield (`await Bun.sleep(0)`) inside long synchronous work, or move it to a
251
274
 
252
275
  - [Rate limits](rate-limits.md) — `redisStore`.
253
276
  - [Testing](testing.md) — specs for a guarded route.
254
- - [`@nxgt/redis-guard`](https://www.npmjs.com/package/@nxgt/redis-guard) —
277
+ - [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) —
255
278
  the primitive underneath, for idempotency outside HTTP.
@@ -28,7 +28,8 @@ bun add @alxia/rate-limit
28
28
  ## The signature
29
29
 
30
30
  ```ts
31
- function redisStore(client: RedisClient, options: RedisStoreOptions): RateLimitStore;
31
+ function redisStore(target: RedisClient | Redis<any>, options: RedisStoreOptions): RateLimitStore;
32
+ function redisStore(limit: BoundRateLimit<string>): RateLimitStore; // wired by defineRedis
32
33
 
33
34
  interface RedisStoreOptions {
34
35
  /** Prepended to every key it counts: one name per limit, so two never share a count. */
@@ -46,6 +47,11 @@ interface RedisStoreOptions {
46
47
  `429 { error: 'rate_limited', retryAfter }` with `Retry-After`, the same as
47
48
  with the memory store.
48
49
 
50
+ A limit wired by `defineRedis`, `redisStore(handle.limits.api)`, takes no
51
+ `name`: its definition holds the name and the rate, and the keys are
52
+ `<prefix>:<name>:<key>`, shared with every other `@nxgt/redis` consumer. See
53
+ [Defined once, in `defineRedis`](connecting.md#defined-once-in-defineredis).
54
+
49
55
  ## What changes with Redis
50
56
 
51
57
  - **Every process counts together.** Behind a load balancer, a client gets
@@ -124,7 +130,7 @@ are the same count.
124
130
  `rateLimit` refuses a `limit` or a `windowMs` that is not a whole number of
125
131
  1 or more when it is created, with
126
132
  [`TypeError: rateLimit: … must be a whole number of 1 or more, not …`](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/troubleshooting.md#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-).
127
- `redisStore` checks two more bounds, `@nxgt/redis-guard`'s, only when it
133
+ `redisStore` checks two more bounds, `@nxgt/redis`'s, only when it
128
134
  first counts under a policy, not when the app starts. A policy past either
129
135
  makes the first request it counts, and every one after it, a
130
136
  `500 {"error":"internal"}`, with the reason in the log — a refused policy
@@ -145,7 +151,7 @@ and [ten years](../troubleshooting.md#typeerror-defineratelimit--would-take-long
145
151
  `store.reset(key)` forgets a key — after a successful login, say:
146
152
 
147
153
  ```ts
148
- import { alxia } from '@alxia/core';
154
+ import { alxia, validate } from '@alxia/core';
149
155
  import { rateLimit } from '@alxia/rate-limit';
150
156
  import { redisStore } from '@alxia/redis';
151
157
  import { connectRedis } from '@nxgt/redis';
@@ -158,7 +164,7 @@ const passwords = new Map([['ada', 'lovelace']]);
158
164
  const app = alxia().group('/auth', (auth) =>
159
165
  auth
160
166
  .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 }) => {
167
+ .post('/login', validate({ body: z.object({ name: z.string(), password: z.string() }) }), async ({ body, ip, reply }) => {
162
168
  if (passwords.get(body.name) !== body.password) {
163
169
  return reply(401, { error: 'invalid_credentials' as const });
164
170
  }
@@ -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