@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/README.md +81 -7
- package/dist/cache-store.d.ts +6 -3
- package/dist/cache-store.d.ts.map +1 -1
- package/dist/check.d.ts +19 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/context.d.ts +33 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/handle.d.ts +31 -0
- package/dist/handle.d.ts.map +1 -0
- package/dist/idempotency-response.d.ts +26 -0
- package/dist/idempotency-response.d.ts.map +1 -0
- package/dist/idempotency.d.ts +29 -7
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +177 -62
- package/dist/index.js.map +10 -7
- package/dist/store.d.ts +30 -4
- package/dist/store.d.ts.map +1 -1
- package/docs/README.md +9 -1
- package/docs/guide/caches-and-locks.md +14 -0
- package/docs/guide/connecting.md +131 -1
- package/docs/guide/idempotency.md +10 -3
- package/docs/guide/rate-limits.md +8 -2
- package/docs/roadmap.md +48 -8
- package/docs/troubleshooting.md +113 -5
- package/package.json +10 -12
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,
|
|
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.
|
package/docs/guide/connecting.md
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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)
|
|
27
|
-
|
|
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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
|
7
|
-
each through
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "Redis for alxia on @nxgt/redis
|
|
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.
|
|
45
|
-
"@alxia/core": "^0.
|
|
46
|
-
"@alxia/rate-limit": "^0.
|
|
47
|
-
"@nxgt/redis": "^0.
|
|
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.
|
|
54
|
-
"@alxia/core": "^0.
|
|
55
|
-
"@alxia/rate-limit": "^0.
|
|
56
|
-
"@nxgt/redis": "^0.
|
|
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
|
},
|