@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/README.md +85 -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 +186 -62
- package/dist/index.js.map +10 -7
- package/dist/store.d.ts +47 -5
- 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 +32 -2
- package/docs/roadmap.md +55 -7
- package/docs/troubleshooting.md +141 -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({ 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(
|
|
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,9 @@ 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
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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)
|
|
27
|
-
|
|
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
|
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**
|
|
@@ -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
|
|
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
|
|
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.
|
|
4
|
-
"description": "Redis for alxia on @nxgt/redis
|
|
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.
|
|
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.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.
|
|
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.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
|
},
|