@ddd-ts-kit/redis 0.16.0 → 0.16.1

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.
Files changed (3) hide show
  1. package/README.fr.md +1122 -0
  2. package/README.md +394 -48
  3. package/package.json +6 -6
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ **English** · [Français](README.fr.md)
2
+
1
3
  # @ddd-ts-kit/redis
2
4
 
3
5
  Adapters of kernel ports on the Redis protocol: the `Cache`, `Lock` and `RateLimiter` ports,
@@ -13,28 +15,82 @@ npm i @ddd-ts-kit/redis ioredis
13
15
 
14
16
  | Entry | Holds | Imports |
15
17
  |---|---|---|
16
- | `@ddd-ts-kit/redis` | `connectRedis`, `redisHealth`, `RedisCache`, `RedisLock`, `RedisRateLimiter`, `RedisRelaySignal`, `relayWakeUpMiddleware`, `RedisJobStore`, `reportLostJobs`; re-exported from the kernel: the `Lock` and `RateLimiter` ports, `InMemoryLock`, `InMemoryRateLimiter` | the kernel, `@ddd-ts-kit/scheduling`, `ioredis` (peer) |
17
- | `@ddd-ts-kit/redis/event-bus` | `RedisStreamsEventBus`, its options, topology and error types, the names of the entry fields | the kernel, `ioredis` (peer) |
18
+ | `@ddd-ts-kit/redis` | `connectRedis`, `closeRedis`, `FAIL_FAST`, `redisHealth`, `RedisCache`, `jsonCodec`, `RedisLock`, `RedisRateLimiter`, `RedisRelaySignal`, `relayWakeUpMiddleware`, `RedisJobStore`, `reportLostJobs`; re-exported from the kernel: the `Lock` and `RateLimiter` ports, `InMemoryLock`, `InMemoryRateLimiter` | the kernel, `@ddd-ts-kit/scheduling`, `ioredis` (peer) |
19
+ | `@ddd-ts-kit/redis/event-bus` | `RedisStreamsEventBus`, its options, topology and error types, `streamNamesOf`, `DEFAULT_PREFIX`, the codes `ATTEMPTS_EXHAUSTED` and `POISON_ENTRY`, the names of the entry fields | the kernel, `ioredis` (peer) |
18
20
  | `@ddd-ts-kit/redis/testing` | `cacheContract`, `lockContract`, `rateLimiterContract`, re-exported from `@ddd-ts-kit/testing/contracts` | `@ddd-ts-kit/testing` (optional peer), the runner you pass |
19
21
 
22
+ ## Where it sits in an application
23
+
20
24
  Nothing here is imported from `domain/` or `application/`. A use case depends on the port
21
25
  (`Cache`, `Lock`, `RateLimiter` of the kernel); the adapter is chosen in `platform/` and bound
22
26
  in the composition root.
23
27
 
28
+ ![The six ports the code names (Cache, Lock, RateLimiter, RelaySignal, JobStore, EventBus), the six adapters of @ddd-ts-kit/redis that implement them, and the one server they send their commands to; the platform binds them, the outbox and the processed events stay in the database](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/redis-place-in-an-application.svg)
29
+
30
+ The same use case then runs against Redis in production and against the in-memory adapters of
31
+ the kernel in its tests. Instead of naming the adapter, which ties the use case to one server
32
+ and which [`layer/application-inward`](../eslint-plugin/docs/rules/layer-application-inward.md)
33
+ reports in `application/`, name the port:
34
+
35
+ <!-- checked sample -->
36
+ ```ts
37
+ import { Cache, FixedClock, InMemoryCache, cacheKey, type CacheKey } from '@ddd-ts-kit/kernel';
38
+
39
+ interface Quote {
40
+ readonly pair: string;
41
+ readonly rate: number;
42
+ }
43
+
44
+ interface ExchangeRates {
45
+ quoteOf(pair: string): Promise<Quote>;
46
+ }
47
+
48
+ const quoteKey = (pair: string): CacheKey<Quote> => cacheKey<Quote>('quote', pair);
49
+
50
+ // Instead of `constructor(private readonly cache: RedisCache)`: the port of the kernel.
51
+ export class QuoteReader {
52
+ constructor(
53
+ private readonly cache: Cache,
54
+ private readonly rates: ExchangeRates,
55
+ ) {}
56
+
57
+ async quoteOf(pair: string): Promise<Quote> {
58
+ const cached = await this.cache.get(quoteKey(pair));
59
+
60
+ if (cached !== undefined) {
61
+ return cached;
62
+ }
63
+
64
+ const quote = await this.rates.quoteOf(pair);
65
+ await this.cache.set(quoteKey(pair), quote, { ttlMs: 30_000 });
66
+
67
+ return quote;
68
+ }
69
+ }
70
+
71
+ // In a test, the in-memory cache of the kernel; in production, RedisCache, bound in platform/.
72
+ const clock = new FixedClock(new Date('2026-10-13T09:00:00Z'));
73
+ export const reader = new QuoteReader(new InMemoryCache(clock), { quoteOf: async (pair) => ({ pair, rate: 1.08 }) });
74
+ ```
75
+
24
76
  With the CLI, `init --cache redis` writes `platform/redis/open-redis.ts` (one connection, the
25
- cache, the lock, the rate limiter, the relay signal, the readiness probe) and
26
- `init --broker redis` writes `platform/events/create-event-provider.ts` around
27
- `RedisStreamsEventBus`. What follows is each piece, wired by hand.
77
+ cache, the lock, the rate limiter, the relay signal when events go through an outbox, the
78
+ readiness probe), `init --broker redis` writes `platform/events/create-event-provider.ts`
79
+ around `RedisStreamsEventBus`, and `init --queue redis` (which needs `--cache redis`) adds the
80
+ job store to the same connection. What follows is each piece, wired by hand.
28
81
 
29
82
  ## Connection
30
83
 
84
+ <!-- checked sample -->
31
85
  ```ts
32
86
  // src/platform/redis/redis.ts
33
87
  import { connectRedis, redisHealth } from '@ddd-ts-kit/redis';
34
88
 
35
- export const redis = connectRedis(config.redisUrl); // or ioredis options, or a client you already have
89
+ declare const config: { readonly redisUrl: string };
90
+
91
+ export const redis = connectRedis(config.redisUrl); // or ioredis options, or a client you already have
36
92
 
37
- export const ready = () => redisHealth(redis.client); // { status: 'up', latencyMs } | { status: 'down', reason }
93
+ export const ready = () => redisHealth(redis.client); // { status: 'up', latencyMs } | { status: 'down', reason }
38
94
  export const shutdown = () => redis.close();
39
95
  ```
40
96
 
@@ -46,7 +102,7 @@ export const shutdown = () => redis.close();
46
102
  defaults, a command sent while the server is away waits for twenty reconnections before
47
103
  failing, and every request of the application waits with it.
48
104
  - `redisHealth(client, { timeoutMs? })` sends one `PING`, never throws, and answers within the
49
- timeout: use it in a readiness probe. A cache being down is rarely a reason to stop
105
+ timeout (1 s by default): use it in a readiness probe. A cache being down is rarely a reason to stop
50
106
  receiving traffic; a lock or a rate limiter being down may be.
51
107
  - Every adapter takes the ioredis client, so several share one connection. The relay signal
52
108
  opens a second one, to listen, and the event bus one per consumer name, to block on its
@@ -75,31 +131,51 @@ volumes:
75
131
  **Credentials.** A client of ioredis keeps its options, password included, and shows them to
76
132
  whoever inspects it: that cannot be changed from here, so never log a client. The adapters of
77
133
  this package (`RedisCache`, `RedisLock`, `RedisRateLimiter`, `RedisRelaySignal`,
78
- `RedisStreamsEventBus`) hold their client in a field that is not enumerable, and answer their
79
- name to `JSON.stringify` and `util.inspect`: logging, spreading or serialising an adapter does
80
- not lead to the client. The object `connectRedis` answers gives its `client` by name
81
- (`redis.client`, `const { client } = redis`) and leaves it out of a spread or a log.
134
+ `RedisJobStore`, `RedisStreamsEventBus`) hold their client in a field that is not enumerable,
135
+ and answer their name to `JSON.stringify` and `util.inspect`: logging, spreading or serialising
136
+ an adapter does not lead to the client. The object `connectRedis` answers gives its `client` by
137
+ name (`redis.client`, `const { client } = redis`) and leaves it out of a spread or a log.
138
+
139
+ <!-- checked sample -->
140
+ ```ts
141
+ import type { Logger } from '@ddd-ts-kit/kernel';
142
+ import { redisHealth, type RedisConnection } from '@ddd-ts-kit/redis';
143
+
144
+ declare const redis: RedisConnection;
145
+ declare const logger: Logger;
146
+
147
+ export async function logStartup(): Promise<void> {
148
+ // Instead of: logger.info('redis connected', { client: redis.client }), the password with it.
149
+ // Write what an operator needs, which holds no secret:
150
+ const health = await redisHealth(redis.client);
151
+ logger.info('redis connected', { status: health.status });
152
+ }
153
+ ```
82
154
 
83
155
  ## Cache
84
156
 
85
157
  `RedisCache` implements the kernel `Cache` port with its typed `CacheKey`.
86
158
 
159
+ <!-- checked sample -->
87
160
  ```ts
88
161
  // src/main.ts
89
162
  import { Cache, Lock, Metrics, RateLimiter } from '@ddd-ts-kit/kernel';
90
- import { contextModule, createApplication, defineContextModule, kernelModule } from '@ddd-ts-kit/di';
91
- import { RedisCache, RedisLock, RedisRateLimiter } from '@ddd-ts-kit/redis';
163
+ import { contextModule, createApplication, defineContextModule, kernelModule, type DeclaredSource } from '@ddd-ts-kit/di';
164
+ import { RedisCache, RedisLock, RedisRateLimiter, type RedisConnection } from '@ddd-ts-kit/redis';
165
+
166
+ declare const redis: RedisConnection; // connectRedis(config.redisUrl), above
167
+ declare const catalogDeclarations: DeclaredSource;
92
168
 
93
169
  const platformModule = defineContextModule({
94
170
  name: 'platform',
95
171
  register(builder) {
96
172
  builder.rebind(Cache).toFactory((resolve) => new RedisCache(redis.client, { namespace: 'app', metrics: resolve(Metrics) }));
97
- builder.rebind(Lock).toValue(new RedisLock(redis.client, { namespace: 'app' }));
98
- builder.rebind(RateLimiter).toValue(new RedisRateLimiter(redis.client, { namespace: 'app' }));
173
+ builder.rebind(Lock).toValue(new RedisLock(redis.client, { namespace: 'app:lock' }));
174
+ builder.rebind(RateLimiter).toValue(new RedisRateLimiter(redis.client, { namespace: 'app:rate' }));
99
175
  },
100
176
  });
101
177
 
102
- const app = createApplication({ modules: [kernelModule(), platformModule, contextModule('catalog', catalogDeclarations)] });
178
+ export const app = createApplication({ modules: [kernelModule(), platformModule, contextModule('catalog', catalogDeclarations)] });
103
179
  ```
104
180
 
105
181
  A port of the kernel is an abstract class, and the class is what a binding is keyed by: the
@@ -107,7 +183,9 @@ three adapters `implements` theirs, and a use case that names `Cache`, `Lock` or
107
183
  in its dependencies receives them. `kernelModule({ cache: (resolve) => new RedisCache(...), lock: ..., rateLimiter: ... })`
108
184
  does the same through its options. `cached` of `@ddd-ts-kit/aspects` and everything that
109
185
  resolves `Cache` then use it without knowing. A context that wants its keys apart builds its
110
- own instance with its own `namespace`.
186
+ own instance with its own `namespace`. Give each adapter a namespace of its own, as above:
187
+ without one, the lock writes under `lock:` and the rate limiter under `rate:`, which two
188
+ applications sharing a server would share too.
111
189
 
112
190
  | Option | Default | Effect |
113
191
  |---|---|---|
@@ -126,6 +204,40 @@ own instance with its own `namespace`.
126
204
  request; prefer short time to live values to invalidation.
127
205
  - An entry that cannot be decoded is a miss and is counted.
128
206
 
207
+ The type a `CacheKey` carries is a promise the codec has to keep. JSON keeps it for plain data
208
+ only, so cache the shape the value really has after the round trip:
209
+
210
+ <!-- checked sample -->
211
+ ```ts
212
+ import { cacheKey, type Cache } from '@ddd-ts-kit/kernel';
213
+
214
+ declare const cache: Cache;
215
+
216
+ interface Slot {
217
+ readonly id: string;
218
+ readonly startsAt: Date;
219
+ }
220
+
221
+ // Instead of: the key promises a Date, and the cache answers a string.
222
+ export async function cacheSlot(slot: Slot): Promise<void> {
223
+ await cache.set(cacheKey<Slot>('slot', slot.id), slot, { ttlMs: 60_000 });
224
+ }
225
+
226
+ // Write: plain data, in the shape it has once read back.
227
+ interface SlotView {
228
+ readonly id: string;
229
+ readonly startsAt: string;
230
+ }
231
+
232
+ export async function cacheSlotView(slot: Slot): Promise<void> {
233
+ const view: SlotView = { id: slot.id, startsAt: slot.startsAt.toISOString() };
234
+
235
+ await cache.set(cacheKey<SlotView>('slot', slot.id), view, { ttlMs: 60_000 });
236
+ }
237
+ ```
238
+
239
+ Or give a `codec` (`{ encode(value): string; decode(text): unknown }`) that knows your types.
240
+
129
241
  **A cache must not take the application down.** With the default `onError: 'miss'`, a server
130
242
  that is away, slow beyond the command timeout or answering errors makes the application run
131
243
  as if it had no cache. Watch `cache_errors_total`: a cache that silently stopped working is a
@@ -139,16 +251,39 @@ When a popular entry expires, every request that misses it computes it again at
139
251
  among concurrent identical calls (single flight). Give it a `RedisCache` and that part is done.
140
252
  - Across processes, use `getOrLoad` with a `lock`:
141
253
 
254
+ <!-- checked sample -->
142
255
  ```ts
143
- const cache = new RedisCache(redis.client, { namespace: 'catalog', lock: new RedisLock(redis.client, { namespace: 'catalog' }) });
256
+ import { cacheKey, type CacheKey } from '@ddd-ts-kit/kernel';
257
+ import { RedisCache, RedisLock, type RedisConnection } from '@ddd-ts-kit/redis';
258
+
259
+ declare const redis: RedisConnection;
260
+
261
+ interface Quote {
262
+ readonly pair: string;
263
+ readonly rate: number;
264
+ }
265
+
266
+ declare const rates: { quoteOf(pair: string): Promise<Quote> };
267
+
268
+ const quoteKey = (pair: string): CacheKey<Quote> => cacheKey<Quote>('quote', pair);
269
+
270
+ const cache = new RedisCache(redis.client, {
271
+ namespace: 'catalog',
272
+ lock: new RedisLock(redis.client, { namespace: 'catalog:lock' }),
273
+ });
144
274
 
145
- const quote = await cache.getOrLoad(quoteKey(pair), () => rates.quoteOf(pair), { ttlMs: 30_000 });
275
+ export function quoteOf(pair: string): Promise<Quote> {
276
+ return cache.getOrLoad(quoteKey(pair), () => rates.quoteOf(pair), { ttlMs: 30_000 });
277
+ }
146
278
  ```
147
279
 
148
280
  `getOrLoad` reads the entry; on a miss the callers of this process share one computation, and
149
281
  with a lock only one process computes: the others wait for its value up to `waitMs` (default
150
- 2 s), then compute it themselves. It never fails because of the lock or the server: the worst
151
- case is the unprotected one. A failed computation is not cached.
282
+ 2 s), then compute it themselves. The process that computes holds the lock for `lockTtlMs` at
283
+ most (default 10 s): past it, another one may start. It never fails because of the lock or the
284
+ server: the worst case is the unprotected one. A failed computation is not cached. The
285
+ [cache and coordination guide](../../docs/tools/cache-and-coordination.md#what-this-kit-supports-and-verified)
286
+ draws these steps.
152
287
 
153
288
  ## Lock
154
289
 
@@ -161,15 +296,25 @@ abstract class Lock {
161
296
  interface LockHandle { readonly key: string; readonly fencingToken: number; release(): Promise<void> }
162
297
  ```
163
298
 
299
+ <!-- checked sample -->
164
300
  ```ts
165
- const handle = await lock.acquire('nightly-export', { ttlMs: 60_000 });
166
- if (handle === undefined) { // another instance is already doing it
167
- return;
168
- }
169
- try {
170
- await exportEverything();
171
- } finally {
172
- await handle.release();
301
+ import type { Lock } from '@ddd-ts-kit/kernel';
302
+
303
+ declare const lock: Lock;
304
+ declare function exportEverything(): Promise<void>;
305
+
306
+ export async function nightlyExport(): Promise<void> {
307
+ const handle = await lock.acquire('nightly-export', { ttlMs: 60_000 });
308
+
309
+ if (handle === undefined) {
310
+ return; // another instance is already doing it
311
+ }
312
+
313
+ try {
314
+ await exportEverything();
315
+ } finally {
316
+ await handle.release();
317
+ }
173
318
  }
174
319
  ```
175
320
 
@@ -198,11 +343,64 @@ double booking, a double payment or a duplicated invoice number belongs to a dat
198
343
  constraint (a unique index, an exclusion constraint, the optimistic version of the aggregate),
199
344
  which holds whatever any process believes about a lock.
200
345
 
346
+ ```ts
347
+ // Instead of: a lock as the guard of an invariant.
348
+ const handle = await lock.acquire(`slot:${slotId}`, { ttlMs: 5_000 });
349
+ if (handle !== undefined && !(await bookings.existsForSlot(slotId))) {
350
+ await bookings.save(booking); // two holders (a pause, a failover) both get here: booked twice
351
+ }
352
+
353
+ // Write: the database refuses the second booking, whatever any process believes.
354
+ // CREATE UNIQUE INDEX bookings_one_per_slot ON bookings (slot_id);
355
+ await bookings.save(booking); // the second insert fails on the index: the handler answers a refusal
356
+ ```
357
+
201
358
  The **fencing token** grows with each acquisition of a key (`INCR`, in the same script as the
202
359
  `SET`). A resource that stores the highest token it has seen can refuse a write from a stale
203
360
  holder. It narrows the gap, for resources able to compare; its counter lives in the same
204
361
  server and starts again if that server loses its data, so it is not a proof either.
205
362
 
363
+ <!-- checked sample -->
364
+ ```ts
365
+ import type { Lock } from '@ddd-ts-kit/kernel';
366
+
367
+ declare const lock: Lock;
368
+ declare function buildReport(): Promise<string>;
369
+
370
+ /** In practice a column of the row being written: here, the highest token the store accepted. */
371
+ export class ReportStore {
372
+ private highestToken = 0;
373
+
374
+ private readonly reports: string[] = [];
375
+
376
+ write(report: string, fencingToken: number): void {
377
+ if (fencingToken < this.highestToken) {
378
+ throw new Error(`Stale holder: token ${String(fencingToken)} after ${String(this.highestToken)}`);
379
+ }
380
+
381
+ this.highestToken = fencingToken;
382
+ this.reports.push(report);
383
+ }
384
+ }
385
+
386
+ export async function nightlyReport(store: ReportStore): Promise<void> {
387
+ const handle = await lock.acquire('nightly-report', { ttlMs: 60_000 });
388
+
389
+ if (handle === undefined) {
390
+ return;
391
+ }
392
+
393
+ try {
394
+ store.write(await buildReport(), handle.fencingToken);
395
+ } finally {
396
+ await handle.release();
397
+ }
398
+ }
399
+ ```
400
+
401
+ The [cache and coordination guide](../../docs/tools/cache-and-coordination.md#what-the-lock-does-and-does-not-guarantee)
402
+ draws two holders of one key and how the token tells them apart.
403
+
206
404
  A server that is away makes `acquire` reject: whether somebody holds the key cannot be known,
207
405
  and the caller decides.
208
406
 
@@ -221,19 +419,33 @@ it. A refused call is not counted. `RedisRateLimiter` keeps one sorted set per k
221
419
  per allowed call, and decides in a single script, so concurrent calls from several instances
222
420
  cannot both take the last place.
223
421
 
422
+ ![A limit of three calls per minute: the calls at 20, 30 and 45 seconds are allowed; the call at 60 seconds sees three calls in its window, is refused, not counted, and told to retry after 20 seconds; the call at 82 seconds sees two and is allowed](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/redis-rate-limiter-sliding-window.svg)
423
+
424
+ `remaining` counts the places left once this call is counted; `retryAfterMs` is 0 for an
425
+ allowed call, and for a refused one the time until the oldest call of the window leaves it.
426
+
224
427
  In front of the HTTP layer, before authentication cost anything (Fastify shown; the same three
225
428
  lines fit an Express middleware):
226
429
 
430
+ <!-- checked sample -->
227
431
  ```ts
228
432
  // src/platform/http/rate-limit.ts
433
+ import Fastify from 'fastify';
434
+ import { RedisRateLimiter, type RedisConnection } from '@ddd-ts-kit/redis';
435
+
436
+ declare const redis: RedisConnection;
437
+
438
+ export const server = Fastify();
229
439
  const limiter = new RedisRateLimiter(redis.client, { namespace: 'api', onError: 'allow' });
230
440
 
231
441
  server.addHook('onRequest', async (request, reply) => {
232
442
  const decision = await limiter.consume(`ip:${request.ip}`, { limit: 120, windowMs: 60_000 });
233
443
  reply.header('RateLimit-Remaining', decision.remaining);
444
+
234
445
  if (decision.allowed) {
235
446
  return;
236
447
  }
448
+
237
449
  reply.header('Retry-After', Math.ceil(decision.retryAfterMs / 1000));
238
450
  return reply.code(429).send({ type: 'about:blank', title: 'Too Many Requests', status: 429, code: 'RATE_LIMITED' });
239
451
  });
@@ -242,13 +454,49 @@ server.addHook('onRequest', async (request, reply) => {
242
454
  A use case that must be limited for a business reason (three password resets per hour)
243
455
  depends on the `RateLimiter` port of the kernel and receives the adapter by injection
244
456
  (`builder.rebind(RateLimiter)`, shown under [Cache](#cache)); `InMemoryRateLimiter` on a
245
- `FixedClock` is its fake.
457
+ `FixedClock` is its fake. The refusal is a business answer, with its code, not an HTTP status
458
+ written in the use case:
459
+
460
+ <!-- checked sample -->
461
+ ```ts
462
+ import { Command, ConceptError, FixedClock, InMemoryRateLimiter, RateLimiter, type CommandHandler } from '@ddd-ts-kit/kernel';
463
+
464
+ export class RequestPasswordReset extends Command {
465
+ constructor(readonly accountId: string) {
466
+ super();
467
+ }
468
+ }
469
+
470
+ export class PasswordResetError extends ConceptError {
471
+ static tooMany(retryAfterMs: number) {
472
+ return PasswordResetError.refusal('PASSWORD_RESETS_EXHAUSTED', 'Three password resets per hour at most.', { retryAfterMs });
473
+ }
474
+ }
475
+
476
+ export class RequestPasswordResetHandler implements CommandHandler<RequestPasswordReset> {
477
+ constructor(private readonly limiter: RateLimiter) {}
478
+
479
+ async execute({ accountId }: RequestPasswordReset): Promise<void> {
480
+ const decision = await this.limiter.consume(`password-reset:${accountId}`, { limit: 3, windowMs: 3_600_000 });
481
+
482
+ if (!decision.allowed) {
483
+ throw PasswordResetError.tooMany(decision.retryAfterMs);
484
+ }
485
+
486
+ // send the link
487
+ }
488
+ }
489
+
490
+ // In a test: the in-memory limiter on a fixed clock, and time moves when the test says so.
491
+ const clock = new FixedClock(new Date('2026-10-13T09:00:00Z'));
492
+ export const handler = new RequestPasswordResetHandler(new InMemoryRateLimiter(clock));
493
+ ```
246
494
 
247
495
  - **Exact, at a memory cost.** One member per allowed call: a key costs memory in proportion
248
496
  to its limit. Fine up to thousands per window; for tens of thousands, limit a coarser key or
249
497
  a shorter window.
250
- - **The clock is the caller's.** Instants come from the `Clock` given to the limiter (the
251
- system clock by default), not from the server, so every instance must be synchronized (NTP):
498
+ - **The clock is the caller's.** Instants come from the `Clock` given to the limiter (its
499
+ `clock` option, the system clock by default), not from the server, so every instance must be synchronized (NTP):
252
500
  a skew between two instances shifts the window by as much. In exchange the limiter is tested
253
501
  on a fixed clock, and the script holds no arithmetic that differs between Lua versions.
254
502
  - **`onError`**: `throw` (default) lets the caller decide; `allow` keeps serving without a
@@ -261,13 +509,27 @@ depends on the `RateLimiter` port of the kernel and receives the adapter by inje
261
509
  An outbox relay polls. `RedisRelaySignal` lets it poll rarely and still publish at once, across
262
510
  processes: the process that committed a command notifies, a relay in any other process wakes.
263
511
 
512
+ <!-- checked sample -->
264
513
  ```ts
265
- // the worker that relays
514
+ import { InMemoryCommandBus, SystemClock, unitOfWorkMiddleware, type IntegrationEventPublisher, type UnitOfWork } from '@ddd-ts-kit/kernel';
515
+ import { OutboxRelay, type OutboxStore } from '@ddd-ts-kit/events';
516
+ import { RedisRelaySignal, relayWakeUpMiddleware, type RedisConnection } from '@ddd-ts-kit/redis';
517
+
518
+ declare const redis: RedisConnection;
519
+ declare const store: OutboxStore;
520
+ declare const publisher: IntegrationEventPublisher;
521
+ declare const unitOfWork: UnitOfWork;
522
+
266
523
  const signal = new RedisRelaySignal(redis.client, { channel: 'app:outbox' });
267
- new OutboxRelay({ store, publisher, clock, wakeOn: signal }).start({ intervalMs: 5_000 });
268
524
 
269
- // the API process, before the unit of work so that it notifies after the commit
270
- commandBus.use(relayWakeUpMiddleware(signal)).use(unitOfWorkMiddleware(unitOfWork));
525
+ // The worker that relays: it wakes on a notification, and still polls every 5 s.
526
+ export const relay = new OutboxRelay({ store, publisher, clock: new SystemClock(), wakeOn: signal });
527
+ relay.start({ intervalMs: 5_000 });
528
+
529
+ // The API process. Instead of `.use(unitOfWorkMiddleware(unitOfWork)).use(relayWakeUpMiddleware(signal))`,
530
+ // which notifies inside the transaction, before the record is visible, put the wake-up first:
531
+ // it is then the outer middleware, and notifies once the unit of work committed.
532
+ export const commandBus = new InMemoryCommandBus().use(relayWakeUpMiddleware(signal)).use(unitOfWorkMiddleware(unitOfWork));
271
533
  ```
272
534
 
273
535
  It satisfies the `RelaySignal` port of `@ddd-ts-kit/events` structurally: this package does not
@@ -311,25 +573,52 @@ memory: see [Guarantees and limits](#guarantees-and-limits).
311
573
  Handlers never see this package. They publish to the outbox; the relay publishes to the
312
574
  streams; each consuming context subscribes through the bus under its own name.
313
575
 
576
+ ![The outbox relay claims pending records from the outbox table and publishes them with XADD in one MULTI; each event type has one stream; each consumer name has its own group on it, notifications and billing, both receiving every event; the instances of billing share its entries; a failed or poison entry goes to the dead stream of billing](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/redis-streams-topology.svg)
577
+
578
+ <!-- checked sample -->
314
579
  ```ts
315
580
  // main.ts
316
- import { SystemClock } from '@ddd-ts-kit/kernel';
317
- import { OutboxPublisher, OutboxRelay, consume, subscribeAll, validatingPublisher } from '@ddd-ts-kit/events';
581
+ import { Command, SystemClock, type CommandBus, type Logger, type Metrics, type ProcessedEventStore, type UnitOfWork } from '@ddd-ts-kit/kernel';
582
+ import {
583
+ OutboxPublisher,
584
+ OutboxRelay,
585
+ consume,
586
+ subscribeAll,
587
+ validatingPublisher,
588
+ type EventRegistry,
589
+ type IntegrationEventDefinition,
590
+ type OutboxStore,
591
+ } from '@ddd-ts-kit/events';
318
592
  import { RedisStreamsEventBus } from '@ddd-ts-kit/redis/event-bus';
319
593
  import { tracedPublisher, tracedSubscriber } from '@ddd-ts-kit/tracing';
320
594
 
595
+ declare const config: { readonly redisUrl: string };
596
+ declare const logger: Logger;
597
+ declare const metrics: Metrics;
598
+ declare const outboxStore: OutboxStore;
599
+ declare const registry: EventRegistry;
600
+ declare const processed: ProcessedEventStore;
601
+ declare const unitOfWork: UnitOfWork;
602
+ declare const commandBus: CommandBus;
603
+ declare const orderPlaced: IntegrationEventDefinition<{ readonly orderId: string }>;
604
+ declare class IssueInvoice extends Command {
605
+ constructor(orderId: string);
606
+ }
607
+
608
+ const deliveries = metrics.counter('event_deliveries_total');
609
+
321
610
  const bus = new RedisStreamsEventBus({
322
611
  connection: config.redisUrl, // or ioredis options, or a client you already have (redis.client)
323
612
  prefix: 'integration-events', // the default: one stream per type, `<prefix>:<type>`
324
613
  retry: { maxAttempts: 5, delayMs: 5_000 }, // the defaults
325
614
  visibilityTimeoutMs: 60_000, // the default: above the worst case of a batch of handlers
326
615
  batchSize: 10, // the default
327
- onFailure: (failure) => logger.warn(failure), // codes only, never an error message
328
- onDelivery: ({ stream, consumer, outcome }) => deliveries.add(1, { stream, consumer, outcome }),
616
+ onFailure: (failure) => logger.warn('event bus failure', { ...failure }), // codes only, never an error message
617
+ onDelivery: ({ stream, consumer, outcome }) => deliveries.increment({ stream, consumer, result: outcome }),
329
618
  });
330
619
 
331
620
  // Publishing side: handlers write to the outbox, in the transaction of the command.
332
- const publisher = validatingPublisher(new OutboxPublisher(outboxStore), registry);
621
+ export const publisher = validatingPublisher(new OutboxPublisher(outboxStore), registry);
333
622
 
334
623
  // The relay target is the bus. A record is marked published when the server answered its entry id.
335
624
  const relay = new OutboxRelay({ store: outboxStore, publisher: tracedPublisher(bus), clock: new SystemClock() });
@@ -443,6 +732,8 @@ version in the payload when it matters.
443
732
 
444
733
  ### Retry and dead-letter behaviour
445
734
 
735
+ ![An entry delivered with attempt n: without an envelope it goes to the dead stream as poison; past retry.maxAttempts after its instances crashed on it, to the dead stream as ATTEMPTS_EXHAUSTED; otherwise every handler of its type runs; all resolved, it is acknowledged; one threw on the last attempt, it is copied to the dead stream and acknowledged in one script; otherwise it stays pending and is claimed again after retry.delayMs; another instance takes over the entries of a crashed one after visibilityTimeoutMs](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/redis-streams-retry-and-dead-letter.svg)
736
+
446
737
  1. An entry is acknowledged (`XACK`) after every handler subscribed to its type resolved. Use
447
738
  `consume` of `@ddd-ts-kit/events`: the acknowledgement then follows the commit of the unit
448
739
  of work, and a redelivery is deduplicated.
@@ -513,10 +804,45 @@ What to watch, with the commands of any client (`<stream>` is `<prefix>:<type>`)
513
804
  | `onFailure` with `kind: 'connection'` | this bus | reconnections (`CONNECTION_LOST`, `ECONNREFUSED`) and what the server refused to a reader (`REDIS_NOGROUP`) |
514
805
  | `bus.deliveriesInFlight` | this bus | entries read and not settled; stuck above 0 means a handler that hangs |
515
806
 
807
+ `onFailure` says codes only. To report an event the bus gives up with the error its handler
808
+ really threw, as the example application does, hand the hook to `DeadLetterReports` of the
809
+ kernel and subscribe through it: a delivery that fails and is retried is not reported, a dead
810
+ or poison entry is.
811
+
812
+ <!-- checked sample -->
813
+ ```ts
814
+ import { DeadLetterReports, type ErrorReporter } from '@ddd-ts-kit/kernel';
815
+ import { RedisStreamsEventBus } from '@ddd-ts-kit/redis/event-bus';
816
+
817
+ declare const config: { readonly redisUrl: string };
818
+ declare const errors: ErrorReporter;
819
+
820
+ const deadLetters = new DeadLetterReports(errors);
821
+
822
+ export const bus = new RedisStreamsEventBus({
823
+ connection: config.redisUrl,
824
+ onFailure: (failure) => deadLetters.onFailure(failure),
825
+ });
826
+
827
+ // Remembers what each handler threw, until onFailure says whether the event was given up.
828
+ export const billingEvents = deadLetters.observing(bus.forConsumer('billing'));
829
+ ```
830
+
516
831
  Replaying dead entries, once the cause is fixed:
517
832
 
833
+ <!-- checked sample -->
518
834
  ```ts
519
- const replayed = await bus.replayDead({ consumer: 'billing', type: 'ordering.order.placed' }, 100);
835
+ import { streamNamesOf, type RedisStreamsEventBus } from '@ddd-ts-kit/redis/event-bus';
836
+
837
+ declare const bus: RedisStreamsEventBus;
838
+
839
+ const billing = { consumer: 'billing', type: 'ordering.order.placed' };
840
+
841
+ // The keys to read first, with XLEN and XRANGE: the events stream and the dead stream of billing.
842
+ export const { events, dead } = streamNamesOf(billing); // with the default prefix; pass `streamOf` otherwise
843
+
844
+ // Up to 100 entries back to the events stream, oldest first, for billing only.
845
+ export const replayed = await bus.replayDead(billing, 100);
520
846
  ```
521
847
 
522
848
  It moves up to that many entries, oldest first, from the dead stream back to the events
@@ -673,10 +999,19 @@ for a project without a database or a queue it wants out of it: a few Lua script
673
999
  client of the package, every key under `{<namespace>}` so that a cluster keeps them in one slot
674
1000
  and Dragonfly sees every key a script touches declared.
675
1001
 
1002
+ <!-- checked sample -->
676
1003
  ```ts
677
- const jobStore = new RedisJobStore(redis, { namespace: 'jobs', onLostJob: reportLostJobs(logger, errors) });
1004
+ import type { ErrorReporter, Logger } from '@ddd-ts-kit/kernel';
1005
+ import { kernelModule } from '@ddd-ts-kit/di';
1006
+ import { RedisJobStore, reportLostJobs, type RedisConnection } from '@ddd-ts-kit/redis';
678
1007
 
679
- kernelModule({ jobStore });
1008
+ declare const redis: RedisConnection;
1009
+ declare const logger: Logger;
1010
+ declare const errors: ErrorReporter;
1011
+
1012
+ const jobStore = new RedisJobStore(redis.client, { namespace: 'jobs', onLostJob: reportLostJobs(logger, errors) });
1013
+
1014
+ export const kernel = kernelModule({ jobStore });
680
1015
  ```
681
1016
 
682
1017
  `reportLostJobs(logger, errors?)` logs a write lost after its unit of work committed (the id of
@@ -701,13 +1036,24 @@ hand on Redis 8 (last run: Dragonfly 2.0.2, Redis 8.2, 2026-10-13). What the job
701
1036
  Each port has a suite that its in-memory implementation and its adapter both pass. An adapter
702
1037
  of your own (another client library, another store) is held to the same:
703
1038
 
1039
+ <!-- checked sample -->
704
1040
  ```ts
1041
+ import { beforeEach, describe, expect, it } from 'vitest';
1042
+ import { FixedClock, type Cache, type Clock, type Lock, type RateLimiter } from '@ddd-ts-kit/kernel';
705
1043
  import { cacheContract, lockContract, rateLimiterContract } from '@ddd-ts-kit/testing/contracts';
706
1044
 
1045
+ // Your adapters, under test: each call answers a fresh one.
1046
+ declare function createMyCache(): Cache;
1047
+ declare function createMyLock(): Lock;
1048
+ declare function createMyLimiter(clock: Clock): RateLimiter;
1049
+
1050
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
1051
+ const clock = new FixedClock(new Date('2026-10-13T09:00:00Z'));
1052
+
707
1053
  const runner = { describe, it, expect, beforeEach };
708
- cacheContract(runner, { name: 'MyCache', createCache: () => new MyCache(), elapse: (ms) => sleep(ms) });
709
- lockContract(runner, { name: 'MyLock', createLock: () => new MyLock(), elapse: (ms) => sleep(ms) });
710
- rateLimiterContract(runner, { name: 'MyLimiter', createLimiter: () => new MyLimiter(clock), elapse: (ms) => clock.advance(ms) });
1054
+ cacheContract(runner, { name: 'MyCache', createCache: () => createMyCache(), elapse: (ms) => sleep(ms) });
1055
+ lockContract(runner, { name: 'MyLock', createLock: () => createMyLock(), elapse: (ms) => sleep(ms) });
1056
+ rateLimiterContract(runner, { name: 'MyLimiter', createLimiter: () => createMyLimiter(clock), elapse: (ms) => clock.advance(ms) });
711
1057
  ```
712
1058
 
713
1059
  `elapse` makes time pass for the subject: advance the clock it reads, or really wait when the