@alxia/redis 0.2.0 → 0.4.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,7 +8,7 @@ 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 what takes it, 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 |
@@ -16,3 +16,11 @@ a realistic example for each.
16
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
@@ -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.
@@ -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({ store: redisStore(handle.limits.api, api) })) // shop:api:<address>, 100 per 60 s from `api`
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
 
@@ -35,7 +35,8 @@ to the 404.
35
35
  ## The signature
36
36
 
37
37
  ```ts
38
- function idempotency(client: RedisClient, options: IdempotencyOptions); // a middleware
38
+ function idempotency(target: RedisClient | Redis<any>, options: IdempotencyOptions); // a middleware
39
+ function idempotency(wired: WiredIdempotency, options?: WiredIdempotencyOptions); // wired by defineRedis
39
40
 
40
41
  interface IdempotencyOptions {
41
42
  readonly name: string;
@@ -73,6 +74,12 @@ TypeError: defineIdempotency: an idempotent operation needs a name, for its keys
73
74
  guarded request a `500`, logging
74
75
  `TypeError: run on "payments": wait is a whole number of milliseconds, 0 or more`.
75
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
+
76
83
  ## What each request gets
77
84
 
78
85
  | Request | Answer |
@@ -209,7 +216,7 @@ is two keys.
209
216
 
210
217
  The middleware wraps the routes declared after it, and every middleware
211
218
  declared after it too. Whatever those answer is kept like the route's
212
- answer: so is what an `onError` hook, an `HttpError` or a validation
219
+ answer: so is what a try/catch middleware, an `HttpError` or a validation
213
220
  refusal answers, because `idempotency` settles the rest of the request before
214
221
  it keeps it. A rate limit or an authentication check declared **after**
215
222
  `idempotency` has its `429` or `401` kept and replayed — even once the
@@ -267,5 +274,5 @@ Yield (`await Bun.sleep(0)`) inside long synchronous work, or move it to a
267
274
 
268
275
  - [Rate limits](rate-limits.md) — `redisStore`.
269
276
  - [Testing](testing.md) — specs for a guarded route.
270
- - [`@nxgt/redis-guard`](https://www.npmjs.com/package/@nxgt/redis-guard) —
277
+ - [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) —
271
278
  the primitive underneath, for idempotency outside HTTP.
@@ -28,7 +28,9 @@ 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
33
+ function redisStore(limit: BoundRateLimit<string>, definition: RateLimitDefinition<string>): PolicyStore; // and its policy
32
34
 
33
35
  interface RedisStoreOptions {
34
36
  /** Prepended to every key it counts: one name per limit, so two never share a count. */
@@ -46,6 +48,34 @@ interface RedisStoreOptions {
46
48
  `429 { error: 'rate_limited', retryAfter }` with `Retry-After`, the same as
47
49
  with the memory store.
48
50
 
51
+ A limit wired by `defineRedis`, `redisStore(handle.limits.api)`, takes no
52
+ `name`: its definition holds the name and the rate, and the keys are
53
+ `<prefix>:<name>:<key>`, shared with every other `@nxgt/redis` consumer. See
54
+ [Defined once, in `defineRedis`](connecting.md#defined-once-in-defineredis).
55
+
56
+ ### The rate, written once
57
+
58
+ `@nxgt/redis` 0.6 does not expose a bound limit's rate, so a store of the
59
+ bound limit alone cannot tell `rateLimit` what it counts by, and `limit` and
60
+ `windowMs` repeat the definition for the headers. Give the definition as the
61
+ second argument and the store declares it as its `policy`:
62
+
63
+ ```ts
64
+ const api = defineRateLimit({ name: 'api', key: (ip: string) => ip, limit: 100, per: 60_000 });
65
+ const handle = await openRedis(defineRedis({ uri, prefix: 'shop', limits: { api } }));
66
+
67
+ app.use(rateLimit({ store: redisStore(handle.limits.api, api) })); // 100 per 60 s
68
+ ```
69
+
70
+ `rateLimit` reads `limit` (the definition's `limit`) and `windowMs` (its
71
+ `per`) from the policy: the 429 and the `RateLimit-Limit`, `-Remaining`,
72
+ `-Reset` and `-Policy` headers come from the definition, and one place holds
73
+ the numbers. A `limit` or `windowMs` given too must equal them, or
74
+ `rateLimit()` throws at declaration. A definition that is not the one that
75
+ wired the limit — another `name` — is a `TypeError` from `redisStore`.
76
+ The definition's `burst`, when it sets one, still governs how many requests
77
+ pass at once; the policy states `limit` per `per`.
78
+
49
79
  ## What changes with Redis
50
80
 
51
81
  - **Every process counts together.** Behind a load balancer, a client gets
@@ -124,7 +154,7 @@ are the same count.
124
154
  `rateLimit` refuses a `limit` or a `windowMs` that is not a whole number of
125
155
  1 or more when it is created, with
126
156
  [`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
157
+ `redisStore` checks two more bounds, `@nxgt/redis`'s, only when it
128
158
  first counts under a policy, not when the app starts. A policy past either
129
159
  makes the first request it counts, and every one after it, a
130
160
  `500 {"error":"internal"}`, with the reason in the log — a refused policy
package/docs/roadmap.md CHANGED
@@ -7,10 +7,8 @@ number on it. Every release, with each change it made, is in
7
7
 
8
8
  ## Now
9
9
 
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.
10
+ Nothing in progress: `idempotency` as a middleware, the last change planned,
11
+ shipped in 0.2.0 and finished in 0.3.0.
14
12
 
15
13
  ## Next
16
14
 
@@ -23,14 +21,64 @@ Nothing scheduled yet.
23
21
  ## Not planned
24
22
 
25
23
  - **A second Redis implementation.** `@alxia/redis` is an adapter over
26
- [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) and
27
- [`@nxgt/redis-guard`](https://www.npmjs.com/package/@nxgt/redis-guard),
28
- never a rewrite of them: their scripts, keys and errors are what it runs.
24
+ [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis),
25
+ never a rewrite of it: its scripts, keys and errors are what it runs.
29
26
  They run on Bun's built-in `RedisClient`, so there is no driver to
30
27
  install, and it does not run on Node.
31
28
 
32
29
  ## Shipped
33
30
 
31
+ ### 0.4.0: the rate, written once
32
+
33
+ - **A rate limit that reads its policy from the store.**
34
+ `redisStore(handle.limits.api, api)`, given the definition beside the wired
35
+ limit, declares the definition's `limit` and `per` as the store's `policy`;
36
+ `rateLimit({ store })` takes `limit` and `windowMs` from it (optional in the
37
+ type, with `@alxia/rate-limit` 0.4) and writes its `RateLimit-*` headers
38
+ from it, and numbers that differ throw at declaration. `redisStore` returns
39
+ a `PolicyStore`. The one-argument form stays, with its numbers repeated.
40
+ (`@nxgt/redis` 0.6 does not expose a bound limit's rate, hence the
41
+ definition as an argument.)
42
+
43
+ ### 0.3.0, continued: wired guards
44
+
45
+ - **On `@nxgt/redis` 0.6.** The peer range is `^0.5.0 || ^0.6.0`: the new forms
46
+ need no more than 0.5's types, and 0.6 only adds `handle.limits` and
47
+ `handle.idempotency` to wire them.
48
+ - **A rate limit and an idempotency defined once.** `redisStore(handle.limits.api)`
49
+ and `idempotency(handle.idempotency.orders)` take what `defineRedis`
50
+ wired, so the definition lives in one place and writes the keys
51
+ `@nxgt/redis` writes, `<prefix>:<name>:<key>`: every consumer of the handle
52
+ shares the count. `idempotencyResult` is the schema of the wired idempotency.
53
+ Both older forms stay.
54
+
55
+ ### 0.3.0
56
+
57
+ - **On `@nxgt/redis` 0.5 alone.** The rate limits and the idempotency that
58
+ `@nxgt/redis-guard` held live in `@nxgt/redis` now, and `@nxgt/redis-guard`
59
+ is no longer a peer.
60
+ - **One form for `idempotency`.** The middleware's type is a plain
61
+ `(ctx, next)` function, and `app.plugin(idempotency(…))`, deprecated in
62
+ 0.2.0, is gone: give it to `use(…)`.
63
+ - **An `@nxgt/redis` handle everywhere.** `redis(handle)` takes the handle
64
+ `openRedis(defineRedis({ … }))` gives: typed `caches` from its scopes, a
65
+ `lock` and every key under its `prefix`, and the handle closed once in
66
+ `onStop`, after the drain (`{ close: false }` to opt out). `redisStore`,
67
+ `redisCacheStore` and `idempotency` take it where they take a client and
68
+ put its prefix in front of their keys, and `redisCheck` is a readiness
69
+ check for `health()`. The bare `RedisClient` forms are unchanged.
70
+
71
+ ### 0.2.0
72
+
73
+ - **Middlewares, under the same names.** `app.use(idempotency(client, …))`
74
+ replaces `app.plugin(idempotency(…))`, which stayed, deprecated, until 0.3.0;
75
+ `IdempotencyMiddleware` is the type it returns. A request no route matches
76
+ passes through it, never kept.
77
+ - **A client no one can tell apart is not shared.** A request with no
78
+ `ctx.ip` and no `scope` runs unguarded, nothing stored or replayed, and
79
+ the middleware warns once, instead of keying every such client to
80
+ `anyone`, where one could be replayed another's response.
81
+
34
82
  ### 0.1.0
35
83
 
36
84
  - **A rate limit every process shares.** `redisStore` is an
@@ -2,9 +2,10 @@
2
2
 
3
3
  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
- the response a client got. `@alxia/redis` throws nothing of its own: the
6
- messages are `@nxgt/redis`'s, `@nxgt/redis-guard`'s and Bun's, and it lets
7
- each through. It prints one warning of its own, under
5
+ the response a client got. `@alxia/redis` throws almost nothing of its own: the
6
+ messages are `@nxgt/redis`'s and Bun's, and it lets
7
+ each through; its own refusals, a handle of several instances, a wired guard given a
8
+ `name`, `ttl` or `lease`, or a client given without a `name`, say what to pass instead. It prints one warning of its own, under
8
9
  [Runtime: a warning in the log](#runtime-a-warning-in-the-log). What prints nothing is under [Traps](#traps), by symptom.
9
10
 
10
11
  **Install and types**
@@ -20,8 +21,13 @@ each through. It prints one warning of its own, under
20
21
  - [`TypeError: defineIdempotency: "…" has a lease of …; it is a whole number of milliseconds, and must be at least 1`](#typeerror-defineidempotency--has-a-lease-of--it-is-a-whole-number-of-milliseconds-and-must-be-at-least-1)
21
22
  - [`TypeError: defineIdempotency: an idempotent operation needs a name, for its keys`](#typeerror-defineidempotency-an-idempotent-operation-needs-a-name-for-its-keys)
22
23
  - [`RedisError: Connection closed`](#rediserror-connection-closed)
24
+ - [`TypeError: redisStore: the definition "…" is not the one that wired this limit`](#typeerror-redisstore-the-definition--is-not-the-one-that-wired-this-limit)
23
25
  - [`TypeError: connectRedis: this URI is already connected with other options. Pass the same options everywhere, or close the first connection.`](#typeerror-connectredis-this-uri-is-already-connected-with-other-options-pass-the-same-options-everywhere-or-close-the-first-connection)
24
26
 
27
+ - [`TypeError: @alxia/redis: this @nxgt/redis handle wires N Redis instances (…), and one is needed.`](#typeerror-alxiaredis-this-nxgtredis-handle-wires-n-redis-instances--and-one-is-needed)
28
+ - [`TypeError: defineRedis: instance "default" wires no cache, no channel, no rate limit and no idempotency. Pass the module that exports them, or drop the instance.`](#typeerror-defineredis-instance-default-wires-no-cache-no-channel-no-rate-limit-and-no-idempotency-pass-the-module-that-exports-them-or-drop-the-instance)
29
+ - [`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.`](#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)
30
+
25
31
  **Runtime: a 500, with this in the log**
26
32
 
27
33
  - [`TypeError: defineRateLimit: "…" has a burst of … and a per of …ms; burst × per must be at most 9007199254740 for the script to count exactly`](#typeerror-defineratelimit--has-a-burst-of--and-a-per-of-ms-burst--per-must-be-at-most-9007199254740-for-the-script-to-count-exactly)
@@ -55,6 +61,9 @@ each through. It prints one warning of its own, under
55
61
  - [Two limits count each other's requests](#two-limits-count-each-others-requests)
56
62
  - [A `401` or a `429` is replayed, with `Idempotent-Replayed: true`, after the client fixed it](#a-401-or-a-429-is-replayed-with-idempotent-replayed-true-after-the-client-fixed-it)
57
63
  - [One client gets another client's response](#one-client-gets-another-clients-response)
64
+ - [Counts and kept responses vanish after moving to a handle with a `prefix`](#counts-and-kept-responses-vanish-after-moving-to-a-handle-with-a-prefix)
65
+ - [Counts restart after moving a rate limit to the wired form](#counts-restart-after-moving-a-rate-limit-to-the-wired-form)
66
+ - [The handle is closed while something still uses it](#the-handle-is-closed-while-something-still-uses-it)
58
67
 
59
68
  ## Install and types
60
69
 
@@ -222,6 +231,80 @@ await check.close();
222
231
  const connection = await connectRedis(Bun.env['REDIS_URL']!);
223
232
  ```
224
233
 
234
+ ### `TypeError: @alxia/redis: this @nxgt/redis handle wires N Redis instances (…), and one is needed.`
235
+
236
+ **When:** a handle wiring several instances is given to `redis()`,
237
+ `redisStore`, `redisCacheStore`, `idempotency` or `redisCheck`, at startup.
238
+
239
+ **Why:** the keys of one deployment live on one Redis; which of several is
240
+ meant is not guessed.
241
+
242
+ **Fix:** give the bare client of the one, with the prefix in the `name`:
243
+
244
+ ```ts
245
+ redisStore(handle.clients.cache, { name: 'shop:api' });
246
+ ```
247
+
248
+ or wire one instance per handle.
249
+
250
+ ### `TypeError: defineRedis: instance "default" wires no cache, no channel, no rate limit and no idempotency. Pass the module that exports them, or drop the instance.`
251
+
252
+ **When:** `defineRedis({ uri, prefix })` is written only to give the stores
253
+ and `idempotency` a prefix. `@nxgt/redis` 0.5 said `wires no cache and no
254
+ channel`; 0.6 counts the rate limits and the idempotency it can now wire too.
255
+
256
+ **Why:** `@nxgt/redis` refuses a handle that wires nothing.
257
+
258
+ **Fix:** wire at least one of the four on it: a cache, which `redis(handle)`
259
+ then puts in the context as `caches`, a channel, a rate limit or an idempotency:
260
+
261
+ ```ts
262
+ defineRedis({ uri, prefix: 'shop', caches: { users } });
263
+ defineRedis({ uri, prefix: 'shop', limits: { api } });
264
+ ```
265
+
266
+ ### `TypeError: redisStore: the definition "…" is not the one that wired this limit`
267
+
268
+ **When:** `redisStore(handle.limits.api, other)` is given a definition whose
269
+ `name` is not the one `handle.limits.api` was wired from. The message adds the
270
+ key the limit counts under:
271
+
272
+ ```text
273
+ TypeError: redisStore: the definition "other" is not the one that wired this limit (it counts under "shop:api:probe")
274
+ ```
275
+
276
+ **Why:** the definition gives `rateLimit` its `limit` and `windowMs`, and a
277
+ definition of another limit would write headers for a rate Redis does not
278
+ count by. A bound limit's keys are `<prefix>:<name>:<key>`, which is how the
279
+ store checks the pair.
280
+
281
+ **Fix:** give the definition `handle.limits.api` was wired from:
282
+
283
+ ```ts
284
+ app.use(rateLimit({ store: redisStore(handle.limits.api, api) }));
285
+ ```
286
+
287
+ ### `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.`
288
+
289
+ **When:** the `defineRedis(...)` of an app that gives `caches`, `limits` and
290
+ `idempotency` one `name` on one instance: here the cache exported as `users`
291
+ and the rate limit exported as `login`, both named `user`. The sentence says
292
+ which two, and the export keys they are wired under. It is thrown at wiring
293
+ time, before the app serves; with the same kind twice it reads `wires the rate
294
+ limit named "user" twice, under "a" and "b"`.
295
+
296
+ **Why:** a cache, a rate limit and an idempotency all write
297
+ `<prefix>:<name>:<key>`, so one name is one keyspace, and they would
298
+ overwrite each other's values or meet as `WRONGTYPE`. The same wiring that lets
299
+ `redisStore(handle.limits.login)` and `idempotency(handle.idempotency.orders)`
300
+ write the layout `@nxgt/redis` writes refuses the clash.
301
+
302
+ **Fix:** rename one: the **definition's** `name`, not its export:
303
+
304
+ ```ts
305
+ export const login = defineRateLimit({ name: 'login.limit', key: (ip: string) => ip, limit: 5, per: 60_000 });
306
+ ```
307
+
225
308
  ## Runtime: a 500, with this in the log
226
309
 
227
310
  Each of these makes the request answer `500 {"error":"internal"}`; the
@@ -239,7 +322,7 @@ TypeError: defineRateLimit: "api:1000000/31536000000" has a burst of 1000000 and
239
322
 
240
323
  **Why:** the script counts in exact integers; `limit × windowMs` past that
241
324
  bound would lose precision. `redisStore` hands each policy to
242
- `@nxgt/redis-guard` when it first counts under it, and a refused policy is
325
+ `@nxgt/redis` when it first counts under it, and a refused policy is
243
326
  not kept, so it is checked again, and refused again, on each request.
244
327
 
245
328
  **Fix:** state the same rate over a shorter window:
@@ -451,7 +534,7 @@ number, or below 1.
451
534
  TypeError: defineRateLimit: "api:5/1.5" has a per of 1.5; it is a whole number of milliseconds, and must be at least 1
452
535
  ```
453
536
 
454
- **Why:** `redisStore` hands each `limit`/`windowMs` to `@nxgt/redis-guard`
537
+ **Why:** `redisStore` hands each `limit`/`windowMs` to `@nxgt/redis`
455
538
  the first time it counts under it, and the guard counts in whole
456
539
  milliseconds. `rateLimit` never passes such a value: it refuses it at
457
540
  startup, with [`TypeError: rateLimit: windowMs 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-).
@@ -650,3 +733,56 @@ idempotency(connection.client, {
650
733
 
651
734
  [Scope](guide/idempotency.md#scope-whose-key-it-is) shows the `ip` option
652
735
  behind a proxy.
736
+
737
+ ### Counts and kept responses vanish after moving to a handle with a `prefix`
738
+
739
+ **Symptom:** after `redisStore(client, …)` becomes `redisStore(handle, …)`,
740
+ rate-limit counts start from zero, kept responses miss and an idempotent
741
+ repeat runs again.
742
+
743
+ **Why:** the handle's `prefix` is in front of every key, so the keys are
744
+ new: `api:…` is now `shop:api:…`. The old ones expire on their own.
745
+
746
+ **Fix:** none is needed beyond waiting for the longest `ttl`; switch during a
747
+ quiet period, or keep the bare client until then.
748
+
749
+ ### Counts restart after moving a rate limit to the wired form
750
+
751
+ **Symptom:** after `redisStore(handle, { name: 'api' })` becomes
752
+ `redisStore(handle.limits.api)`, every client's allowance starts full again.
753
+ An idempotent route does not do this: `idempotency(handle, { name: 'orders' })`
754
+ and `idempotency(handle.idempotency.orders)` write the same keys, so a kept
755
+ response is still replayed.
756
+
757
+ **Why:** the two layouts differ. The by-name store keeps one bucket per policy,
758
+ `shop:api:<limit>/<windowMs>:<key>` and `shop:api:policies`; the wired limit
759
+ is `@nxgt/redis`'s own, `shop:api:<key>`, the layout every other consumer of
760
+ the handle shares. The old buckets are not read any more and expire by
761
+ themselves.
762
+
763
+ **Fix:** none is needed but the wait, within `burst × per ÷ limit` of the
764
+ limit; switch at a quiet moment. `redisStore(handle.limits.api)` counts by the
765
+ definition's rate, not by the `limit` and `windowMs` the middleware is given,
766
+ which only write the headers: if the `RateLimit-Policy` header says another
767
+ rate than the limit enforces, give the definition as the second argument,
768
+ `redisStore(handle.limits.api, api)`, and leave `limit` and `windowMs` out of
769
+ `rateLimit`: the store declares the definition's rate and the headers come
770
+ from it. A `limit` or `windowMs` that differs from it throws
771
+ [`rateLimit: limit … differs from the store's policy of …`](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/troubleshooting.md#typeerror-ratelimit-limit--differs-from-the-stores-policy-of-)
772
+ at startup; a definition that is not the one that wired the limit throws
773
+ `redisStore: the definition "…" is not the one that wired this limit`.
774
+
775
+ ### The handle is closed while something still uses it
776
+
777
+ **Symptom:** after the app stopped, or in a second app sharing the handle,
778
+ commands fail with `RedisError: Connection closed`.
779
+
780
+ **Why:** `redis(handle)` closes the handle when its app stops. Two apps
781
+ given one handle close it with the first.
782
+
783
+ **Fix:** pass `{ close: false }` to every `redis(handle, …)` but the one that
784
+ owns the handle's life, or close it yourself:
785
+
786
+ ```ts
787
+ app.plugin(redis(handle, { close: false }));
788
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alxia/redis",
3
- "version": "0.2.0",
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",
3
+ "version": "0.4.0",
4
+ "description": "Redis for alxia on @nxgt/redis: a rate-limit store every process shares, idempotent routes, caches and locks in the context",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -41,20 +41,18 @@
41
41
  ]
42
42
  },
43
43
  "devDependencies": {
44
- "@alxia/cache": "^0.2.0",
45
- "@alxia/core": "^0.4.0",
46
- "@alxia/rate-limit": "^0.2.0",
47
- "@nxgt/redis": "^0.3.1",
48
- "@nxgt/redis-guard": "^0.3.1",
44
+ "@alxia/cache": "^0.3.0",
45
+ "@alxia/core": "^0.5.0",
46
+ "@alxia/rate-limit": "^0.4.0",
47
+ "@nxgt/redis": "^0.6.0",
49
48
  "@types/bun": "^1.4.2",
50
49
  "zod": "^4.6.5"
51
50
  },
52
51
  "peerDependencies": {
53
- "@alxia/cache": "^0.2.0",
54
- "@alxia/core": "^0.4.0",
55
- "@alxia/rate-limit": "^0.2.0",
56
- "@nxgt/redis": "^0.3.1",
57
- "@nxgt/redis-guard": "^0.3.1",
52
+ "@alxia/cache": "^0.3.0",
53
+ "@alxia/core": "^0.5.0",
54
+ "@alxia/rate-limit": "^0.4.0",
55
+ "@nxgt/redis": "^0.5.0 || ^0.6.0",
58
56
  "typescript": "^6.0.3 || ^7.0.0",
59
57
  "zod": "^4.6.5"
60
58
  },