@alxia/redis 0.1.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.
@@ -0,0 +1,198 @@
1
+ # Rate limits
2
+
3
+ This page covers `redisStore`: an
4
+ [`@alxia/rate-limit`](https://www.npmjs.com/package/@alxia/rate-limit)
5
+ store that every process sharing a Redis counts in, and how it differs
6
+ from the memory store.
7
+
8
+ ```ts
9
+ import { alxia } from '@alxia/core';
10
+ import { rateLimit } from '@alxia/rate-limit';
11
+ import { redisStore } from '@alxia/redis';
12
+ import { connectRedis } from '@nxgt/redis';
13
+
14
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
15
+
16
+ const app = alxia()
17
+ .use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(connection.client, { name: 'api' }) }))
18
+ .get('/search', ({ reply }) => reply(200, []));
19
+ ```
20
+
21
+ `@alxia/rate-limit` is an optional peer: install it beside this package to
22
+ use `redisStore`.
23
+
24
+ ```sh
25
+ bun add @alxia/rate-limit
26
+ ```
27
+
28
+ ## The signature
29
+
30
+ ```ts
31
+ function redisStore(client: RedisClient, options: RedisStoreOptions): RateLimitStore;
32
+
33
+ interface RedisStoreOptions {
34
+ /** Prepended to every key it counts: one name per limit, so two never share a count. */
35
+ readonly name: string;
36
+ }
37
+ ```
38
+
39
+ | Option | Type | Default | Effect |
40
+ | --- | --- | --- | --- |
41
+ | `name` | `string` | required | the prefix of every key it writes: `<name>:<limit>/<windowMs>:<key>` for a count, and `<name>:policies` for the policies counted under the name |
42
+
43
+ `limit`, `windowMs`, the `key` counted (the client's address by default),
44
+ `skip` and the headers are `rateLimit`'s options, not the store's: see
45
+ `@alxia/rate-limit`'s guide. The 429 and its type are unchanged — a typed
46
+ `429 { error: 'rate_limited', retryAfter }` with `Retry-After`, the same as
47
+ with the memory store.
48
+
49
+ ## What changes with Redis
50
+
51
+ - **Every process counts together.** Behind a load balancer, a client gets
52
+ `limit` requests in all, not `limit` per process; a restart forgets
53
+ nothing.
54
+ - **It is GCRA, not a fixed window.** The memory store counts `limit`
55
+ requests, then refuses until its window ends. `redisStore` refills
56
+ continuously, at `limit` per `windowMs`, from a bucket that holds
57
+ `limit`. A client that has been idle can send `limit` at once, then one
58
+ more each `windowMs / limit`: in the first `windowMs` after an idle
59
+ spell, up to `2 × limit − 1` requests pass, never more than `limit` at
60
+ once.
61
+ - **The clock is the Redis server's**, not each process's, so processes
62
+ whose clocks differ still agree.
63
+ - **A refused request counts nothing**, as with the memory store.
64
+
65
+ Measured with `limit: 5, windowMs: 2_000`: seven requests at once answer
66
+ five `200` then two `429`; one second later, two more pass.
67
+
68
+ ```ts
69
+ import { expect, test } from 'bun:test';
70
+ import { alxia } from '@alxia/core';
71
+ import { rateLimit } from '@alxia/rate-limit';
72
+ import { redisStore } from '@alxia/redis';
73
+ import { connectRedis } from '@nxgt/redis';
74
+
75
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
76
+
77
+ test('a full bucket, then a steady refill', async () => {
78
+ await connection.client.send('FLUSHDB', []);
79
+ const app = alxia({ ip: () => '1.2.3.4' })
80
+ .use(rateLimit({ limit: 5, windowMs: 2_000, store: redisStore(connection.client, { name: 'demo' }) }))
81
+ .get('/', ({ reply }) => reply(200, 'ok'));
82
+
83
+ const burst = [];
84
+ for (let i = 0; i < 7; i++) burst.push((await app.request('/')).status);
85
+ expect(burst).toEqual([200, 200, 200, 200, 200, 429, 429]);
86
+
87
+ await Bun.sleep(1_000); // half a window: 2.5 requests' worth
88
+ expect((await app.request('/')).status).toBe(200);
89
+ });
90
+ ```
91
+
92
+ If a hard ceiling per fixed window is a requirement, GCRA is not it: halve
93
+ `limit` and `windowMs` together to keep the rate and halve the burst.
94
+
95
+ ## Choosing `name`
96
+
97
+ The key is `<name>:<limit>/<windowMs>:<key>`. Two `rateLimit`s with the
98
+ same `name` and the same `limit` and `windowMs` share one count; with a
99
+ different policy they do not. Give each limit its own name:
100
+
101
+ ```ts
102
+ import { alxia } from '@alxia/core';
103
+ import { rateLimit } from '@alxia/rate-limit';
104
+ import { redisStore } from '@alxia/redis';
105
+ import { connectRedis } from '@nxgt/redis';
106
+
107
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
108
+
109
+ const app = alxia()
110
+ .group('/auth', (auth) =>
111
+ auth
112
+ .use(rateLimit({ limit: 5, windowMs: 15 * 60_000, store: redisStore(connection.client, { name: 'login' }) }))
113
+ .post('/login', ({ reply }) => reply(200, 'ok')),
114
+ )
115
+ .use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(connection.client, { name: 'api' }) }))
116
+ .get('/search', ({ reply }) => reply(200, []));
117
+ ```
118
+
119
+ Every process must agree on `name`, `limit` and `windowMs`: the same three
120
+ are the same count.
121
+
122
+ ## Policies Redis refuses
123
+
124
+ `rateLimit` refuses a `limit` or a `windowMs` that is not a whole number of
125
+ 1 or more when it is created, with
126
+ [`TypeError: rateLimit: … must be a whole number of 1 or more, not …`](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/troubleshooting.md#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-).
127
+ `redisStore` checks two more bounds, `@nxgt/redis-guard`'s, only when it
128
+ first counts under a policy, not when the app starts. A policy past either
129
+ makes the first request it counts, and every one after it, a
130
+ `500 {"error":"internal"}`, with the reason in the log — a refused policy
131
+ is not kept, so each request checks it again:
132
+
133
+ | Policy | Logged |
134
+ | --- | --- |
135
+ | `limit × windowMs` above 9,007,199,254,740 (about 9e12) | `TypeError: defineRateLimit: "api:1000000/31536000000" has a burst of 1000000 and a per of 31536000000ms; burst × per must be at most 9007199254740 for the script to count exactly` |
136
+ | `windowMs` above ten 365-day years, 315,360,000,000, with `limit × windowMs` inside the bound above | `TypeError: defineRateLimit: "api:1/315360000001" would take longer than ten years to refill from empty (burst × per ÷ limit); check that per is in milliseconds` |
137
+
138
+ `limit: 1_000, windowMs: 86_400_000` — a thousand a day — is well inside
139
+ both; a million a year is not. Troubleshooting has the fix for each:
140
+ [burst × per](../troubleshooting.md#typeerror-defineratelimit--has-a-burst-of--and-a-per-of-ms-burst--per-must-be-at-most-9007199254740-for-the-script-to-count-exactly)
141
+ and [ten years](../troubleshooting.md#typeerror-defineratelimit--would-take-longer-than-ten-years-to-refill-from-empty-burst--per--limit-check-that-per-is-in-milliseconds).
142
+
143
+ ## Resetting a key
144
+
145
+ `store.reset(key)` forgets a key — after a successful login, say:
146
+
147
+ ```ts
148
+ import { alxia } from '@alxia/core';
149
+ import { rateLimit } from '@alxia/rate-limit';
150
+ import { redisStore } from '@alxia/redis';
151
+ import { connectRedis } from '@nxgt/redis';
152
+ import { z } from 'zod';
153
+
154
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
155
+ const attempts = redisStore(connection.client, { name: 'login' });
156
+ const passwords = new Map([['ada', 'lovelace']]);
157
+
158
+ const app = alxia().group('/auth', (auth) =>
159
+ auth
160
+ .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 }) => {
162
+ if (passwords.get(body.name) !== body.password) {
163
+ return reply(401, { error: 'invalid_credentials' as const });
164
+ }
165
+ if (ip !== undefined) await attempts.reset(ip);
166
+ return reply(200, { name: body.name });
167
+ }),
168
+ );
169
+ ```
170
+
171
+ `reset` forgets the key under **every policy ever counted under the
172
+ store's `name`**, by any store object and any process: each policy is
173
+ recorded in a Redis set, `<name>:policies`, the first time a process counts
174
+ under it. So a process that only resets — an admin endpoint, a worker —
175
+ needs no count of its own:
176
+
177
+ ```ts
178
+ import { redisStore } from '@alxia/redis';
179
+ import { connectRedis } from '@nxgt/redis';
180
+
181
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
182
+
183
+ // Another process: the login limit above is counted elsewhere.
184
+ export async function unblock(ip: string) {
185
+ await redisStore(connection.client, { name: 'login' }).reset(ip);
186
+ }
187
+ ```
188
+
189
+ The policies set holds one short member per `limit`/`windowMs` pair and is
190
+ kept without an expiry. A name used for several policies over time — a
191
+ limit you tuned — keeps the old ones in it; `reset` then also deletes the
192
+ key under them, which costs a command each and nothing else.
193
+
194
+ ## Next
195
+
196
+ - [Idempotency](idempotency.md) — and where to declare the rate limit
197
+ relative to it.
198
+ - [Testing](testing.md) — specs against a real Redis.
@@ -0,0 +1,129 @@
1
+ # Response cache
2
+
3
+ This page covers `redisCacheStore`: an
4
+ [`@alxia/cache`](https://www.npmjs.com/package/@alxia/cache) store that
5
+ every process sharing a Redis serves from, and invalidates together.
6
+
7
+ ```ts
8
+ import { cache } from '@alxia/cache';
9
+ import { alxia } from '@alxia/core';
10
+ import { redisCacheStore } from '@alxia/redis';
11
+ import { connectRedis } from '@nxgt/redis';
12
+
13
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
14
+
15
+ const products = cache({ ttl: 60, store: redisCacheStore(connection.client, { name: 'shop' }), tags: () => ['products'] });
16
+
17
+ const app = alxia()
18
+ .use(products)
19
+ .get('/products', ({ reply }) => reply(200, [{ id: '1', name: 'Kettle' }]));
20
+ ```
21
+
22
+ `@alxia/cache` is an optional peer: install it beside this package to use
23
+ `redisCacheStore`.
24
+
25
+ ```sh
26
+ bun add @alxia/cache
27
+ ```
28
+
29
+ ## The signature
30
+
31
+ ```ts
32
+ function redisCacheStore(client: RedisClient, options: RedisCacheStoreOptions): CacheStore;
33
+
34
+ interface RedisCacheStoreOptions {
35
+ /** Prepended to every key it writes: one name per app or deployment. */
36
+ readonly name: string;
37
+ }
38
+ ```
39
+
40
+ | Option | Type | Default | Effect |
41
+ | --- | --- | --- | --- |
42
+ | `name` | `string` | required | the prefix of every key: `<name>:response:<key>` for a response, `<name>:tag:<tag>` for a tag |
43
+
44
+ Everything else — `ttl`, `staleWhileRevalidate`, `key`, `vary`, `statuses`,
45
+ `tags` — is `cache()`'s, and behaves as with the memory store: see
46
+ `@alxia/cache`'s guide.
47
+
48
+ ## What it stores
49
+
50
+ - **A response** is one Redis string at `<name>:response:<key>`: an
51
+ `@nxgt/redis` cache record holding the status, the headers, the body as
52
+ base64, and the plugin's `storedAt`, `ttl` and `stale`. Redis expires it
53
+ when `ttl + staleWhileRevalidate` has passed, rounded up to the second.
54
+ - **A tag** is a Redis set at `<name>:tag:<tag>`, of the response keys it
55
+ names. Besides your tags, every response carries `alxia:path:<path>` —
56
+ the tag `invalidate(path)` deletes. It expires with the longest-kept response it names: each `set`
57
+ gives a new tag set its expiry, and only ever lengthens it after that.
58
+ On a Redis older than 7, which cannot compare expiries, the last
59
+ response written sets it.
60
+ - **Reading** checks the record against its schema: a record that no
61
+ longer reads as a response — written by another version, or by hand — is
62
+ a miss, and is deleted.
63
+
64
+ Measured with `cache({ ttl: 2, tags: () => ['products'] })` after one
65
+ `GET /products`:
66
+
67
+ ```text
68
+ shop:response:/products string TTL 2
69
+ shop:tag:alxia:path:/products set TTL 2
70
+ shop:tag:products set TTL 2
71
+ ```
72
+
73
+ ## Invalidating across processes
74
+
75
+ `invalidate(path)` and `invalidateTag(tag)` delete from Redis, so every
76
+ process sharing it misses on its next request:
77
+
78
+ ```ts
79
+ import { cache } from '@alxia/cache';
80
+ import { alxia } from '@alxia/core';
81
+ import { redisCacheStore } from '@alxia/redis';
82
+ import { connectRedis } from '@nxgt/redis';
83
+ import { z } from 'zod';
84
+
85
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
86
+ const Product = z.object({ id: z.string(), name: z.string() });
87
+ const catalogue = new Map<string, z.infer<typeof Product>>();
88
+
89
+ const store = redisCacheStore(connection.client, { name: 'shop' });
90
+ const products = cache({ ttl: 60, staleWhileRevalidate: 300, store, tags: () => ['products'] });
91
+
92
+ const app = alxia()
93
+ .post('/products', { body: Product }, async ({ body, reply }) => {
94
+ catalogue.set(body.id, body);
95
+ await products.invalidateTag('products'); // forgotten in every process
96
+ return reply(201, body);
97
+ })
98
+ .use(products)
99
+ .get('/products', ({ reply }) => reply(200, [...catalogue.values()]));
100
+ ```
101
+
102
+ `invalidateTag` reads the tag's set, deletes every key in it, then the set.
103
+ `invalidate(path)` does the same with the path's tag, so it forgets every
104
+ response kept for that path — each `vary` value, a `key` of your own:
105
+
106
+ ```ts
107
+ await products.invalidate('/products'); // every language, every key, in every process
108
+ await products.invalidate('/products?page=2'); // another path: its query is part of it
109
+ ```
110
+
111
+ Two `cache()` given stores with the same `name` share responses and tags;
112
+ give each app or deployment its own `name`.
113
+
114
+ ## What changes with Redis
115
+
116
+ - **Concurrent misses** run the route once per process, not once overall:
117
+ `@alxia/cache` coalesces them in memory.
118
+ - **A Redis that does not answer costs the cache, not the response.** A
119
+ read that fails is a miss, so the route runs; a write that fails keeps
120
+ nothing; the first error of an outage is logged with `console.error`. `invalidate` and
121
+ `invalidateTag` do reject, so a write that empties the cache learns that
122
+ it could not — see `@alxia/cache`'s
123
+ [Stores](https://github.com/softistx/alxia/blob/develop/packages/cache/docs/guide/stores.md#when-the-store-cannot-answer).
124
+
125
+ ## Next
126
+
127
+ - [Caches and locks](caches-and-locks.md) — `redis()` adds typed caches to
128
+ the context, as `caches`, beside this one's `cache`.
129
+ - [Testing](testing.md).
@@ -0,0 +1,135 @@
1
+ # Testing
2
+
3
+ This page covers specs for routes that use `@alxia/redis`: against a real
4
+ Redis, emptied between tests, with the app built once the client is
5
+ connected.
6
+
7
+ ```ts
8
+ // payments.spec.ts
9
+ import { beforeAll, beforeEach, expect, test } from 'bun:test';
10
+ import { alxia } from '@alxia/core';
11
+ import { idempotency } from '@alxia/redis';
12
+ import { connectRedis, type RedisConnection } from '@nxgt/redis';
13
+
14
+ let connection: RedisConnection;
15
+ let app: ReturnType<typeof makeApp>;
16
+
17
+ const makeApp = (client: RedisConnection['client']) =>
18
+ alxia({ ip: () => '1.2.3.4' })
19
+ .use(idempotency(client, { name: 'payments' }))
20
+ .post('/payments', ({ reply }) => reply(201, { id: crypto.randomUUID() }));
21
+
22
+ beforeAll(async () => {
23
+ connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
24
+ app = makeApp(connection.client);
25
+ });
26
+ beforeEach(async () => {
27
+ await connection.client.send('FLUSHDB', []);
28
+ });
29
+
30
+ test('a repeat replays the first response', async () => {
31
+ const pay = () => app.request('/payments', { method: 'POST', headers: { 'idempotency-key': 'k-1' } });
32
+ const first = await pay();
33
+ const again = await pay();
34
+ expect(await again.json()).toEqual(await first.json());
35
+ expect(again.headers.get('idempotent-replayed')).toBe('true');
36
+ });
37
+ ```
38
+
39
+ ## A Redis to test against
40
+
41
+ There is no in-memory fake: the stores and the guard run Lua scripts and
42
+ read the Redis server's clock, which only a Redis answers. Point
43
+ `REDIS_URL` at a disposable one:
44
+
45
+ ```sh
46
+ docker run --rm -d -p 6379:6379 redis:8-alpine
47
+ REDIS_URL=redis://127.0.0.1:6379 bun test
48
+ ```
49
+
50
+ In CI, a Redis service container and `REDIS_URL` do the same. Use a
51
+ database nothing else uses: `FLUSHDB` empties all of it.
52
+
53
+ ## Build the app after connecting
54
+
55
+ Each export binds the client it is given when it is called. An app built
56
+ at the top of the file, while the variable that `beforeAll` fills is still
57
+ unset, binds `undefined`, and its first request fails. Build it in
58
+ `beforeAll`, as above, or connect with a top-level `await` before building
59
+ it, as below.
60
+
61
+ ## Give each test a client address
62
+
63
+ `app.request()` has no socket, so `ctx.ip` is `undefined`. A rate limit
64
+ then counts nothing, and idempotency scopes every key to `anyone`. Pass the
65
+ app an `ip` that answers, as above, or one per test to stand for two
66
+ clients:
67
+
68
+ ```ts
69
+ import { expect, test } from 'bun:test';
70
+ import { alxia } from '@alxia/core';
71
+ import { rateLimit } from '@alxia/rate-limit';
72
+ import { redisStore } from '@alxia/redis';
73
+ import { connectRedis } from '@nxgt/redis';
74
+
75
+ const connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
76
+
77
+ test('two processes share one count', async () => {
78
+ await connection.client.send('FLUSHDB', []);
79
+ const make = () =>
80
+ alxia({ ip: () => '1.2.3.4' })
81
+ .use(rateLimit({ limit: 2, windowMs: 60_000, store: redisStore(connection.client, { name: 'api' }) }))
82
+ .get('/', ({ reply }) => reply(200, 'ok'));
83
+ const [one, two] = [make(), make()]; // two apps stand for two processes
84
+ expect((await one.request('/')).status).toBe(200);
85
+ expect((await two.request('/')).status).toBe(200);
86
+ expect((await one.request('/')).status).toBe(429);
87
+ });
88
+ ```
89
+
90
+ ## Typed refusals
91
+
92
+ The `409`, `422` and `400` of `idempotency` are part of each guarded route's
93
+ type. `@alxia/client` and `expectTypeOf` pin them, with no Redis call:
94
+
95
+ ```ts
96
+ import { expect, expectTypeOf, test } from 'bun:test';
97
+ import { client } from '@alxia/client';
98
+ import { alxia } from '@alxia/core';
99
+ import { idempotency } from '@alxia/redis';
100
+ import { connectRedis } from '@nxgt/redis';
101
+
102
+ const connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
103
+
104
+ const app = alxia()
105
+ .post('/open', ({ reply }) => reply(201, 'ok'))
106
+ .use(idempotency(connection.client, { name: 'payments' }))
107
+ .post('/payments', ({ reply }) => reply(201, 'ok'));
108
+
109
+ test('only the routes after the plugin carry its refusals', () => {
110
+ const types = async () => {
111
+ const api = client(app);
112
+ expectTypeOf((await api.post('/payments')).status).toEqualTypeOf<201 | 400 | 409 | 422 | 500>();
113
+ expectTypeOf((await api.post('/open')).status).toEqualTypeOf<201 | 500>();
114
+ };
115
+ expect(types).toBeFunction();
116
+ });
117
+ ```
118
+
119
+ ## Close at the end
120
+
121
+ Close the connections once the file is done:
122
+
123
+ ```ts
124
+ import { afterAll } from 'bun:test';
125
+ import { closeRedis } from '@nxgt/redis';
126
+
127
+ afterAll(async () => {
128
+ await closeRedis();
129
+ });
130
+ ```
131
+
132
+ ## Next
133
+
134
+ - [Connecting](connecting.md).
135
+ - [Idempotency](idempotency.md) — what each request gets.
@@ -0,0 +1,46 @@
1
+ # Roadmap
2
+
3
+ What `@alxia/redis` gives an app, and what is coming. This page is a
4
+ direction, not a commitment: the version something shipped in is the only
5
+ number on it. Every release, with each change it made, is in
6
+ [`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/redis/CHANGELOG.md).
7
+
8
+ ## Now
9
+
10
+ Nothing scheduled yet.
11
+
12
+ ## Next
13
+
14
+ Nothing scheduled yet.
15
+
16
+ ## Later
17
+
18
+ Nothing scheduled yet.
19
+
20
+ ## Not planned
21
+
22
+ - **A second Redis implementation.** `@alxia/redis` is an adapter over
23
+ [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) and
24
+ [`@nxgt/redis-guard`](https://www.npmjs.com/package/@nxgt/redis-guard),
25
+ never a rewrite of them: their scripts, keys and errors are what it runs.
26
+ They run on Bun's built-in `RedisClient`, so there is no driver to
27
+ install, and it does not run on Node.
28
+
29
+ ## Shipped
30
+
31
+ ### 0.1.0
32
+
33
+ - **A rate limit every process shares.** `redisStore` is an
34
+ `@alxia/rate-limit` store with GCRA in one atomic script, timed by the
35
+ Redis server's clock; a refused request counts nothing, and the 429
36
+ stays typed.
37
+ - **A response cache every process shares.** `redisCacheStore` is an
38
+ `@alxia/cache` store on `@nxgt/redis`'s typed caches; a record that no
39
+ longer reads as a response is a miss, and `invalidateTag` reaches every
40
+ process.
41
+ - **Idempotent routes.** `idempotency` runs a `POST` or `PATCH` once per
42
+ `Idempotency-Key`, replays its response with `Idempotent-Replayed: true`
43
+ from any process, and answers a typed `409`, `422` or `400` for the
44
+ repeats it cannot; a `5xx` or a stream is not kept.
45
+ - **Redis in the context.** `redis` gives routes the client, typed caches
46
+ bound once, and a lock every process respects.