@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/docs/roadmap.md CHANGED
@@ -7,11 +7,16 @@ number on it. Every release, with each change it made, is in
7
7
 
8
8
  ## Now
9
9
 
10
- Nothing scheduled yet.
10
+ Nothing in progress: `idempotency` as a middleware, the last change planned,
11
+ shipped in 0.2.0 and finished in 0.3.0.
11
12
 
12
13
  ## Next
13
14
 
14
- Nothing scheduled yet.
15
+ - **A rate limit that reads its policy from the store.** With
16
+ `redisStore(handle.limits.api)` the rate lives in the definition, and
17
+ `rateLimit({ limit, windowMs })` still repeats it for its headers. The store
18
+ telling `rateLimit` its own policy, so the numbers are written once, is
19
+ planned for `@alxia/rate-limit` and this package together.
15
20
 
16
21
  ## Later
17
22
 
@@ -20,14 +25,52 @@ Nothing scheduled yet.
20
25
  ## Not planned
21
26
 
22
27
  - **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.
28
+ [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis),
29
+ never a rewrite of it: its scripts, keys and errors are what it runs.
26
30
  They run on Bun's built-in `RedisClient`, so there is no driver to
27
31
  install, and it does not run on Node.
28
32
 
29
33
  ## Shipped
30
34
 
35
+ ### 0.3.0, continued: wired guards
36
+
37
+ - **On `@nxgt/redis` 0.6.** The peer range is `^0.5.0 || ^0.6.0`: the new forms
38
+ need no more than 0.5's types, and 0.6 only adds `handle.limits` and
39
+ `handle.idempotency` to wire them.
40
+ - **A rate limit and an idempotency defined once.** `redisStore(handle.limits.api)`
41
+ and `idempotency(handle.idempotency.orders)` take what `defineRedis`
42
+ wired, so the definition lives in one place and writes the keys
43
+ `@nxgt/redis` writes, `<prefix>:<name>:<key>`: every consumer of the handle
44
+ shares the count. `idempotencyResult` is the schema of the wired idempotency.
45
+ Both older forms stay.
46
+
47
+ ### 0.3.0
48
+
49
+ - **On `@nxgt/redis` 0.5 alone.** The rate limits and the idempotency that
50
+ `@nxgt/redis-guard` held live in `@nxgt/redis` now, and `@nxgt/redis-guard`
51
+ is no longer a peer.
52
+ - **One form for `idempotency`.** The middleware's type is a plain
53
+ `(ctx, next)` function, and `app.plugin(idempotency(…))`, deprecated in
54
+ 0.2.0, is gone: give it to `use(…)`.
55
+ - **An `@nxgt/redis` handle everywhere.** `redis(handle)` takes the handle
56
+ `openRedis(defineRedis({ … }))` gives: typed `caches` from its scopes, a
57
+ `lock` and every key under its `prefix`, and the handle closed once in
58
+ `onStop`, after the drain (`{ close: false }` to opt out). `redisStore`,
59
+ `redisCacheStore` and `idempotency` take it where they take a client and
60
+ put its prefix in front of their keys, and `redisCheck` is a readiness
61
+ check for `health()`. The bare `RedisClient` forms are unchanged.
62
+
63
+ ### 0.2.0
64
+
65
+ - **Middlewares, under the same names.** `app.use(idempotency(client, …))`
66
+ replaces `app.plugin(idempotency(…))`, which stayed, deprecated, until 0.3.0;
67
+ `IdempotencyMiddleware` is the type it returns. A request no route matches
68
+ passes through it, never kept.
69
+ - **A client no one can tell apart is not shared.** A request with no
70
+ `ctx.ip` and no `scope` runs unguarded, nothing stored or replayed, and
71
+ the middleware warns once, instead of keying every such client to
72
+ `anyone`, where one could be replayed another's response.
73
+
31
74
  ### 0.1.0
32
75
 
33
76
  - **A rate limit every process shares.** `redisStore` is an
@@ -2,9 +2,11 @@
2
2
 
3
3
  Each entry is headed by the text you see: a TypeScript error, an exception
4
4
  at startup, an exception in the log beside a `500 {"error":"internal"}`, or
5
- the response a client got. `@alxia/redis` throws nothing of its own: the
6
- messages are `@nxgt/redis`'s, `@nxgt/redis-guard`'s and Bun's, and it lets
7
- each through. What prints nothing is under [Traps](#traps), by symptom.
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
9
+ [Runtime: a warning in the log](#runtime-a-warning-in-the-log). What prints nothing is under [Traps](#traps), by symptom.
8
10
 
9
11
  **Install and types**
10
12
 
@@ -21,6 +23,10 @@ each through. What prints nothing is under [Traps](#traps), by symptom.
21
23
  - [`RedisError: Connection closed`](#rediserror-connection-closed)
22
24
  - [`TypeError: connectRedis: this URI is already connected with other options. Pass the same options everywhere, or close the first connection.`](#typeerror-connectredis-this-uri-is-already-connected-with-other-options-pass-the-same-options-everywhere-or-close-the-first-connection)
23
25
 
26
+ - [`TypeError: @alxia/redis: this @nxgt/redis handle wires N Redis instances (…), and one is needed.`](#typeerror-alxiaredis-this-nxgtredis-handle-wires-n-redis-instances--and-one-is-needed)
27
+ - [`TypeError: defineRedis: instance "default" wires no cache, no channel, no rate limit and no idempotency. Pass the module that exports them, or drop the instance.`](#typeerror-defineredis-instance-default-wires-no-cache-no-channel-no-rate-limit-and-no-idempotency-pass-the-module-that-exports-them-or-drop-the-instance)
28
+ - [`TypeError: defineRedis: instance "default" wires the cache "users" and the rate limit "login" under one name, "user". They would share every key in Redis. Give one of them a name of its own.`](#typeerror-defineredis-instance-default-wires-the-cache-users-and-the-rate-limit-login-under-one-name-user-they-would-share-every-key-in-redis-give-one-of-them-a-name-of-its-own)
29
+
24
30
  **Runtime: a 500, with this in the log**
25
31
 
26
32
  - [`TypeError: defineRateLimit: "…" has a burst of … and a per of …ms; burst × per must be at most 9007199254740 for the script to count exactly`](#typeerror-defineratelimit--has-a-burst-of--and-a-per-of-ms-burst--per-must-be-at-most-9007199254740-for-the-script-to-count-exactly)
@@ -32,6 +38,10 @@ each through. What prints nothing is under [Traps](#traps), by symptom.
32
38
  - [`RedisError: The lock "…" expired before its work finished: it ran longer than the …ms ttl, so it may have run beside another holder`](#rediserror-the-lock--expired-before-its-work-finished-it-ran-longer-than-the-ms-ttl-so-it-may-have-run-beside-another-holder)
33
39
  - [`TypeError: undefined is not an object (evaluating 'cache.…')`](#typeerror-undefined-is-not-an-object-evaluating-cache)
34
40
 
41
+ **Runtime: a warning in the log**
42
+
43
+ - [`idempotency "…": 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().`](#idempotency--no-client-scope-could-be-derived-ctxip-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)
44
+
35
45
  **Calling the store yourself**
36
46
 
37
47
  - [`TypeError: defineRateLimit: "…" has a per of …; it is a whole number of milliseconds, and must be at least 1`](#typeerror-defineratelimit--has-a-per-of--it-is-a-whole-number-of-milliseconds-and-must-be-at-least-1)
@@ -50,6 +60,9 @@ each through. What prints nothing is under [Traps](#traps), by symptom.
50
60
  - [Two limits count each other's requests](#two-limits-count-each-others-requests)
51
61
  - [A `401` or a `429` is replayed, with `Idempotent-Replayed: true`, after the client fixed it](#a-401-or-a-429-is-replayed-with-idempotent-replayed-true-after-the-client-fixed-it)
52
62
  - [One client gets another client's response](#one-client-gets-another-clients-response)
63
+ - [Counts and kept responses vanish after moving to a handle with a `prefix`](#counts-and-kept-responses-vanish-after-moving-to-a-handle-with-a-prefix)
64
+ - [Counts restart after moving a rate limit to the wired form](#counts-restart-after-moving-a-rate-limit-to-the-wired-form)
65
+ - [The handle is closed while something still uses it](#the-handle-is-closed-while-something-still-uses-it)
53
66
 
54
67
  ## Install and types
55
68
 
@@ -106,7 +119,7 @@ never meet `@alxia/cache`'s `cache`.
106
119
 
107
120
  ```ts
108
121
  alxia()
109
- .use(redis(connection.client, { caches: { users } }))
122
+ .plugin(redis(connection.client, { caches: { users } }))
110
123
  .get('/users/:id', async ({ caches, params, reply }) =>
111
124
  reply.ok(await caches.users.remember(params.id, () => loadUser(params.id))),
112
125
  );
@@ -217,6 +230,59 @@ await check.close();
217
230
  const connection = await connectRedis(Bun.env['REDIS_URL']!);
218
231
  ```
219
232
 
233
+ ### `TypeError: @alxia/redis: this @nxgt/redis handle wires N Redis instances (…), and one is needed.`
234
+
235
+ **When:** a handle wiring several instances is given to `redis()`,
236
+ `redisStore`, `redisCacheStore`, `idempotency` or `redisCheck`, at startup.
237
+
238
+ **Why:** the keys of one deployment live on one Redis; which of several is
239
+ meant is not guessed.
240
+
241
+ **Fix:** give the bare client of the one, with the prefix in the `name`:
242
+
243
+ ```ts
244
+ redisStore(handle.clients.cache, { name: 'shop:api' });
245
+ ```
246
+
247
+ or wire one instance per handle.
248
+
249
+ ### `TypeError: defineRedis: instance "default" wires no cache, no channel, no rate limit and no idempotency. Pass the module that exports them, or drop the instance.`
250
+
251
+ **When:** `defineRedis({ uri, prefix })` is written only to give the stores
252
+ and `idempotency` a prefix. `@nxgt/redis` 0.5 said `wires no cache and no
253
+ channel`; 0.6 counts the rate limits and the idempotency it can now wire too.
254
+
255
+ **Why:** `@nxgt/redis` refuses a handle that wires nothing.
256
+
257
+ **Fix:** wire at least one of the four on it: a cache, which `redis(handle)`
258
+ then puts in the context as `caches`, a channel, a rate limit or an idempotency:
259
+
260
+ ```ts
261
+ defineRedis({ uri, prefix: 'shop', caches: { users } });
262
+ defineRedis({ uri, prefix: 'shop', limits: { api } });
263
+ ```
264
+
265
+ ### `TypeError: defineRedis: instance "default" wires the cache "users" and the rate limit "login" under one name, "user". They would share every key in Redis. Give one of them a name of its own.`
266
+
267
+ **When:** the `defineRedis(...)` of an app that gives `caches`, `limits` and
268
+ `idempotency` one `name` on one instance: here the cache exported as `users`
269
+ and the rate limit exported as `login`, both named `user`. The sentence says
270
+ which two, and the export keys they are wired under. It is thrown at wiring
271
+ time, before the app serves; with the same kind twice it reads `wires the rate
272
+ limit named "user" twice, under "a" and "b"`.
273
+
274
+ **Why:** a cache, a rate limit and an idempotency all write
275
+ `<prefix>:<name>:<key>`, so one name is one keyspace, and they would
276
+ overwrite each other's values or meet as `WRONGTYPE`. The same wiring that lets
277
+ `redisStore(handle.limits.login)` and `idempotency(handle.idempotency.orders)`
278
+ write the layout `@nxgt/redis` writes refuses the clash.
279
+
280
+ **Fix:** rename one: the **definition's** `name`, not its export:
281
+
282
+ ```ts
283
+ export const login = defineRateLimit({ name: 'login.limit', key: (ip: string) => ip, limit: 5, per: 60_000 });
284
+ ```
285
+
220
286
  ## Runtime: a 500, with this in the log
221
287
 
222
288
  Each of these makes the request answer `500 {"error":"internal"}`; the
@@ -234,7 +300,7 @@ TypeError: defineRateLimit: "api:1000000/31536000000" has a burst of 1000000 and
234
300
 
235
301
  **Why:** the script counts in exact integers; `limit × windowMs` past that
236
302
  bound would lose precision. `redisStore` hands each policy to
237
- `@nxgt/redis-guard` when it first counts under it, and a refused policy is
303
+ `@nxgt/redis` when it first counts under it, and a refused policy is
238
304
  not kept, so it is checked again, and refused again, on each request.
239
305
 
240
306
  **Fix:** state the same rate over a shorter window:
@@ -277,7 +343,7 @@ with a fraction or below 0.
277
343
  TypeError: run on "payments": wait is a whole number of milliseconds, 0 or more
278
344
  ```
279
345
 
280
- **Why:** `wait` is checked when it is used, not when the plugin is made.
346
+ **Why:** `wait` is checked when it is used, not when the middleware is made.
281
347
 
282
348
  **Fix:**
283
349
 
@@ -398,6 +464,38 @@ with `undefined`.
398
464
  **Fix:** read `caches.<name>`, drop the `derive`, and typecheck:
399
465
  `tsc --noEmit` finds every place.
400
466
 
467
+ ## Runtime: a warning in the log
468
+
469
+ ### `idempotency "…": 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().`
470
+
471
+ **When:** a guarded request carries an `Idempotency-Key`, and the
472
+ middleware has no client to scope it by: `ctx.ip` is `undefined` — under
473
+ `app.request()` in a test, or a server that cannot see the address — and
474
+ there is no `scope` option, or it returned `undefined`. Printed once per
475
+ `idempotency(…)`, with its `name`.
476
+
477
+ **Why:** keys are scoped by the client, so two clients choosing the same key
478
+ never see each other's response. With no client, the request runs
479
+ unguarded: the route runs, nothing is stored, a repeat runs it again.
480
+ Sharing one key space between every client would replay one client's
481
+ response to another.
482
+
483
+ **Fix:** scope by the user where there is one, or give `alxia()` an `ip`
484
+ option that reads the address:
485
+
486
+ ```ts
487
+ idempotency(connection.client, {
488
+ name: 'payments',
489
+ scope: ({ request }) => request.headers.get('x-user-id') ?? undefined,
490
+ });
491
+ ```
492
+
493
+ or, behind a proxy you trust:
494
+
495
+ ```ts
496
+ alxia({ ip: (request) => request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() });
497
+ ```
498
+
401
499
  ## Calling the store yourself
402
500
 
403
501
  A policy `rateLimit` would refuse at startup still reaches the store when
@@ -414,7 +512,7 @@ number, or below 1.
414
512
  TypeError: defineRateLimit: "api:5/1.5" has a per of 1.5; it is a whole number of milliseconds, and must be at least 1
415
513
  ```
416
514
 
417
- **Why:** `redisStore` hands each `limit`/`windowMs` to `@nxgt/redis-guard`
515
+ **Why:** `redisStore` hands each `limit`/`windowMs` to `@nxgt/redis`
418
516
  the first time it counts under it, and the guard counts in whole
419
517
  milliseconds. `rateLimit` never passes such a value: it refuses it at
420
518
  startup, with [`TypeError: rateLimit: windowMs must be a whole number of 1 or more, not …`](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/troubleshooting.md#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-).
@@ -567,7 +665,8 @@ rateLimit({ limit: 5, windowMs: 60_000, store: redisStore(connection.client, { n
567
665
  ### A `401` or a `429` is replayed, with `Idempotent-Replayed: true`, after the client fixed it
568
666
 
569
667
  **When:** a request under a key was refused with a `4xx` — by the route, or
570
- by a rate limit or an authentication check declared after `idempotency` —
668
+ by a rate limit or an authentication check declared after `idempotency`,
669
+ or by an error handler —
571
670
  and the client, once allowed, retries with the same key and body.
572
671
 
573
672
  **Why:** every response below `500` is kept and replayed for `ttl`, not
@@ -596,8 +695,9 @@ behind a proxy, or with keys that are not random — `1`, `order-1`.
596
695
 
597
696
  **Why:** keys are scoped by `scope(ctx)`, the client's address by default.
598
697
  Behind a proxy that the app's `ip` option does not see through, every
599
- client has the proxy's address; with no address at all, the scope is
600
- `anyone`. Clients then share one key space.
698
+ client has the proxy's address, and they then share one key space. (With
699
+ no address at all and no `scope`, the request runs unguarded instead, with
700
+ a [warning](#runtime-a-warning-in-the-log).)
601
701
 
602
702
  **Fix:** scope by the user where there is one, and have clients send
603
703
  random keys:
@@ -611,3 +711,50 @@ idempotency(connection.client, {
611
711
 
612
712
  [Scope](guide/idempotency.md#scope-whose-key-it-is) shows the `ip` option
613
713
  behind a proxy.
714
+
715
+ ### Counts and kept responses vanish after moving to a handle with a `prefix`
716
+
717
+ **Symptom:** after `redisStore(client, …)` becomes `redisStore(handle, …)`,
718
+ rate-limit counts start from zero, kept responses miss and an idempotent
719
+ repeat runs again.
720
+
721
+ **Why:** the handle's `prefix` is in front of every key, so the keys are
722
+ new: `api:…` is now `shop:api:…`. The old ones expire on their own.
723
+
724
+ **Fix:** none is needed beyond waiting for the longest `ttl`; switch during a
725
+ quiet period, or keep the bare client until then.
726
+
727
+ ### Counts restart after moving a rate limit to the wired form
728
+
729
+ **Symptom:** after `redisStore(handle, { name: 'api' })` becomes
730
+ `redisStore(handle.limits.api)`, every client's allowance starts full again.
731
+ An idempotent route does not do this: `idempotency(handle, { name: 'orders' })`
732
+ and `idempotency(handle.idempotency.orders)` write the same keys, so a kept
733
+ response is still replayed.
734
+
735
+ **Why:** the two layouts differ. The by-name store keeps one bucket per policy,
736
+ `shop:api:<limit>/<windowMs>:<key>` and `shop:api:policies`; the wired limit
737
+ is `@nxgt/redis`'s own, `shop:api:<key>`, the layout every other consumer of
738
+ the handle shares. The old buckets are not read any more and expire by
739
+ themselves.
740
+
741
+ **Fix:** none is needed but the wait, within `burst × per ÷ limit` of the
742
+ limit; switch at a quiet moment. `redisStore(handle.limits.api)` counts by the
743
+ definition's rate, not by the `limit` and `windowMs` the middleware is given,
744
+ which only write the headers: if the `RateLimit-Policy` header says another
745
+ rate than the limit enforces, make the two numbers equal.
746
+
747
+ ### The handle is closed while something still uses it
748
+
749
+ **Symptom:** after the app stopped, or in a second app sharing the handle,
750
+ commands fail with `RedisError: Connection closed`.
751
+
752
+ **Why:** `redis(handle)` closes the handle when its app stops. Two apps
753
+ given one handle close it with the first.
754
+
755
+ **Fix:** pass `{ close: false }` to every `redis(handle, …)` but the one that
756
+ owns the handle's life, or close it yourself:
757
+
758
+ ```ts
759
+ app.plugin(redis(handle, { close: false }));
760
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alxia/redis",
3
- "version": "0.1.3",
4
- "description": "Redis for alxia on @nxgt/redis and @nxgt/redis-guard: a rate-limit store every process shares, idempotent routes, caches and locks in the context",
3
+ "version": "0.3.0",
4
+ "description": "Redis for alxia on @nxgt/redis: a rate-limit store every process shares, idempotent routes, caches and locks in the context",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -41,21 +41,18 @@
41
41
  ]
42
42
  },
43
43
  "devDependencies": {
44
- "@alxia/cache": "^0.1.2",
45
- "@alxia/client": "^0.2.1",
46
- "@alxia/core": "^0.3.0",
47
- "@alxia/rate-limit": "^0.1.2",
48
- "@nxgt/redis": "^0.3.1",
49
- "@nxgt/redis-guard": "^0.3.1",
44
+ "@alxia/cache": "^0.3.0",
45
+ "@alxia/core": "^0.5.0",
46
+ "@alxia/rate-limit": "^0.3.0",
47
+ "@nxgt/redis": "^0.6.0",
50
48
  "@types/bun": "^1.4.2",
51
49
  "zod": "^4.6.5"
52
50
  },
53
51
  "peerDependencies": {
54
- "@alxia/cache": "^0.1.2",
55
- "@alxia/core": "^0.3.0",
56
- "@alxia/rate-limit": "^0.1.2",
57
- "@nxgt/redis": "^0.3.1",
58
- "@nxgt/redis-guard": "^0.3.1",
52
+ "@alxia/cache": "^0.3.0",
53
+ "@alxia/core": "^0.5.0",
54
+ "@alxia/rate-limit": "^0.3.0",
55
+ "@nxgt/redis": "^0.5.0 || ^0.6.0",
59
56
  "typescript": "^6.0.3 || ^7.0.0",
60
57
  "zod": "^4.6.5"
61
58
  },