@alxia/redis 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -16
- 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 +35 -4
- 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 +40 -8
- 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 +198 -61
- 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 +10 -2
- package/docs/guide/caches-and-locks.md +22 -8
- package/docs/guide/connecting.md +136 -6
- package/docs/guide/idempotency.md +49 -26
- package/docs/guide/rate-limits.md +10 -4
- package/docs/guide/response-cache.md +3 -3
- package/docs/guide/testing.md +11 -14
- package/docs/roadmap.md +48 -5
- package/docs/troubleshooting.md +157 -10
- package/package.json +10 -13
package/docs/README.md
CHANGED
|
@@ -8,11 +8,19 @@ a realistic example for each.
|
|
|
8
8
|
|
|
9
9
|
| Page | Read it when |
|
|
10
10
|
| --- | --- |
|
|
11
|
-
| [Connecting](guide/connecting.md) | opening the client every export takes, deciding when to build
|
|
11
|
+
| [Connecting](guide/connecting.md) | opening the client every export takes, deciding when to build what takes it, failing fast when Redis is down, closing it, naming keys so features never share them, or giving every export one `@nxgt/redis` handle: a prefix on every key, a health check, closing on stop |
|
|
12
12
|
| [Rate limits](guide/rate-limits.md) | sharing an `@alxia/rate-limit` count across processes, understanding why GCRA lets a burst through, choosing `name`, or resetting a key |
|
|
13
13
|
| [Response cache](guide/response-cache.md) | sharing `@alxia/cache` responses across processes, invalidating them everywhere, or knowing what is stored in Redis |
|
|
14
14
|
| [Idempotency](guide/idempotency.md) | making a `POST` run once per `Idempotency-Key`, reading the `409`, `422` and `400`, scoping keys by user, or ordering it with a rate limit or an auth check |
|
|
15
15
|
| [Caches and locks](guide/caches-and-locks.md) | reading typed caches and a lock from the context, catching `LOCK_HELD`, or using `redis()` beside `@alxia/cache` |
|
|
16
|
-
| [Testing](guide/testing.md) | writing specs against a real Redis, giving `app.request()` a client address, or
|
|
16
|
+
| [Testing](guide/testing.md) | writing specs against a real Redis, giving `app.request()` a client address, or checking which routes the refusals reach |
|
|
17
17
|
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a limit, a cache or a replay does not behave as you expected |
|
|
18
18
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
|
19
|
+
|
|
20
|
+
## Recipes
|
|
21
|
+
|
|
22
|
+
A task that crosses packages, in [the repository's recipes](https://github.com/softistx/alxia/blob/develop/docs/recipes/README.md), each with a complete example:
|
|
23
|
+
|
|
24
|
+
- [Caching and rate limiting with Redis](https://github.com/softistx/alxia/blob/develop/docs/recipes/caching-and-rate-limiting.md): a limit and a response cache shared by every process
|
|
25
|
+
- [Test an alxia app](https://github.com/softistx/alxia/blob/develop/docs/recipes/testing.md): `app.request`, a middleware alone, the typed client, sockets, Redis
|
|
26
|
+
- [Health checks and graceful shutdown](https://github.com/softistx/alxia/blob/develop/docs/recipes/health-and-shutdown.md): probes, the drain, Docker and Kubernetes
|
|
@@ -17,7 +17,7 @@ const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, sch
|
|
|
17
17
|
const loadUser = async (id: string) => ({ id, name: 'Ada' }); // your database
|
|
18
18
|
|
|
19
19
|
const app = alxia()
|
|
20
|
-
.
|
|
20
|
+
.plugin(redis(connection.client, { caches: { users } }))
|
|
21
21
|
.get('/users/:id', async ({ caches, params, reply }) => {
|
|
22
22
|
const user = await caches.users.remember(params.id, () => loadUser(params.id)); // typed by User
|
|
23
23
|
return reply.ok(user);
|
|
@@ -66,7 +66,7 @@ import type { RedisClient } from 'bun';
|
|
|
66
66
|
|
|
67
67
|
export function withCaches<const Caches extends Record<string, AnyCache>>(client: RedisClient, caches: Caches) {
|
|
68
68
|
return alxia()
|
|
69
|
-
.
|
|
69
|
+
.plugin(redis(client, { caches }))
|
|
70
70
|
.get('/caches', ({ caches: bound, reply }) => reply(200, Object.keys(bound)));
|
|
71
71
|
}
|
|
72
72
|
```
|
|
@@ -88,7 +88,7 @@ the name you give it in `caches`:
|
|
|
88
88
|
A realistic case, a profile read through the cache and forgotten on write:
|
|
89
89
|
|
|
90
90
|
```ts
|
|
91
|
-
import { alxia } from '@alxia/core';
|
|
91
|
+
import { alxia, validate } from '@alxia/core';
|
|
92
92
|
import { redis } from '@alxia/redis';
|
|
93
93
|
import { connectRedis, defineCache } from '@nxgt/redis';
|
|
94
94
|
import { z } from 'zod';
|
|
@@ -100,12 +100,12 @@ const profiles = defineCache({ name: 'profile', key: (id: string) => id, ttl: 60
|
|
|
100
100
|
const table = new Map<string, z.input<typeof Profile>>([['1', { id: '1', name: 'Ada' }]]);
|
|
101
101
|
|
|
102
102
|
const app = alxia()
|
|
103
|
-
.
|
|
103
|
+
.plugin(redis(connection.client, { caches: { profiles } }))
|
|
104
104
|
.get('/profiles/:id', async ({ caches, params, reply }) => {
|
|
105
105
|
const profile = await caches.profiles.remember(params.id, async () => table.get(params.id) ?? { id: params.id, name: '?' });
|
|
106
106
|
return reply.ok(profile); // plan is filled in: 'free'
|
|
107
107
|
})
|
|
108
|
-
.put('/profiles/:id', { body: Profile.omit({ id: true }) }, async ({ caches, params, body, reply }) => {
|
|
108
|
+
.put('/profiles/:id', validate({ body: Profile.omit({ id: true }) }), async ({ caches, params, body, reply }) => {
|
|
109
109
|
table.set(params.id, { id: params.id, ...body });
|
|
110
110
|
await caches.profiles.delete(params.id); // the next read loads it again
|
|
111
111
|
return reply(204, undefined);
|
|
@@ -142,7 +142,7 @@ const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
|
142
142
|
const sendInvoices = async () => 12; // the work that must not run twice at once
|
|
143
143
|
|
|
144
144
|
const app = alxia()
|
|
145
|
-
.
|
|
145
|
+
.plugin(redis(connection.client))
|
|
146
146
|
.post('/invoices/run', async ({ lock, reply }) => {
|
|
147
147
|
try {
|
|
148
148
|
const sent = await lock('invoices', sendInvoices, { ttl: 60_000 });
|
|
@@ -176,7 +176,7 @@ import { connectRedis } from '@nxgt/redis';
|
|
|
176
176
|
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
177
177
|
|
|
178
178
|
const app = alxia()
|
|
179
|
-
.
|
|
179
|
+
.plugin(redis(connection.client))
|
|
180
180
|
.post('/articles/:id/views', async ({ redis, params, reply }) => {
|
|
181
181
|
const views = await redis.incr(`views:${params.id}`);
|
|
182
182
|
return reply(200, { views });
|
|
@@ -201,7 +201,7 @@ const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, sch
|
|
|
201
201
|
const loadUser = async (id: string) => ({ id, name: 'Ada' });
|
|
202
202
|
|
|
203
203
|
const app = alxia()
|
|
204
|
-
.
|
|
204
|
+
.plugin(redis(connection.client, { caches: { users } }))
|
|
205
205
|
.use(cache({ ttl: 60 }))
|
|
206
206
|
.get('/users/:id', async ({ caches, cache, params, reply }) => {
|
|
207
207
|
cache.tag(`user:${params.id}`); // the response cache
|
|
@@ -213,6 +213,20 @@ Code written for the earlier name — `ctx.cache.users` — no longer compiles;
|
|
|
213
213
|
[Troubleshooting](../troubleshooting.md#property-cache-does-not-exist-on-type---rediscontext-) has the
|
|
214
214
|
message and the rename.
|
|
215
215
|
|
|
216
|
+
## From an `@nxgt/redis` handle
|
|
217
|
+
|
|
218
|
+
`redis(handle)` takes the handle `openRedis(defineRedis({ …, caches }))` gives
|
|
219
|
+
instead of a client and a `caches` option: the context's `caches` are the
|
|
220
|
+
handle's `cache` scope, the `lock` is under its `prefix`, and the handle is
|
|
221
|
+
closed when the app stops. See [Connecting](connecting.md#with-an-nxgtredis-handle).
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
const handle = await openRedis(defineRedis({ uri: Bun.env['REDIS_URL']!, prefix: 'shop', caches: { users } }));
|
|
225
|
+
alxia()
|
|
226
|
+
.plugin(redis(handle))
|
|
227
|
+
.get('/users/:id', ({ caches, params, reply }) => reply.ok(caches.users.get(params.id))); // shop:user:<id>
|
|
228
|
+
```
|
|
229
|
+
|
|
216
230
|
## Next
|
|
217
231
|
|
|
218
232
|
- [Connecting](connecting.md) — the client, and closing it.
|
package/docs/guide/connecting.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Connecting
|
|
2
2
|
|
|
3
3
|
This page covers the one thing every export of `@alxia/redis` takes first:
|
|
4
|
-
a Bun `RedisClient`, how to open it, when to
|
|
4
|
+
a Bun `RedisClient`, how to open it, when to build what takes it, and
|
|
5
5
|
how to close it.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
@@ -12,7 +12,7 @@ import { connectRedis } from '@nxgt/redis';
|
|
|
12
12
|
const connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
|
|
13
13
|
|
|
14
14
|
const app = alxia()
|
|
15
|
-
.
|
|
15
|
+
.plugin(redis(connection.client))
|
|
16
16
|
.get('/ping', async ({ redis, reply }) => reply(200, await redis.ping()));
|
|
17
17
|
|
|
18
18
|
app.listen({ port: 3000 });
|
|
@@ -25,8 +25,8 @@ Every export takes Bun's own `RedisClient` as its first argument:
|
|
|
25
25
|
```ts
|
|
26
26
|
redisStore(client: RedisClient, options: RedisStoreOptions): RateLimitStore
|
|
27
27
|
redisCacheStore(client: RedisClient, options: RedisCacheStoreOptions): CacheStore
|
|
28
|
-
idempotency(client: RedisClient, options: IdempotencyOptions) // a
|
|
29
|
-
redis(client: RedisClient, options?: RedisContextOptions) // a plugin
|
|
28
|
+
idempotency(client: RedisClient, options: IdempotencyOptions) // a middleware
|
|
29
|
+
redis(client: RedisClient, options?: RedisContextOptions) // a plugin, given to app.plugin
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
Any `RedisClient` will do. `connectRedis` from
|
|
@@ -48,7 +48,7 @@ One client is enough for the whole app: the rate-limit store, the cache
|
|
|
48
48
|
store, idempotency and `redis()` all send ordinary commands over it. None
|
|
49
49
|
of them subscribes, so none needs a connection of its own.
|
|
50
50
|
|
|
51
|
-
##
|
|
51
|
+
## Build them after you connect
|
|
52
52
|
|
|
53
53
|
Each export binds the client it is given when it is called. Open the
|
|
54
54
|
connection first, then build the app:
|
|
@@ -143,6 +143,135 @@ process.on('SIGTERM', async () => {
|
|
|
143
143
|
`closeRedis()` from `@nxgt/redis` closes every client the process opened
|
|
144
144
|
through `connectRedis` — the end of a test file, say.
|
|
145
145
|
|
|
146
|
+
## With an `@nxgt/redis` handle
|
|
147
|
+
|
|
148
|
+
`openRedis(defineRedis({ … }))` opens the client and wires the caches, a
|
|
149
|
+
`prefix` and a lock in one object, the handle. Every export that takes a
|
|
150
|
+
client takes the handle too, and the deployment's `prefix` is then in front of
|
|
151
|
+
every key it writes:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import { cache } from '@alxia/cache';
|
|
155
|
+
import { alxia, health } from '@alxia/core';
|
|
156
|
+
import { idempotency, redis, redisCacheStore, redisCheck } from '@alxia/redis';
|
|
157
|
+
import { defineCache, defineRedis, openRedis } from '@nxgt/redis';
|
|
158
|
+
import { z } from 'zod';
|
|
159
|
+
|
|
160
|
+
const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: z.object({ id: z.string(), name: z.string() }) });
|
|
161
|
+
const handle = await openRedis(
|
|
162
|
+
defineRedis({ uri: Bun.env['REDIS_URL']!, prefix: 'shop', caches: { users } }),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const app = alxia()
|
|
166
|
+
.plugin(health({ checks: { redis: redisCheck(handle) } }))
|
|
167
|
+
.plugin(redis(handle))
|
|
168
|
+
.use(idempotency(handle, { name: 'orders' }))
|
|
169
|
+
.use(cache({ ttl: 60, store: redisCacheStore(handle, { name: 'pages' }) }))
|
|
170
|
+
.get('/users/:id', ({ caches, params, reply }) => reply.ok(caches.users.get(params.id)));
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
| Export | Keys under `prefix: 'shop'` |
|
|
174
|
+
| --- | --- |
|
|
175
|
+
| `redisStore(handle, { name: 'api' })` | `shop:api:<limit>/<windowMs>:<key>`, `shop:api:policies` |
|
|
176
|
+
| `redisCacheStore(handle, { name: 'pages' })` | `shop:pages:response:<key>`, `shop:pages:tag:<tag>` |
|
|
177
|
+
| `idempotency(handle, { name: 'orders' })` | `shop:orders:<route>:<scope>:<Idempotency-Key>` |
|
|
178
|
+
| `redis(handle)` | `shop:<cache name>:<key>`, `lock:shop:<key>` |
|
|
179
|
+
|
|
180
|
+
The lock is `@nxgt/redis`'s, which writes `lock:` first and the prefix inside
|
|
181
|
+
it. `redis(handle)` puts `redis` (the client), `caches`, `lock` and `prefix` in
|
|
182
|
+
the context; `caches` is the handle's own `cache` scope, typed by each schema.
|
|
183
|
+
|
|
184
|
+
**Closing.** `redis(handle)` closes the handle in core's `onStop`, once, after
|
|
185
|
+
the requests in flight drained, so `listen`'s graceful shutdown ends with the
|
|
186
|
+
connection closed. Opt out with `redis(handle, { close: false })` when
|
|
187
|
+
something else owns the handle. The stores and `idempotency` never close it;
|
|
188
|
+
an app that does not mount `redis(handle)` closes the handle itself. A
|
|
189
|
+
`client` that `defineRedis` was given is never closed by `@nxgt/redis`.
|
|
190
|
+
|
|
191
|
+
**Health.** `redisCheck(handle)` is a check for `health({ checks })`: it
|
|
192
|
+
passes while every instance answers a `PING` and is down when one does not;
|
|
193
|
+
given a handle, `{ timeout }` bounds each instance's ping, in milliseconds
|
|
194
|
+
(2 s by default). It takes a bare client too, whose `PING` has no bound of
|
|
195
|
+
its own: `health({ timeout })` bounds the check.
|
|
196
|
+
|
|
197
|
+
**One instance.** The handle must wire exactly one Redis instance; a handle of
|
|
198
|
+
several is refused with a `TypeError` naming them. Give a bare client,
|
|
199
|
+
`handle.clients.<name>`, to the factory that lives on one of them, with the
|
|
200
|
+
prefix in its `name`.
|
|
201
|
+
|
|
202
|
+
**Defined once.** With `@nxgt/redis` 0.6 the rate limit and the idempotent
|
|
203
|
+
operation can be wired by `defineRedis` too, and `redisStore` and `idempotency`
|
|
204
|
+
take what it wired: [Defined once, in `defineRedis`](#defined-once-in-defineredis).
|
|
205
|
+
|
|
206
|
+
**Switching from a bare client** changes the keys: a rate-limit count, a kept
|
|
207
|
+
response or a replayable response written without the prefix is not found
|
|
208
|
+
under it, and the new keys start empty.
|
|
209
|
+
|
|
210
|
+
## Defined once, in `defineRedis`
|
|
211
|
+
|
|
212
|
+
`defineRedis` takes `limits` and `idempotency` next to `caches`, and the handle
|
|
213
|
+
exposes each as `handle.limits.<name>` and `handle.idempotency.<name>`, typed
|
|
214
|
+
from the definitions and writing `<prefix>:<name>:<key>`, the layout every
|
|
215
|
+
`@nxgt/redis` consumer of the deployment shares. Hand the wired entry to the
|
|
216
|
+
store and to the middleware instead of a name:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { alxia } from '@alxia/core';
|
|
220
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
221
|
+
import { idempotency, idempotencyResult, redisStore } from '@alxia/redis';
|
|
222
|
+
import { defineIdempotency, defineRateLimit, defineRedis, openRedis } from '@nxgt/redis';
|
|
223
|
+
|
|
224
|
+
const api = defineRateLimit({ name: 'api', key: (ip: string) => ip, limit: 100, per: 60_000 });
|
|
225
|
+
const orders = defineIdempotency({ name: 'orders', key: (id: string) => id, ttl: 86_400, schema: idempotencyResult });
|
|
226
|
+
|
|
227
|
+
const handle = await openRedis(
|
|
228
|
+
defineRedis({ uri: Bun.env['REDIS_URL']!, prefix: 'shop', limits: { api }, idempotency: { orders } }),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const app = alxia()
|
|
232
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(handle.limits.api) })) // shop:api:<address>
|
|
233
|
+
.use(idempotency(handle.idempotency.orders)) // shop:orders:<route>:<scope>:<key>
|
|
234
|
+
.post('/orders', ({ reply }) => reply(201, { id: crypto.randomUUID() }));
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
| Wired | Keys under `prefix: 'shop'` | Also written by |
|
|
238
|
+
| --- | --- | --- |
|
|
239
|
+
| `redisStore(handle.limits.api)` | `shop:api:<key>` | `handle.limits.api.consume(key)` anywhere |
|
|
240
|
+
| `idempotency(handle.idempotency.orders)` | `shop:orders:<route>:<scope>:<Idempotency-Key>` | `idempotency(handle, { name: 'orders' })`: the same keys |
|
|
241
|
+
|
|
242
|
+
What to know:
|
|
243
|
+
|
|
244
|
+
- **The definition is the one place.** The name, `limit`, `per` and `burst` of
|
|
245
|
+
a rate limit, the name, `ttl` and `lease` of an idempotency, are the
|
|
246
|
+
definition's. `idempotency(handle.idempotency.orders)` takes the middleware's
|
|
247
|
+
options (`required`, `scope`, `wait`, `methods`, `header`) but not `name`,
|
|
248
|
+
`ttl` or `lease`: the types refuse them, and a script that passes them gets a
|
|
249
|
+
`TypeError`.
|
|
250
|
+
- **The rate is the definition's, and `rateLimit` repeats it for its
|
|
251
|
+
headers.** The store counts by the wired limit's own rate, so `limit` and
|
|
252
|
+
`windowMs` on `rateLimit` only write `RateLimit-*`: give them the definition's
|
|
253
|
+
numbers.
|
|
254
|
+
- **The limit counts by a string.** `rateLimit` counts by the string its `key`
|
|
255
|
+
returns, so the wired limit's key must take one, as `key: (ip: string) => ip`.
|
|
256
|
+
`redisStore(handle.limits.byIp)` for a limit keyed by `{ ip }` does not
|
|
257
|
+
compile.
|
|
258
|
+
- **The idempotency keeps a response.** Its `schema` must be
|
|
259
|
+
`idempotencyResult`, the shape `@alxia/redis` stores (status, headers, body),
|
|
260
|
+
and its key must take a string; the middleware builds
|
|
261
|
+
`<route>:<scope>:<Idempotency-Key>` itself. Another schema does not compile.
|
|
262
|
+
- **A guard bound by hand works too**: `redisStore(bindRateLimit(client, api))`,
|
|
263
|
+
with no prefix.
|
|
264
|
+
- **Same name, one kind.** One name shared by a cache and a limit or an
|
|
265
|
+
idempotency on one instance is refused when the handle is wired:
|
|
266
|
+
[troubleshooting](../troubleshooting.md#typeerror-defineredis-instance-default-wires-the-cache-users-and-the-rate-limit-login-under-one-name-user-they-would-share-every-key-in-redis-give-one-of-them-a-name-of-its-own).
|
|
267
|
+
- **Moving from the by-name form.** An idempotency keeps its keys. A rate limit
|
|
268
|
+
does not: the by-name store counts under `shop:api:<limit>/<windowMs>:<key>`,
|
|
269
|
+
the wired one under `shop:api:<key>`, so counts restart:
|
|
270
|
+
[troubleshooting](../troubleshooting.md#counts-restart-after-moving-a-rate-limit-to-the-wired-form).
|
|
271
|
+
- **Both forms stay**, and need `@nxgt/redis` 0.6 only for `handle.limits` and
|
|
272
|
+
`handle.idempotency`: a limit or an idempotency bound by hand with 0.5 is
|
|
273
|
+
accepted the same.
|
|
274
|
+
|
|
146
275
|
## Naming keys
|
|
147
276
|
|
|
148
277
|
Every export that writes takes a `name`, prepended to its keys, so several
|
|
@@ -155,7 +284,8 @@ apps and several features can share one Redis:
|
|
|
155
284
|
| `idempotency(client, { name })` | `<name>:<route>:<scope>:<Idempotency-Key>` |
|
|
156
285
|
| `redis(client, { caches })` | each cache's own `<name>:<key>`, and `lock:<key>` |
|
|
157
286
|
|
|
158
|
-
Two features given the same `name` share their keys; give each its own.
|
|
287
|
+
Two features given the same `name` share their keys; give each its own. Given a
|
|
288
|
+
[handle](#with-an-nxgtredis-handle), each key also starts with its `prefix`.
|
|
159
289
|
|
|
160
290
|
## Next
|
|
161
291
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Idempotency
|
|
2
2
|
|
|
3
|
-
This page covers `idempotency`: a
|
|
3
|
+
This page covers `idempotency`: a middleware, given to `app.use`, that runs a `POST` or `PATCH`
|
|
4
4
|
once per `Idempotency-Key`, replays its response to every repeat, and
|
|
5
5
|
refuses the repeats it cannot answer — across every process sharing a
|
|
6
6
|
Redis.
|
|
7
7
|
|
|
8
8
|
```ts
|
|
9
|
-
import { alxia } from '@alxia/core';
|
|
10
|
-
import { idempotency } from '@alxia/redis';
|
|
9
|
+
import { alxia, validate } from '@alxia/core';
|
|
10
|
+
import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
|
|
11
11
|
import { connectRedis } from '@nxgt/redis';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
|
|
@@ -16,7 +16,7 @@ const Payment = z.object({ amount: z.number().int().positive() });
|
|
|
16
16
|
|
|
17
17
|
const app = alxia()
|
|
18
18
|
.use(idempotency(connection.client, { name: 'payments' }))
|
|
19
|
-
.post('/payments', { body: Payment }, ({ body, reply }) =>
|
|
19
|
+
.post('/payments', validate({ body: Payment }), ({ body, reply }) =>
|
|
20
20
|
reply(201, { id: crypto.randomUUID(), amount: body.amount }),
|
|
21
21
|
);
|
|
22
22
|
```
|
|
@@ -28,12 +28,15 @@ curl -X POST localhost:3000/payments -H 'idempotency-key: 4f1c' -H 'content-type
|
|
|
28
28
|
# 201 {"id":"9a…","amount":10} Idempotent-Replayed: true — the route did not run
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
Only the routes declared **after** `use(idempotency(…))` are guarded.
|
|
31
|
+
Only the routes declared **after** `use(idempotency(…))` are guarded. A request
|
|
32
|
+
no route matches is not: there is no route to scope its key by, so it passes
|
|
33
|
+
to the 404.
|
|
32
34
|
|
|
33
35
|
## The signature
|
|
34
36
|
|
|
35
37
|
```ts
|
|
36
|
-
function idempotency(
|
|
38
|
+
function idempotency(target: RedisClient | Redis<any>, options: IdempotencyOptions); // a middleware
|
|
39
|
+
function idempotency(wired: WiredIdempotency, options?: WiredIdempotencyOptions); // wired by defineRedis
|
|
37
40
|
|
|
38
41
|
interface IdempotencyOptions {
|
|
39
42
|
readonly name: string;
|
|
@@ -56,7 +59,7 @@ interface IdempotencyOptions {
|
|
|
56
59
|
| `methods` | `readonly string[]` | `['POST', 'PATCH']` | the methods it guards; the others pass through |
|
|
57
60
|
| `header` | `string` | `'idempotency-key'` | the request header the key is read from (any case) |
|
|
58
61
|
| `required` | `boolean` | `false` | refuse a guarded request with no key, with a `400` |
|
|
59
|
-
| `scope` | `(ctx: BaseContext) => string \| undefined` | `ctx.ip` | whose key it is; `undefined`
|
|
62
|
+
| `scope` | `(ctx: BaseContext) => string \| undefined` | `ctx.ip` | whose key it is; `undefined` leaves the request unguarded, with a warning |
|
|
60
63
|
|
|
61
64
|
`name`, `ttl` and `lease` are checked when `idempotency(…)` is called, so a
|
|
62
65
|
wrong one throws at startup:
|
|
@@ -71,6 +74,12 @@ TypeError: defineIdempotency: an idempotent operation needs a name, for its keys
|
|
|
71
74
|
guarded request a `500`, logging
|
|
72
75
|
`TypeError: run on "payments": wait is a whole number of milliseconds, 0 or more`.
|
|
73
76
|
|
|
77
|
+
`idempotency(handle.idempotency.orders)` takes an idempotency wired by
|
|
78
|
+
`defineRedis` instead: its definition holds the `name`, `ttl` and `lease`, and
|
|
79
|
+
its `schema` is `idempotencyResult`. The options are the others of the table
|
|
80
|
+
(`WiredIdempotencyOptions`). See
|
|
81
|
+
[Defined once, in `defineRedis`](connecting.md#defined-once-in-defineredis).
|
|
82
|
+
|
|
74
83
|
## What each request gets
|
|
75
84
|
|
|
76
85
|
| Request | Answer |
|
|
@@ -84,8 +93,9 @@ guarded request a `500`, logging
|
|
|
84
93
|
| no key, without `required` | the route runs, unguarded |
|
|
85
94
|
| a method not in `methods` | the route runs, unguarded |
|
|
86
95
|
|
|
87
|
-
Every refusal is
|
|
88
|
-
|
|
96
|
+
Every refusal's body is an `IdempotencyErrorBody`; declare the statuses in
|
|
97
|
+
your OpenAPI document, and the client you generate from it (with
|
|
98
|
+
`@nxgt/openapi-codegen`, say) reads them typed:
|
|
89
99
|
|
|
90
100
|
```ts
|
|
91
101
|
interface IdempotencyErrorBody {
|
|
@@ -100,9 +110,8 @@ interface IdempotencyErrorBody {
|
|
|
100
110
|
```
|
|
101
111
|
|
|
102
112
|
```ts
|
|
103
|
-
import {
|
|
104
|
-
import {
|
|
105
|
-
import { idempotency } from '@alxia/redis';
|
|
113
|
+
import { alxia, validate } from '@alxia/core';
|
|
114
|
+
import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
|
|
106
115
|
import { connectRedis } from '@nxgt/redis';
|
|
107
116
|
import { z } from 'zod';
|
|
108
117
|
|
|
@@ -110,18 +119,19 @@ const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
|
110
119
|
|
|
111
120
|
const app = alxia()
|
|
112
121
|
.use(idempotency(connection.client, { name: 'payments', required: true }))
|
|
113
|
-
.post('/payments', { body: z.object({ amount: z.number() }) }, ({ body, reply }) =>
|
|
122
|
+
.post('/payments', validate({ body: z.object({ amount: z.number() }) }), ({ body, reply }) =>
|
|
114
123
|
reply(201, { id: crypto.randomUUID(), amount: body.amount }),
|
|
115
124
|
);
|
|
116
125
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
const result = await app.request('/payments', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'content-type': 'application/json', 'idempotency-key': crypto.randomUUID() },
|
|
129
|
+
body: JSON.stringify({ amount: 10 }),
|
|
121
130
|
});
|
|
122
|
-
// result.status: 201
|
|
131
|
+
// result.status: 201, or 400, 409, 422 from the guard, or 500
|
|
123
132
|
if (result.status === 409) {
|
|
124
|
-
|
|
133
|
+
const { retryAfter } = (await result.json()) as IdempotencyErrorBody;
|
|
134
|
+
await Bun.sleep(retryAfter! * 1000); // then send the same request again
|
|
125
135
|
}
|
|
126
136
|
```
|
|
127
137
|
|
|
@@ -150,7 +160,7 @@ gets the replay rather than the `409`:
|
|
|
150
160
|
|
|
151
161
|
```ts
|
|
152
162
|
import { alxia } from '@alxia/core';
|
|
153
|
-
import { idempotency } from '@alxia/redis';
|
|
163
|
+
import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
|
|
154
164
|
import { connectRedis } from '@nxgt/redis';
|
|
155
165
|
|
|
156
166
|
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
@@ -165,15 +175,22 @@ const app = alxia()
|
|
|
165
175
|
Clients choose their keys, so two clients can choose the same one. The
|
|
166
176
|
stored key is `<name>:<route>:<scope>:<key>`, where `scope(ctx)` is the
|
|
167
177
|
client's address by default. When it is `undefined` — no address, as with
|
|
168
|
-
`app.request()` in a test or a server that cannot see one
|
|
169
|
-
|
|
178
|
+
`app.request()` in a test or a server that cannot see one, and no `scope`
|
|
179
|
+
option, or one that returns `undefined` — the request runs unguarded:
|
|
180
|
+
nothing is stored, nothing replayed, and each repeat runs the route again.
|
|
181
|
+
Sharing one key space between every client would replay one client's
|
|
182
|
+
response to another. The middleware warns once:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
idempotency "payments": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().
|
|
186
|
+
```
|
|
170
187
|
|
|
171
188
|
Behind a proxy, the address is the proxy's unless the app's `ip` option
|
|
172
189
|
reads the forwarded header. Where requests carry a user, scope by the user:
|
|
173
190
|
|
|
174
191
|
```ts
|
|
175
192
|
import { alxia } from '@alxia/core';
|
|
176
|
-
import { idempotency } from '@alxia/redis';
|
|
193
|
+
import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
|
|
177
194
|
import { connectRedis } from '@nxgt/redis';
|
|
178
195
|
|
|
179
196
|
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
@@ -197,9 +214,11 @@ is two keys.
|
|
|
197
214
|
|
|
198
215
|
## Order: what runs inside the guard
|
|
199
216
|
|
|
200
|
-
The
|
|
217
|
+
The middleware wraps the routes declared after it, and every middleware
|
|
201
218
|
declared after it too. Whatever those answer is kept like the route's
|
|
202
|
-
answer
|
|
219
|
+
answer: so is what a try/catch middleware, an `HttpError` or a validation
|
|
220
|
+
refusal answers, because `idempotency` settles the rest of the request before
|
|
221
|
+
it keeps it. A rate limit or an authentication check declared **after**
|
|
203
222
|
`idempotency` has its `429` or `401` kept and replayed — even once the
|
|
204
223
|
client is allowed through. Declare them **before**:
|
|
205
224
|
|
|
@@ -222,6 +241,10 @@ Measured: with the rate limit after `idempotency`, a key refused with a
|
|
|
222
241
|
had passed; with it before, the same repeat ran the route and answered
|
|
223
242
|
`201`.
|
|
224
243
|
|
|
244
|
+
Declared on the app, a rate limit or a guard also runs on a request no route
|
|
245
|
+
matches, and answers it before `idempotency` is reached; that is not a
|
|
246
|
+
concern here, since `idempotency` skips that request anyway.
|
|
247
|
+
|
|
225
248
|
A route that refuses a request it might accept later — a `401` before the
|
|
226
249
|
client signs in again, a `409` on a state that changes — should answer it
|
|
227
250
|
before the guard, or the client should send a new key when it retries.
|
|
@@ -251,5 +274,5 @@ Yield (`await Bun.sleep(0)`) inside long synchronous work, or move it to a
|
|
|
251
274
|
|
|
252
275
|
- [Rate limits](rate-limits.md) — `redisStore`.
|
|
253
276
|
- [Testing](testing.md) — specs for a guarded route.
|
|
254
|
-
- [`@nxgt/redis
|
|
277
|
+
- [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) —
|
|
255
278
|
the primitive underneath, for idempotency outside HTTP.
|
|
@@ -28,7 +28,8 @@ bun add @alxia/rate-limit
|
|
|
28
28
|
## The signature
|
|
29
29
|
|
|
30
30
|
```ts
|
|
31
|
-
function redisStore(
|
|
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
|
|
@@ -145,7 +151,7 @@ and [ten years](../troubleshooting.md#typeerror-defineratelimit--would-take-long
|
|
|
145
151
|
`store.reset(key)` forgets a key — after a successful login, say:
|
|
146
152
|
|
|
147
153
|
```ts
|
|
148
|
-
import { alxia } from '@alxia/core';
|
|
154
|
+
import { alxia, validate } from '@alxia/core';
|
|
149
155
|
import { rateLimit } from '@alxia/rate-limit';
|
|
150
156
|
import { redisStore } from '@alxia/redis';
|
|
151
157
|
import { connectRedis } from '@nxgt/redis';
|
|
@@ -158,7 +164,7 @@ const passwords = new Map([['ada', 'lovelace']]);
|
|
|
158
164
|
const app = alxia().group('/auth', (auth) =>
|
|
159
165
|
auth
|
|
160
166
|
.use(rateLimit({ limit: 5, windowMs: 15 * 60_000, store: attempts }))
|
|
161
|
-
.post('/login', { body: z.object({ name: z.string(), password: z.string() }) }, async ({ body, ip, reply }) => {
|
|
167
|
+
.post('/login', validate({ body: z.object({ name: z.string(), password: z.string() }) }), async ({ body, ip, reply }) => {
|
|
162
168
|
if (passwords.get(body.name) !== body.password) {
|
|
163
169
|
return reply(401, { error: 'invalid_credentials' as const });
|
|
164
170
|
}
|
|
@@ -49,7 +49,7 @@ Everything else — `ttl`, `staleWhileRevalidate`, `key`, `vary`, `statuses`,
|
|
|
49
49
|
|
|
50
50
|
- **A response** is one Redis string at `<name>:response:<key>`: an
|
|
51
51
|
`@nxgt/redis` cache record holding the status, the headers, the body as
|
|
52
|
-
base64, and the
|
|
52
|
+
base64, and the cache's `storedAt`, `ttl` and `stale`. Redis expires it
|
|
53
53
|
when `ttl + staleWhileRevalidate` has passed, rounded up to the second.
|
|
54
54
|
- **A tag** is a Redis set at `<name>:tag:<tag>`, of the response keys it
|
|
55
55
|
names. Besides your tags, every response carries `alxia:path:<path>` —
|
|
@@ -77,7 +77,7 @@ process sharing it misses on its next request:
|
|
|
77
77
|
|
|
78
78
|
```ts
|
|
79
79
|
import { cache } from '@alxia/cache';
|
|
80
|
-
import { alxia } from '@alxia/core';
|
|
80
|
+
import { alxia, validate } from '@alxia/core';
|
|
81
81
|
import { redisCacheStore } from '@alxia/redis';
|
|
82
82
|
import { connectRedis } from '@nxgt/redis';
|
|
83
83
|
import { z } from 'zod';
|
|
@@ -90,7 +90,7 @@ const store = redisCacheStore(connection.client, { name: 'shop' });
|
|
|
90
90
|
const products = cache({ ttl: 60, staleWhileRevalidate: 300, store, tags: () => ['products'] });
|
|
91
91
|
|
|
92
92
|
const app = alxia()
|
|
93
|
-
.post('/products', { body: Product }, async ({ body, reply }) => {
|
|
93
|
+
.post('/products', validate({ body: Product }), async ({ body, reply }) => {
|
|
94
94
|
catalogue.set(body.id, body);
|
|
95
95
|
await products.invalidateTag('products'); // forgotten in every process
|
|
96
96
|
return reply(201, body);
|
package/docs/guide/testing.md
CHANGED
|
@@ -61,8 +61,9 @@ it, as below.
|
|
|
61
61
|
## Give each test a client address
|
|
62
62
|
|
|
63
63
|
`app.request()` has no socket, so `ctx.ip` is `undefined`. A rate limit
|
|
64
|
-
then counts nothing, and idempotency
|
|
65
|
-
|
|
64
|
+
then counts nothing, and idempotency guards nothing: each request runs, and
|
|
65
|
+
it warns once that no client scope could be derived. Pass the app an `ip`
|
|
66
|
+
that answers, as above, or one per test to stand for two
|
|
66
67
|
clients:
|
|
67
68
|
|
|
68
69
|
```ts
|
|
@@ -87,14 +88,13 @@ test('two processes share one count', async () => {
|
|
|
87
88
|
});
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
##
|
|
91
|
+
## Refusals behind the middleware only
|
|
91
92
|
|
|
92
|
-
The `409`, `422` and `400` of `idempotency` are
|
|
93
|
-
|
|
93
|
+
The `409`, `422` and `400` of `idempotency` are answered by the routes
|
|
94
|
+
declared after it alone, never by a request no route matches. Send a key it refuses to both:
|
|
94
95
|
|
|
95
96
|
```ts
|
|
96
|
-
import { expect,
|
|
97
|
-
import { client } from '@alxia/client';
|
|
97
|
+
import { expect, test } from 'bun:test';
|
|
98
98
|
import { alxia } from '@alxia/core';
|
|
99
99
|
import { idempotency } from '@alxia/redis';
|
|
100
100
|
import { connectRedis } from '@nxgt/redis';
|
|
@@ -106,13 +106,10 @@ const app = alxia()
|
|
|
106
106
|
.use(idempotency(connection.client, { name: 'payments' }))
|
|
107
107
|
.post('/payments', ({ reply }) => reply(201, 'ok'));
|
|
108
108
|
|
|
109
|
-
test('only the routes after the
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
expectTypeOf((await api.post('/open')).status).toEqualTypeOf<201 | 500>();
|
|
114
|
-
};
|
|
115
|
-
expect(types).toBeFunction();
|
|
109
|
+
test('only the routes after the middleware refuse a bad key', async () => {
|
|
110
|
+
const init = { method: 'POST', headers: { 'idempotency-key': 'not a key' } };
|
|
111
|
+
expect((await app.request('/payments', init)).status).toBe(400);
|
|
112
|
+
expect((await app.request('/open', init)).status).toBe(201);
|
|
116
113
|
});
|
|
117
114
|
```
|
|
118
115
|
|