@alxia/redis 0.2.0 → 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,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({ 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
 
@@ -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,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
package/docs/roadmap.md CHANGED
@@ -7,14 +7,16 @@ 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
 
17
- Nothing scheduled yet.
15
+ - **A rate limit that reads its policy from the store.** With
16
+ `redisStore(handle.limits.api)` the rate lives in the definition, and
17
+ `rateLimit({ limit, windowMs })` still repeats it for its headers. The store
18
+ telling `rateLimit` its own policy, so the numbers are written once, is
19
+ planned for `@alxia/rate-limit` and this package together.
18
20
 
19
21
  ## Later
20
22
 
@@ -23,14 +25,52 @@ Nothing scheduled yet.
23
25
  ## Not planned
24
26
 
25
27
  - **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.
28
+ [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis),
29
+ never a rewrite of it: its scripts, keys and errors are what it runs.
29
30
  They run on Bun's built-in `RedisClient`, so there is no driver to
30
31
  install, and it does not run on Node.
31
32
 
32
33
  ## Shipped
33
34
 
35
+ ### 0.3.0, continued: wired guards
36
+
37
+ - **On `@nxgt/redis` 0.6.** The peer range is `^0.5.0 || ^0.6.0`: the new forms
38
+ need no more than 0.5's types, and 0.6 only adds `handle.limits` and
39
+ `handle.idempotency` to wire them.
40
+ - **A rate limit and an idempotency defined once.** `redisStore(handle.limits.api)`
41
+ and `idempotency(handle.idempotency.orders)` take what `defineRedis`
42
+ wired, so the definition lives in one place and writes the keys
43
+ `@nxgt/redis` writes, `<prefix>:<name>:<key>`: every consumer of the handle
44
+ shares the count. `idempotencyResult` is the schema of the wired idempotency.
45
+ Both older forms stay.
46
+
47
+ ### 0.3.0
48
+
49
+ - **On `@nxgt/redis` 0.5 alone.** The rate limits and the idempotency that
50
+ `@nxgt/redis-guard` held live in `@nxgt/redis` now, and `@nxgt/redis-guard`
51
+ is no longer a peer.
52
+ - **One form for `idempotency`.** The middleware's type is a plain
53
+ `(ctx, next)` function, and `app.plugin(idempotency(…))`, deprecated in
54
+ 0.2.0, is gone: give it to `use(…)`.
55
+ - **An `@nxgt/redis` handle everywhere.** `redis(handle)` takes the handle
56
+ `openRedis(defineRedis({ … }))` gives: typed `caches` from its scopes, a
57
+ `lock` and every key under its `prefix`, and the handle closed once in
58
+ `onStop`, after the drain (`{ close: false }` to opt out). `redisStore`,
59
+ `redisCacheStore` and `idempotency` take it where they take a client and
60
+ put its prefix in front of their keys, and `redisCheck` is a readiness
61
+ check for `health()`. The bare `RedisClient` forms are unchanged.
62
+
63
+ ### 0.2.0
64
+
65
+ - **Middlewares, under the same names.** `app.use(idempotency(client, …))`
66
+ replaces `app.plugin(idempotency(…))`, which stayed, deprecated, until 0.3.0;
67
+ `IdempotencyMiddleware` is the type it returns. A request no route matches
68
+ passes through it, never kept.
69
+ - **A client no one can tell apart is not shared.** A request with no
70
+ `ctx.ip` and no `scope` runs unguarded, nothing stored or replayed, and
71
+ the middleware warns once, instead of keying every such client to
72
+ `anyone`, where one could be replayed another's response.
73
+
34
74
  ### 0.1.0
35
75
 
36
76
  - **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**
@@ -22,6 +23,10 @@ each through. It prints one warning of its own, under
22
23
  - [`RedisError: Connection closed`](#rediserror-connection-closed)
23
24
  - [`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
25
 
26
+ - [`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)
27
+ - [`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)
28
+ - [`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)
29
+
25
30
  **Runtime: a 500, with this in the log**
26
31
 
27
32
  - [`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 +60,9 @@ each through. It prints one warning of its own, under
55
60
  - [Two limits count each other's requests](#two-limits-count-each-others-requests)
56
61
  - [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
62
  - [One client gets another client's response](#one-client-gets-another-clients-response)
63
+ - [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)
64
+ - [Counts restart after moving a rate limit to the wired form](#counts-restart-after-moving-a-rate-limit-to-the-wired-form)
65
+ - [The handle is closed while something still uses it](#the-handle-is-closed-while-something-still-uses-it)
58
66
 
59
67
  ## Install and types
60
68
 
@@ -222,6 +230,59 @@ await check.close();
222
230
  const connection = await connectRedis(Bun.env['REDIS_URL']!);
223
231
  ```
224
232
 
233
+ ### `TypeError: @alxia/redis: this @nxgt/redis handle wires N Redis instances (…), and one is needed.`
234
+
235
+ **When:** a handle wiring several instances is given to `redis()`,
236
+ `redisStore`, `redisCacheStore`, `idempotency` or `redisCheck`, at startup.
237
+
238
+ **Why:** the keys of one deployment live on one Redis; which of several is
239
+ meant is not guessed.
240
+
241
+ **Fix:** give the bare client of the one, with the prefix in the `name`:
242
+
243
+ ```ts
244
+ redisStore(handle.clients.cache, { name: 'shop:api' });
245
+ ```
246
+
247
+ or wire one instance per handle.
248
+
249
+ ### `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.`
250
+
251
+ **When:** `defineRedis({ uri, prefix })` is written only to give the stores
252
+ and `idempotency` a prefix. `@nxgt/redis` 0.5 said `wires no cache and no
253
+ channel`; 0.6 counts the rate limits and the idempotency it can now wire too.
254
+
255
+ **Why:** `@nxgt/redis` refuses a handle that wires nothing.
256
+
257
+ **Fix:** wire at least one of the four on it: a cache, which `redis(handle)`
258
+ then puts in the context as `caches`, a channel, a rate limit or an idempotency:
259
+
260
+ ```ts
261
+ defineRedis({ uri, prefix: 'shop', caches: { users } });
262
+ defineRedis({ uri, prefix: 'shop', limits: { api } });
263
+ ```
264
+
265
+ ### `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.`
266
+
267
+ **When:** the `defineRedis(...)` of an app that gives `caches`, `limits` and
268
+ `idempotency` one `name` on one instance: here the cache exported as `users`
269
+ and the rate limit exported as `login`, both named `user`. The sentence says
270
+ which two, and the export keys they are wired under. It is thrown at wiring
271
+ time, before the app serves; with the same kind twice it reads `wires the rate
272
+ limit named "user" twice, under "a" and "b"`.
273
+
274
+ **Why:** a cache, a rate limit and an idempotency all write
275
+ `<prefix>:<name>:<key>`, so one name is one keyspace, and they would
276
+ overwrite each other's values or meet as `WRONGTYPE`. The same wiring that lets
277
+ `redisStore(handle.limits.login)` and `idempotency(handle.idempotency.orders)`
278
+ write the layout `@nxgt/redis` writes refuses the clash.
279
+
280
+ **Fix:** rename one: the **definition's** `name`, not its export:
281
+
282
+ ```ts
283
+ export const login = defineRateLimit({ name: 'login.limit', key: (ip: string) => ip, limit: 5, per: 60_000 });
284
+ ```
285
+
225
286
  ## Runtime: a 500, with this in the log
226
287
 
227
288
  Each of these makes the request answer `500 {"error":"internal"}`; the
@@ -239,7 +300,7 @@ TypeError: defineRateLimit: "api:1000000/31536000000" has a burst of 1000000 and
239
300
 
240
301
  **Why:** the script counts in exact integers; `limit × windowMs` past that
241
302
  bound would lose precision. `redisStore` hands each policy to
242
- `@nxgt/redis-guard` when it first counts under it, and a refused policy is
303
+ `@nxgt/redis` when it first counts under it, and a refused policy is
243
304
  not kept, so it is checked again, and refused again, on each request.
244
305
 
245
306
  **Fix:** state the same rate over a shorter window:
@@ -451,7 +512,7 @@ number, or below 1.
451
512
  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
513
  ```
453
514
 
454
- **Why:** `redisStore` hands each `limit`/`windowMs` to `@nxgt/redis-guard`
515
+ **Why:** `redisStore` hands each `limit`/`windowMs` to `@nxgt/redis`
455
516
  the first time it counts under it, and the guard counts in whole
456
517
  milliseconds. `rateLimit` never passes such a value: it refuses it at
457
518
  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 +711,50 @@ idempotency(connection.client, {
650
711
 
651
712
  [Scope](guide/idempotency.md#scope-whose-key-it-is) shows the `ip` option
652
713
  behind a proxy.
714
+
715
+ ### Counts and kept responses vanish after moving to a handle with a `prefix`
716
+
717
+ **Symptom:** after `redisStore(client, …)` becomes `redisStore(handle, …)`,
718
+ rate-limit counts start from zero, kept responses miss and an idempotent
719
+ repeat runs again.
720
+
721
+ **Why:** the handle's `prefix` is in front of every key, so the keys are
722
+ new: `api:…` is now `shop:api:…`. The old ones expire on their own.
723
+
724
+ **Fix:** none is needed beyond waiting for the longest `ttl`; switch during a
725
+ quiet period, or keep the bare client until then.
726
+
727
+ ### Counts restart after moving a rate limit to the wired form
728
+
729
+ **Symptom:** after `redisStore(handle, { name: 'api' })` becomes
730
+ `redisStore(handle.limits.api)`, every client's allowance starts full again.
731
+ An idempotent route does not do this: `idempotency(handle, { name: 'orders' })`
732
+ and `idempotency(handle.idempotency.orders)` write the same keys, so a kept
733
+ response is still replayed.
734
+
735
+ **Why:** the two layouts differ. The by-name store keeps one bucket per policy,
736
+ `shop:api:<limit>/<windowMs>:<key>` and `shop:api:policies`; the wired limit
737
+ is `@nxgt/redis`'s own, `shop:api:<key>`, the layout every other consumer of
738
+ the handle shares. The old buckets are not read any more and expire by
739
+ themselves.
740
+
741
+ **Fix:** none is needed but the wait, within `burst × per ÷ limit` of the
742
+ limit; switch at a quiet moment. `redisStore(handle.limits.api)` counts by the
743
+ definition's rate, not by the `limit` and `windowMs` the middleware is given,
744
+ which only write the headers: if the `RateLimit-Policy` header says another
745
+ rate than the limit enforces, make the two numbers equal.
746
+
747
+ ### The handle is closed while something still uses it
748
+
749
+ **Symptom:** after the app stopped, or in a second app sharing the handle,
750
+ commands fail with `RedisError: Connection closed`.
751
+
752
+ **Why:** `redis(handle)` closes the handle when its app stops. Two apps
753
+ given one handle close it with the first.
754
+
755
+ **Fix:** pass `{ close: false }` to every `redis(handle, …)` but the one that
756
+ owns the handle's life, or close it yourself:
757
+
758
+ ```ts
759
+ app.plugin(redis(handle, { close: false }));
760
+ ```
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.3.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.3.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.3.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
  },