@ddd-ts-kit/rabbitmq 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.
- package/README.fr.md +384 -0
- package/README.md +102 -12
- package/package.json +4 -4
package/README.fr.md
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
[English](README.md) · **Français**
|
|
2
|
+
<!-- translation-of: README.md sha256:e7ab91d746cae3cf -->
|
|
3
|
+
|
|
4
|
+
# @ddd-ts-kit/rabbitmq
|
|
5
|
+
|
|
6
|
+
Le fournisseur d'événements d'intégration pour RabbitMQ : un `EventBus` du noyau qui publie avec
|
|
7
|
+
des confirmations de publication (publisher confirms), pour que le relais de l'outbox puisse
|
|
8
|
+
marquer un enregistrement publié sans risque, et qui consomme depuis une file d'attente durable
|
|
9
|
+
par consommateur et par type d'événement, avec acquittement manuel, un nombre borné de nouvelles
|
|
10
|
+
tentatives différées et une file de rejet (dead letter). C'est la ligne « brokers à files
|
|
11
|
+
d'attente » du tableau des stratégies de `@ddd-ts-kit/events`, implémentée, et il passe
|
|
12
|
+
`eventBusContract` de `@ddd-ts-kit/testing`.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
npm i @ddd-ts-kit/rabbitmq @ddd-ts-kit/events amqplib
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Testé avec amqplib 2.2 contre RabbitMQ 4.3. Tout *broker* AMQP 0-9-1 doté de confirmations de
|
|
19
|
+
publication devrait fonctionner avec `queueType: 'classic'` ; seul RabbitMQ a été exécuté.
|
|
20
|
+
|
|
21
|
+
## Racine de composition
|
|
22
|
+
|
|
23
|
+
Les gestionnaires ne voient jamais ce *package*. Ils publient dans l'outbox ; le relais publie
|
|
24
|
+
dans RabbitMQ ; chaque contexte consommateur écoute par le bus, sous son propre nom. Le bus est
|
|
25
|
+
donné à l'application sous la forme de deux ports du noyau : `IntegrationEventPublisher` reste
|
|
26
|
+
l'outbox, et `EventSubscribers` remet à chaque contexte l'abonné de son propre nom de
|
|
27
|
+
consommateur.
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
`init --broker rabbitmq` écrit ce câblage dans la plateforme du projet : le bus est construit par
|
|
32
|
+
`platform/events/create-event-provider.ts`, `platform/platform.module.ts` lie à nouveau
|
|
33
|
+
(`rebind`) `EventSubscribers` sur lui, et `main.ts` le donne, tracé, au relais de l'outbox. À la
|
|
34
|
+
main, la même chose s'écrit :
|
|
35
|
+
|
|
36
|
+
<!-- checked sample -->
|
|
37
|
+
```ts
|
|
38
|
+
// platform/messaging.module.ts
|
|
39
|
+
import { defineContextModule } from '@ddd-ts-kit/di';
|
|
40
|
+
import { EventSubscribers, IntegrationEventPublisher, type Logger, type Metrics } from '@ddd-ts-kit/kernel';
|
|
41
|
+
import { OutboxPublisher, OutboxStore } from '@ddd-ts-kit/events';
|
|
42
|
+
import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
|
|
43
|
+
|
|
44
|
+
declare const config: { readonly amqpUrl: string };
|
|
45
|
+
declare const logger: Logger;
|
|
46
|
+
declare const metrics: Metrics;
|
|
47
|
+
|
|
48
|
+
export const bus = new RabbitMqEventBus({
|
|
49
|
+
connection: config.amqpUrl, // ou des options amqplib, ou une fabrique qui ouvre une nouvelle connexion
|
|
50
|
+
exchange: 'integration-events', // la valeur par défaut
|
|
51
|
+
retry: { maxAttempts: 5, delayMs: 5_000 }, // les valeurs par défaut
|
|
52
|
+
prefetch: 10, // la valeur par défaut
|
|
53
|
+
onFailure: (failure) => logger.warn('event bus failure', { ...failure }), // des codes seulement, jamais un message d'erreur
|
|
54
|
+
// Le libellé `outcome` du noyau vaut success ou failure : les quatre issues d'une livraison prennent un autre nom.
|
|
55
|
+
onDelivery: ({ queue, outcome }) => metrics.counter('event_deliveries_total').increment({ queue, status: outcome }),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const messagingModule = defineContextModule({
|
|
59
|
+
name: 'messaging',
|
|
60
|
+
register(builder) {
|
|
61
|
+
// Côté publication : les gestionnaires écrivent dans l'outbox, dans la transaction de la commande.
|
|
62
|
+
builder.rebind(IntegrationEventPublisher).toClass(OutboxPublisher, [OutboxStore]);
|
|
63
|
+
// Côté consommation : un abonné par contexte consommateur, nommé d'après lui.
|
|
64
|
+
builder.rebind(EventSubscribers).toValue({ forContext: (name) => bus.forConsumer(name) });
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
<!-- checked sample -->
|
|
70
|
+
```ts
|
|
71
|
+
import { Command, CommandBus, systemActor, type Actor, type IntegrationEvent } from '@ddd-ts-kit/kernel';
|
|
72
|
+
import { IntegrationEventHandler } from '@ddd-ts-kit/kernel/declarations';
|
|
73
|
+
import { defineIntegrationEvent, type PayloadOf } from '@ddd-ts-kit/events';
|
|
74
|
+
import { zodSchema } from '@ddd-ts-kit/validation/zod';
|
|
75
|
+
import { z } from 'zod';
|
|
76
|
+
|
|
77
|
+
// contexts/ordering/published-language : le contrat qu'écoute billing.
|
|
78
|
+
export const orderPlaced = defineIntegrationEvent({
|
|
79
|
+
type: 'ordering.order.placed',
|
|
80
|
+
version: 1,
|
|
81
|
+
payload: zodSchema(z.object({ orderId: z.string() })),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// contexts/billing/application/commands/issue-invoice/issue-invoice.command.ts
|
|
85
|
+
export class IssueInvoice extends Command {
|
|
86
|
+
constructor(
|
|
87
|
+
readonly actor: Actor,
|
|
88
|
+
readonly orderId: string,
|
|
89
|
+
) {
|
|
90
|
+
super();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// contexts/billing/application/integration/handlers/invoice-on-order-placed.integration-handler.ts
|
|
95
|
+
@IntegrationEventHandler(orderPlaced, { consumer: 'billing.invoice-on-order-placed' })
|
|
96
|
+
export class InvoiceOnOrderPlacedIntegrationHandler {
|
|
97
|
+
constructor(private readonly commands: CommandBus) {}
|
|
98
|
+
|
|
99
|
+
// Personne n'appelle : le gestionnaire agit comme un acteur système nommé d'après son consommateur.
|
|
100
|
+
readonly actor = systemActor('billing.invoice-on-order-placed');
|
|
101
|
+
|
|
102
|
+
async handle(event: IntegrationEvent<PayloadOf<typeof orderPlaced>>): Promise<void> {
|
|
103
|
+
await this.commands.execute(new IssueInvoice(this.actor, event.payload.orderId));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
<!-- checked sample -->
|
|
109
|
+
```ts
|
|
110
|
+
// main.ts
|
|
111
|
+
import { contextModule, createApplication, kernelModule, type ContextModule, type DeclaredSource } from '@ddd-ts-kit/di';
|
|
112
|
+
import { Clock } from '@ddd-ts-kit/kernel';
|
|
113
|
+
import { OutboxRelay, OutboxStore } from '@ddd-ts-kit/events';
|
|
114
|
+
import type { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
|
|
115
|
+
|
|
116
|
+
// Dans un projet : import { bus, messagingModule } from '@/platform/messaging.module';
|
|
117
|
+
declare const bus: RabbitMqEventBus;
|
|
118
|
+
declare const messagingModule: ContextModule;
|
|
119
|
+
declare const persistenceModule: ContextModule;
|
|
120
|
+
declare const orderingDeclarations: DeclaredSource;
|
|
121
|
+
declare const billingDeclarations: DeclaredSource;
|
|
122
|
+
|
|
123
|
+
const app = createApplication({
|
|
124
|
+
modules: [
|
|
125
|
+
kernelModule(),
|
|
126
|
+
persistenceModule,
|
|
127
|
+
messagingModule,
|
|
128
|
+
contextModule('ordering', orderingDeclarations),
|
|
129
|
+
contextModule('billing', billingDeclarations),
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// La cible du relais est le bus. Un enregistrement est marqué publié quand le broker l'a confirmé.
|
|
134
|
+
const relay = new OutboxRelay({ store: app.resolve(OutboxStore), publisher: bus, clock: app.resolve(Clock) });
|
|
135
|
+
|
|
136
|
+
await app.start(); // abonne le gestionnaire de billing sur bus.forConsumer('billing')
|
|
137
|
+
await bus.assertTopology(); // échouer au démarrage, pas au premier message
|
|
138
|
+
relay.start({ intervalMs: 500 });
|
|
139
|
+
|
|
140
|
+
process.on('SIGTERM', async () => {
|
|
141
|
+
await relay.stop(); // le tour en cours se termine
|
|
142
|
+
await app.stop(); // les gestionnaires sont désabonnés
|
|
143
|
+
await bus.close(); // les consommateurs s'arrêtent, les gestionnaires en cours se terminent et acquittent
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- `persistenceModule` lie `OutboxStore`, et lie à nouveau `UnitOfWork` et `ProcessedEventStore`
|
|
148
|
+
à la base de données : voir le README de l'adaptateur de persistance. Un gestionnaire d'événement
|
|
149
|
+
d'intégration déclaré est abonné sur l'abonné de son propre contexte (`forContext` reçoit le
|
|
150
|
+
nom de son module), et puisque `UnitOfWork` et `ProcessedEventStore` sont liés, sa marque
|
|
151
|
+
d'idempotence et son travail sont validés dans une seule unité de travail : une relivraison est
|
|
152
|
+
dédupliquée, un échec ne laisse rien derrière lui.
|
|
153
|
+
- Sans outbox, le bus est le bus d'événements du module du noyau, et les gestionnaires publient
|
|
154
|
+
directement dans le *broker* :
|
|
155
|
+
`kernelModule({ eventBus: bus, eventSubscribers: () => ({ forContext: (name) => bus.forConsumer(name) }) })`.
|
|
156
|
+
- Sans conteneur, les mêmes objets se câblent à la main : `bus.forConsumer('billing')` est un
|
|
157
|
+
`IntegrationEventSubscriber`, à donner à `subscribeAll` et à `consume` de `@ddd-ts-kit/events`.
|
|
158
|
+
Un bus construit sans l'option `consumer` n'a pas de nom à donner à ses files, si bien que
|
|
159
|
+
`bus.subscribe(...)` lève `ConsumerNameRequiredError` : nommez plutôt le contexte consommateur.
|
|
160
|
+
|
|
161
|
+
<!-- checked sample -->
|
|
162
|
+
```ts
|
|
163
|
+
import { consume, defineIntegrationEvent, subscribeAll } from '@ddd-ts-kit/events';
|
|
164
|
+
import type { ProcessedEventStore, UnitOfWork } from '@ddd-ts-kit/kernel';
|
|
165
|
+
import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
|
|
166
|
+
import { zodSchema } from '@ddd-ts-kit/validation/zod';
|
|
167
|
+
import { z } from 'zod';
|
|
168
|
+
|
|
169
|
+
declare const processed: ProcessedEventStore; // le store de l'adaptateur de persistance
|
|
170
|
+
declare const unitOfWork: UnitOfWork; // la transaction de la base de données
|
|
171
|
+
declare function issueInvoice(orderId: string): Promise<void>;
|
|
172
|
+
|
|
173
|
+
const orderPlaced = defineIntegrationEvent({
|
|
174
|
+
type: 'ordering.order.placed',
|
|
175
|
+
version: 1,
|
|
176
|
+
payload: zodSchema(z.object({ orderId: z.string() })),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const bus = new RabbitMqEventBus({ connection: 'amqp://kit:kit@127.0.0.1:5672' });
|
|
180
|
+
|
|
181
|
+
// Au lieu de : bus.subscribe(orderPlaced.type, handler), qui lève ici ConsumerNameRequiredError.
|
|
182
|
+
// Écrivez : l'abonné d'un contexte consommateur, dont les files portent le nom.
|
|
183
|
+
export const billing = subscribeAll(bus.forConsumer('billing'), [
|
|
184
|
+
consume(orderPlaced, { consumer: 'billing.invoice-on-order-placed', processed, unitOfWork }, async (event) => {
|
|
185
|
+
await issueInvoice(event.payload.orderId);
|
|
186
|
+
}),
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
await bus.assertTopology(); // déclare billing.ordering.order.placed ainsi que ses .retry et .dead, puis consomme
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- `publish(events)` ne se résout que lorsque le *broker* a confirmé **chaque** message du lot, et
|
|
193
|
+
rejette sinon (`PUBLISH_NOT_CONFIRMED`, `BROKER_UNAVAILABLE`, `UNROUTABLE_EVENT`,
|
|
194
|
+
`EVENT_BUS_CLOSED`). Rien n'est mis en mémoire pour plus tard : l'outbox est la mémoire tampon.
|
|
195
|
+
- Un nom de consommateur est requis pour s'abonner : `bus.forConsumer('billing')` donne un
|
|
196
|
+
`IntegrationEventSubscriber` pour un contexte sur la connexion partagée (un monolithe modulaire
|
|
197
|
+
en a plusieurs), et l'option `consumer` nomme celui pour lequel travaille `bus.subscribe` (un
|
|
198
|
+
service qui est un seul contexte).
|
|
199
|
+
- Le traçage est posé autour des deux côtés depuis l'extérieur, comme avec tout fournisseur :
|
|
200
|
+
`tracedPublisher(bus)` comme cible du relais, et côté consommation l'instrumentation du module
|
|
201
|
+
du noyau (`kernelModule({ instrumentation: traceApplication() })`), qui donne à chaque
|
|
202
|
+
gestionnaire déclaré un *span* `process <consumer>` par tentative.
|
|
203
|
+
`tracedSubscriber(bus.forConsumer(name))` sert à une composition sans `kernelModule`. Le bus
|
|
204
|
+
transporte `traceparent` et `correlationId` intacts, dans l'enveloppe et dans les en-têtes du
|
|
205
|
+
message.
|
|
206
|
+
- La corrélation fait l'aller-retour. Publiez par `scopedPublisher(publisher, scope)` du noyau :
|
|
207
|
+
un gestionnaire déclaré (ou `consume(definition, { ..., scope }, handler)` à la main)
|
|
208
|
+
s'exécute dans un contexte d'exécution dont le `correlationId` est celui de l'appel qui a
|
|
209
|
+
publié, dont le `causationId` est l'événement, et une relivraison après un échec s'exécute
|
|
210
|
+
dans le même. Un opérateur le lit sur le message sans en ouvrir le corps, dans la propriété
|
|
211
|
+
`correlation_id` et l'en-tête `x-correlation-id`.
|
|
212
|
+
- Un contexte extrait dans son propre service garde son nom de consommateur et donc ses files :
|
|
213
|
+
rien ne change sur le *broker*.
|
|
214
|
+
|
|
215
|
+
## Topologie
|
|
216
|
+
|
|
217
|
+
`bus.describeTopology()` renvoie les *exchanges*, les files et les liaisons sous forme de données,
|
|
218
|
+
pour une revue ou un document généré ; `bus.assertTopology()` les déclare, de façon idempotente.
|
|
219
|
+
Le bus les déclare aussi à chaque (re)connexion. Pour `forConsumer('billing')` abonné à
|
|
220
|
+
`ordering.order.placed` :
|
|
221
|
+
|
|
222
|
+
| Objet | Nom | Propriétés |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| *exchange* | `integration-events` | topic, durable |
|
|
225
|
+
| file de travail | `billing.ordering.order.placed` | durable, quorum ; liée à l'*exchange* avec la clé de routage `ordering.order.placed` ; renvoie ses rejets (dead letters) vers la file de rejet |
|
|
226
|
+
| file de nouvelles tentatives | `billing.ordering.order.placed.retry` | durable, quorum ; `x-message-ttl` = `retry.delayMs` ; renvoie ses rejets vers la file de travail, au moins une fois |
|
|
227
|
+
| file de rejet | `billing.ordering.order.placed.dead` | durable, quorum ; aucun consommateur |
|
|
228
|
+
|
|
229
|
+
Les nouvelles tentatives et les rejets passent par l'*exchange* par défaut du *broker* (clé de
|
|
230
|
+
routage = nom de la file) : il n'y a donc qu'un *exchange* à gérer, et aucune liaison en dehors
|
|
231
|
+
de celle ci-dessus. Une file de nouvelles tentatives quorum prend aussi
|
|
232
|
+
`x-dead-letter-strategy: at-least-once` et `x-overflow: reject-publish`, pour qu'une nouvelle
|
|
233
|
+
tentative expirée ne se perde pas sur le chemin du retour vers la file de travail.
|
|
234
|
+
|
|
235
|
+
Un message est l'enveloppe en JSON (`id`, `type`, `version`, `occurredAt`, `payload`,
|
|
236
|
+
`traceparent`, `correlationId`, `tenantId`), persistant, avec la clé de routage et la propriété
|
|
237
|
+
`type` réglées sur le type d'événement, `messageId` sur l'identifiant de l'événement,
|
|
238
|
+
`correlationId` sur l'identifiant de corrélation, et les en-têtes `traceparent`,
|
|
239
|
+
`x-correlation-id`, `x-tenant-id` et `x-event-version`. N'importe quel client AMQP peut le
|
|
240
|
+
consommer.
|
|
241
|
+
|
|
242
|
+
Deux consommateurs d'un même type ont chacun une file et reçoivent tous deux chaque événement.
|
|
243
|
+
Deux instances d'un même consommateur partagent sa file : chaque événement va à l'une d'elles.
|
|
244
|
+
|
|
245
|
+
## Comportement des nouvelles tentatives et de la file de rejet
|
|
246
|
+
|
|
247
|
+

|
|
248
|
+
|
|
249
|
+
1. Un message est acquitté après que chaque gestionnaire abonné à sa file s'est résolu. Un
|
|
250
|
+
`@IntegrationEventHandler` déclaré (ou `consume` de `@ddd-ts-kit/events` à la main) valide son
|
|
251
|
+
travail et sa marque d'idempotence dans une seule unité de travail : l'acquittement suit alors
|
|
252
|
+
cette validation, et une relivraison est dédupliquée.
|
|
253
|
+
2. Quand un gestionnaire lève une exception, le bus envoie une copie confirmée du message à la
|
|
254
|
+
file de nouvelles tentatives, avec l'en-tête `x-attempt` incrémenté et `x-error-code`
|
|
255
|
+
renseigné, puis acquitte l'original. La copie y attend `retry.delayMs` et revient dans la file
|
|
256
|
+
de travail. Un message en échec n'est jamais remis en file sur-le-champ : pas de boucle folle.
|
|
257
|
+
3. À la `retry.maxAttempts`-ième livraison en échec, la copie va à la place dans la file de
|
|
258
|
+
rejet, avec `x-dead-reason: max-attempts`.
|
|
259
|
+
4. Un message dont le corps n'est pas une enveloppe (illisible, sans `id`, `type`, `version` ou
|
|
260
|
+
`occurredAt`, ou avec un `tenantId` que `isTenantId` du noyau refuse) est empoisonné (poison) :
|
|
261
|
+
il va directement dans la file de rejet avec `x-dead-reason: poison` et
|
|
262
|
+
`x-error-code: POISON_MESSAGE`, et aucun gestionnaire ne le voit.
|
|
263
|
+
5. Seuls des codes voyagent : `x-error-code` est le `code` de l'erreur ou le nom de sa classe. Le
|
|
264
|
+
message d'une erreur n'est jamais journalisé, remonté ni joint, car il peut citer des données
|
|
265
|
+
du *payload*.
|
|
266
|
+
|
|
267
|
+
La livraison est au moins une fois et sans ordre. L'ordre « copier puis acquitter » signifie
|
|
268
|
+
qu'un plantage entre les deux livre le message deux fois, jamais zéro fois.
|
|
269
|
+
|
|
270
|
+
Sur une connexion perdue, le bus se reconnecte avec un recul exponentiel (`reconnect`, 200 ms
|
|
271
|
+
doublés jusqu'à 30 s), déclare à nouveau sa topologie et reprend ses consommateurs. Les
|
|
272
|
+
publications en cours rejettent, tout comme `publish` entre deux tentatives, si bien que le
|
|
273
|
+
relais de l'outbox garde les enregistrements et les retente. Les messages qui étaient en cours de
|
|
274
|
+
traitement sont relivrés par le *broker*.
|
|
275
|
+
|
|
276
|
+
**Secrets de connexion.** L'URL ou les options de `connection` sont détenues par la fonction qui ouvre la connexion, dans aucun champ. Un `RabbitMqEventBus` journalisé, étalé ou
|
|
277
|
+
sérialisé montre `RabbitMqEventBus [connection concealed]` et rien d'autre : ses champs ne sont pas énumérables,
|
|
278
|
+
et `toJSON` et `util.inspect` répondent ce nom. Les échecs qu'il signale sont des codes. Ce que la
|
|
279
|
+
bibliothèque cliente montre elle-même quand l'un de ses propres objets est inspecté n'est pas
|
|
280
|
+
entre les mains de ce *package* : ne journalisez pas ces objets.
|
|
281
|
+
|
|
282
|
+
**Ce qu'on dit à un gestionnaire.** À côté de l'événement, chaque gestionnaire reçoit la
|
|
283
|
+
livraison (`EventDelivery` du noyau) : `{ attempt, maxAttempts, system: 'rabbitmq', destination, consumer }`,
|
|
284
|
+
où `destination` est la file de travail et `consumer` le nom du consommateur (`billing`). Les gestionnaires de l'application l'ignorent ; `@ddd-ts-kit/tracing` la lit
|
|
285
|
+
pour faire un *span* CONSUMER par tentative et pour marquer les relivraisons et la tentative après
|
|
286
|
+
laquelle l'événement est enterré. Le bus se nomme `messagingSystem: 'rabbitmq'`, et `traceparent` voyage dans
|
|
287
|
+
l'enveloppe et comme véritable en-tête, intact d'une nouvelle tentative à l'autre.
|
|
288
|
+
|
|
289
|
+
## Exploitation
|
|
290
|
+
|
|
291
|
+
Ce qu'il faut surveiller :
|
|
292
|
+
|
|
293
|
+
| Signal | Où | Signification |
|
|
294
|
+
|---|---|---|
|
|
295
|
+
| `stats()` de l'outbox (`pending`, `failed`, `dead`) | le store de l'outbox | le côté publication : voir `@ddd-ts-kit/events` |
|
|
296
|
+
| profondeur de `<consumer>.<type>` | *broker* | le consommateur prend du retard ou est arrêté |
|
|
297
|
+
| profondeur de `<consumer>.<type>.dead` | *broker* | doit être 0 : alertez sur toute autre valeur |
|
|
298
|
+
| profondeur de `<consumer>.<type>.retry` | *broker* | des échecs en cours ; un plateau signale un gestionnaire qui échoue sans cesse |
|
|
299
|
+
| `onDelivery` compté par `outcome` | ce bus | le taux de `retried`, `dead`, `poison` rapporté à `acknowledged` |
|
|
300
|
+
| `onFailure` avec `kind: 'connection'` | ce bus | les reconnexions, et les déclarations que le *broker* a refusées (`AMQP_406`) |
|
|
301
|
+
| `bus.deliveriesInFlight` | ce bus | les gestionnaires en cours d'exécution ; bloqué au-dessus de 0, c'est un gestionnaire qui ne rend pas la main |
|
|
302
|
+
|
|
303
|
+
Avec un outil de suivi des erreurs, `DeadLetterReports` du noyau transforme `onFailure` en
|
|
304
|
+
rapports des événements abandonnés, et de ceux-là seulement :
|
|
305
|
+
`onFailure: (failure) => reports.onFailure(failure)`, et `reports.observing(bus.forConsumer(name))`
|
|
306
|
+
comme abonné de chaque contexte, pour qu'un rapport porte ce que le gestionnaire a levé. `init`
|
|
307
|
+
le câble quand le projet en a un.
|
|
308
|
+
|
|
309
|
+
Rejouer les messages rejetés, une fois la cause corrigée :
|
|
310
|
+
|
|
311
|
+
<!-- checked sample -->
|
|
312
|
+
```ts
|
|
313
|
+
import type { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
|
|
314
|
+
|
|
315
|
+
declare const bus: RabbitMqEventBus;
|
|
316
|
+
|
|
317
|
+
// Jusqu'à 100 messages de billing.ordering.order.placed.dead, renvoyés dans la file de travail avec x-attempt 1.
|
|
318
|
+
const replayed = await bus.replayDead({ consumer: 'billing', type: 'ordering.order.placed' }, 100);
|
|
319
|
+
console.log(`${String(replayed)} message(s) replayed`);
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Il déplace jusqu'à ce nombre de messages de la file de rejet vers la file de travail avec un
|
|
323
|
+
budget de tentatives neuf, chacun avec une copie confirmée avant l'acquittement. L'interface de
|
|
324
|
+
gestion ou une *shovel* peuvent aussi les déplacer ; dans ce cas, remettez à zéro ou supprimez
|
|
325
|
+
l'en-tête `x-attempt`, sinon le message meurt à nouveau à son premier échec. Regardez d'abord
|
|
326
|
+
`x-dead-reason` et `x-error-code` : rejouer un message empoisonné ne fait que le tuer à nouveau.
|
|
327
|
+
|
|
328
|
+
Pour arrêter un consommateur pour de bon, supprimez ses trois files : le désabonnement les laisse
|
|
329
|
+
liées, et la file de travail continue de se remplir.
|
|
330
|
+
|
|
331
|
+
## Limites
|
|
332
|
+
|
|
333
|
+
- **Pas d'ordre.** Les nouvelles tentatives et les instances concurrentes réordonnent. Portez une
|
|
334
|
+
version dans le *payload*.
|
|
335
|
+
- **Un seul délai.** L'attente avant une nouvelle tentative est le TTL de la file de nouvelles
|
|
336
|
+
tentatives : le même pour chaque tentative, sans recul exponentiel.
|
|
337
|
+
- **Les arguments d'une file sont fixés à sa création.** Changer `retry.delayMs` ou `queueType`
|
|
338
|
+
pour une file existante est refusé par le *broker* (signalé comme `AMQP_406` pendant que le bus
|
|
339
|
+
continue d'essayer de se reconnecter) : supprimez la file, ou migrez sous un autre nom de
|
|
340
|
+
consommateur.
|
|
341
|
+
- **Une relivraison exécute à nouveau chaque gestionnaire de la file**, y compris ceux qui ont
|
|
342
|
+
réussi, quand plusieurs gestionnaires d'un même consommateur s'abonnent au même type. Chacun
|
|
343
|
+
doit être idempotent.
|
|
344
|
+
- **Un lot n'est pas atomique.** Quand `publish` rejette, certains événements du lot ont pu être
|
|
345
|
+
pris par le *broker*. Le relais publie un par un et retente, les consommateurs dédupliquent.
|
|
346
|
+
- **`onUnroutable: 'fail'`** rejette un événement pour lequel aucune file n'est liée ; derrière le
|
|
347
|
+
relais de l'outbox, un tel enregistrement est retenté, puis passe à l'état mort (*dead*). Ne
|
|
348
|
+
l'utilisez que lorsqu'un consommateur manquant est un incident.
|
|
349
|
+
- **Un consommateur qui plante en plein gestionnaire** récupère ses messages sans que `x-attempt`
|
|
350
|
+
soit incrémenté. Les files quorum bornent cela par leur limite de livraisons, au-delà de
|
|
351
|
+
laquelle le *broker* envoie le message dans la file de rejet ; les files classiques ne le
|
|
352
|
+
bornent pas. Ce chemin appartient au *broker* et n'est pas exercé par la suite de tests.
|
|
353
|
+
- **Les files classiques** renvoient les nouvelles tentatives expirées au plus une fois : un
|
|
354
|
+
plantage du *broker* à cet instant peut en perdre une. Les files quorum (le défaut) utilisent la
|
|
355
|
+
stratégie au moins une fois.
|
|
356
|
+
- **Une fabrique de connexion doit ouvrir une nouvelle connexion à chaque appel** ; le bus ferme
|
|
357
|
+
ce qu'il a ouvert. Une connexion partagée avec d'autre code ne peut pas être donnée.
|
|
358
|
+
- Les gros lots sont écrits sans attendre que le *socket* se vide : la mémoire grossit avec le
|
|
359
|
+
lot. Le relais publie enregistrement par enregistrement, ce qui l'évite.
|
|
360
|
+
- Les noms de files sont `<consumer>.<type>` plus un suffixe, dans les 255 octets d'AMQP.
|
|
361
|
+
|
|
362
|
+
## Autres brokers
|
|
363
|
+
|
|
364
|
+
[`@ddd-ts-kit/kafka`](../kafka/README.fr.md), [`@ddd-ts-kit/nats`](../nats/README.fr.md) et
|
|
365
|
+
[`@ddd-ts-kit/redis`](../redis/README.fr.md) (Redis Streams) implémentent les deux mêmes ports et
|
|
366
|
+
passent la même suite de contrat. [docs/other-brokers.md](docs/other-brokers.fr.md) dit ce que
|
|
367
|
+
chaque fournisseur doit, fait correspondre les concepts de celui-ci à Kafka et à JetStream comme
|
|
368
|
+
ces *packages* le font, et esquisse SQS/SNS, qui n'est pas construit.
|
|
369
|
+
|
|
370
|
+
## Tests
|
|
371
|
+
|
|
372
|
+
Les suites ont besoin d'un vrai *broker* et sont ignorées sans `DDD_TS_KIT_TEST_AMQP_URL` :
|
|
373
|
+
|
|
374
|
+
```
|
|
375
|
+
docker run -d --rm --name ddd-ts-kit-test-rabbitmq -e RABBITMQ_DEFAULT_USER=kit -e RABBITMQ_DEFAULT_PASS=kit -p 127.0.0.1:5672:5672 rabbitmq:4-management-alpine
|
|
376
|
+
DDD_TS_KIT_TEST_AMQP_URL=amqp://kit:kit@127.0.0.1:5672 npx vitest run packages/rabbitmq
|
|
377
|
+
docker rm -f ddd-ts-kit-test-rabbitmq
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Elles exécutent la suite de contrat sur des files quorum et sur des files classiques, puis les
|
|
381
|
+
comportements de ce fournisseur, une suite par comportement (`publishing`, `consuming`,
|
|
382
|
+
`correlation`, `retries`, `dead-letters`, `topology`, `connection-loss` à travers un relais TCP
|
|
383
|
+
que le test coupe, `closing`), et toute la chaîne de l'outbox jusqu'à un consommateur idempotent.
|
|
384
|
+
Chaque test travaille sur un *exchange* et des files qui lui sont propres, et les supprime.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
**English** · [Français](README.fr.md)
|
|
2
|
+
|
|
1
3
|
# @ddd-ts-kit/rabbitmq
|
|
2
4
|
|
|
3
5
|
The integration event provider for RabbitMQ: an `EventBus` of the kernel that publishes with
|
|
@@ -20,25 +22,33 @@ each consuming context listens through the bus under its own name. The bus is gi
|
|
|
20
22
|
application as two ports of the kernel: `IntegrationEventPublisher` stays the outbox, and
|
|
21
23
|
`EventSubscribers` hands each context the subscriber of its own consumer name.
|
|
22
24
|
|
|
25
|
+

|
|
26
|
+
|
|
23
27
|
`init --broker rabbitmq` writes this wiring in the platform of the project: the bus is built by
|
|
24
28
|
`platform/events/create-event-provider.ts`, `platform/platform.module.ts` rebinds
|
|
25
29
|
`EventSubscribers` on it, and `main.ts` gives it, traced, to the outbox relay. By hand, the
|
|
26
30
|
same thing reads:
|
|
27
31
|
|
|
32
|
+
<!-- checked sample -->
|
|
28
33
|
```ts
|
|
29
34
|
// platform/messaging.module.ts
|
|
30
35
|
import { defineContextModule } from '@ddd-ts-kit/di';
|
|
31
|
-
import { EventSubscribers, IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
|
|
36
|
+
import { EventSubscribers, IntegrationEventPublisher, type Logger, type Metrics } from '@ddd-ts-kit/kernel';
|
|
32
37
|
import { OutboxPublisher, OutboxStore } from '@ddd-ts-kit/events';
|
|
33
38
|
import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
|
|
34
39
|
|
|
40
|
+
declare const config: { readonly amqpUrl: string };
|
|
41
|
+
declare const logger: Logger;
|
|
42
|
+
declare const metrics: Metrics;
|
|
43
|
+
|
|
35
44
|
export const bus = new RabbitMqEventBus({
|
|
36
45
|
connection: config.amqpUrl, // or amqplib options, or a factory opening a new connection
|
|
37
46
|
exchange: 'integration-events', // the default
|
|
38
47
|
retry: { maxAttempts: 5, delayMs: 5_000 }, // the defaults
|
|
39
48
|
prefetch: 10, // the default
|
|
40
49
|
onFailure: (failure) => logger.warn('event bus failure', { ...failure }), // codes only, never an error message
|
|
41
|
-
|
|
50
|
+
// The label `outcome` of the kernel is success or failure: the four outcomes of a delivery take another name.
|
|
51
|
+
onDelivery: ({ queue, outcome }) => metrics.counter('event_deliveries_total').increment({ queue, status: outcome }),
|
|
42
52
|
});
|
|
43
53
|
|
|
44
54
|
export const messagingModule = defineContextModule({
|
|
@@ -52,11 +62,32 @@ export const messagingModule = defineContextModule({
|
|
|
52
62
|
});
|
|
53
63
|
```
|
|
54
64
|
|
|
65
|
+
<!-- checked sample -->
|
|
55
66
|
```ts
|
|
56
|
-
|
|
57
|
-
import { CommandBus, systemActor, type IntegrationEvent } from '@ddd-ts-kit/kernel';
|
|
67
|
+
import { Command, CommandBus, systemActor, type Actor, type IntegrationEvent } from '@ddd-ts-kit/kernel';
|
|
58
68
|
import { IntegrationEventHandler } from '@ddd-ts-kit/kernel/declarations';
|
|
69
|
+
import { defineIntegrationEvent, type PayloadOf } from '@ddd-ts-kit/events';
|
|
70
|
+
import { zodSchema } from '@ddd-ts-kit/validation/zod';
|
|
71
|
+
import { z } from 'zod';
|
|
72
|
+
|
|
73
|
+
// contexts/ordering/published-language: the contract billing listens to.
|
|
74
|
+
export const orderPlaced = defineIntegrationEvent({
|
|
75
|
+
type: 'ordering.order.placed',
|
|
76
|
+
version: 1,
|
|
77
|
+
payload: zodSchema(z.object({ orderId: z.string() })),
|
|
78
|
+
});
|
|
59
79
|
|
|
80
|
+
// contexts/billing/application/commands/issue-invoice/issue-invoice.command.ts
|
|
81
|
+
export class IssueInvoice extends Command {
|
|
82
|
+
constructor(
|
|
83
|
+
readonly actor: Actor,
|
|
84
|
+
readonly orderId: string,
|
|
85
|
+
) {
|
|
86
|
+
super();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// contexts/billing/application/integration/handlers/invoice-on-order-placed.integration-handler.ts
|
|
60
91
|
@IntegrationEventHandler(orderPlaced, { consumer: 'billing.invoice-on-order-placed' })
|
|
61
92
|
export class InvoiceOnOrderPlacedIntegrationHandler {
|
|
62
93
|
constructor(private readonly commands: CommandBus) {}
|
|
@@ -64,18 +95,26 @@ export class InvoiceOnOrderPlacedIntegrationHandler {
|
|
|
64
95
|
// Nobody is calling: the handler acts as a system actor named after its consumer.
|
|
65
96
|
readonly actor = systemActor('billing.invoice-on-order-placed');
|
|
66
97
|
|
|
67
|
-
async handle(event: IntegrationEvent<
|
|
98
|
+
async handle(event: IntegrationEvent<PayloadOf<typeof orderPlaced>>): Promise<void> {
|
|
68
99
|
await this.commands.execute(new IssueInvoice(this.actor, event.payload.orderId));
|
|
69
100
|
}
|
|
70
101
|
}
|
|
71
102
|
```
|
|
72
103
|
|
|
104
|
+
<!-- checked sample -->
|
|
73
105
|
```ts
|
|
74
106
|
// main.ts
|
|
75
|
-
import { contextModule, createApplication, kernelModule } from '@ddd-ts-kit/di';
|
|
107
|
+
import { contextModule, createApplication, kernelModule, type ContextModule, type DeclaredSource } from '@ddd-ts-kit/di';
|
|
76
108
|
import { Clock } from '@ddd-ts-kit/kernel';
|
|
77
109
|
import { OutboxRelay, OutboxStore } from '@ddd-ts-kit/events';
|
|
78
|
-
import {
|
|
110
|
+
import type { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
|
|
111
|
+
|
|
112
|
+
// In a project: import { bus, messagingModule } from '@/platform/messaging.module';
|
|
113
|
+
declare const bus: RabbitMqEventBus;
|
|
114
|
+
declare const messagingModule: ContextModule;
|
|
115
|
+
declare const persistenceModule: ContextModule;
|
|
116
|
+
declare const orderingDeclarations: DeclaredSource;
|
|
117
|
+
declare const billingDeclarations: DeclaredSource;
|
|
79
118
|
|
|
80
119
|
const app = createApplication({
|
|
81
120
|
modules: [
|
|
@@ -112,6 +151,40 @@ process.on('SIGTERM', async () => {
|
|
|
112
151
|
`kernelModule({ eventBus: bus, eventSubscribers: () => ({ forContext: (name) => bus.forConsumer(name) }) })`.
|
|
113
152
|
- Without a container the same objects are wired by hand: `bus.forConsumer('billing')` is an
|
|
114
153
|
`IntegrationEventSubscriber`, to give to `subscribeAll` and `consume` of `@ddd-ts-kit/events`.
|
|
154
|
+
A bus built without the `consumer` option has no name to give its queues, so
|
|
155
|
+
`bus.subscribe(...)` throws `ConsumerNameRequiredError`: name the consuming context instead.
|
|
156
|
+
|
|
157
|
+
<!-- checked sample -->
|
|
158
|
+
```ts
|
|
159
|
+
import { consume, defineIntegrationEvent, subscribeAll } from '@ddd-ts-kit/events';
|
|
160
|
+
import type { ProcessedEventStore, UnitOfWork } from '@ddd-ts-kit/kernel';
|
|
161
|
+
import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
|
|
162
|
+
import { zodSchema } from '@ddd-ts-kit/validation/zod';
|
|
163
|
+
import { z } from 'zod';
|
|
164
|
+
|
|
165
|
+
declare const processed: ProcessedEventStore; // the store of the persistence adapter
|
|
166
|
+
declare const unitOfWork: UnitOfWork; // the transaction of the database
|
|
167
|
+
declare function issueInvoice(orderId: string): Promise<void>;
|
|
168
|
+
|
|
169
|
+
const orderPlaced = defineIntegrationEvent({
|
|
170
|
+
type: 'ordering.order.placed',
|
|
171
|
+
version: 1,
|
|
172
|
+
payload: zodSchema(z.object({ orderId: z.string() })),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const bus = new RabbitMqEventBus({ connection: 'amqp://kit:kit@127.0.0.1:5672' });
|
|
176
|
+
|
|
177
|
+
// Instead of: bus.subscribe(orderPlaced.type, handler), which throws ConsumerNameRequiredError here.
|
|
178
|
+
// Write: the subscriber of one consuming context, whose name its queues carry.
|
|
179
|
+
export const billing = subscribeAll(bus.forConsumer('billing'), [
|
|
180
|
+
consume(orderPlaced, { consumer: 'billing.invoice-on-order-placed', processed, unitOfWork }, async (event) => {
|
|
181
|
+
await issueInvoice(event.payload.orderId);
|
|
182
|
+
}),
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
await bus.assertTopology(); // declares billing.ordering.order.placed and its .retry and .dead, then consumes
|
|
186
|
+
```
|
|
187
|
+
|
|
115
188
|
- `publish(events)` resolves only when the broker confirmed **every** message of the batch and
|
|
116
189
|
rejects otherwise (`PUBLISH_NOT_CONFIRMED`, `BROKER_UNAVAILABLE`, `UNROUTABLE_EVENT`,
|
|
117
190
|
`EVENT_BUS_CLOSED`). Nothing is buffered for later: the outbox is the buffer.
|
|
@@ -149,7 +222,9 @@ them on every (re)connection. For `forConsumer('billing')` subscribed to
|
|
|
149
222
|
| dead queue | `billing.ordering.order.placed.dead` | durable, quorum; no consumer |
|
|
150
223
|
|
|
151
224
|
Retries and dead letters travel through the default exchange of the broker (routing key =
|
|
152
|
-
queue name), so there is one exchange to manage and no binding besides the one above.
|
|
225
|
+
queue name), so there is one exchange to manage and no binding besides the one above. A quorum
|
|
226
|
+
retry queue also takes `x-dead-letter-strategy: at-least-once` and `x-overflow: reject-publish`,
|
|
227
|
+
so that an expired retry is not lost on its way back to the work queue.
|
|
153
228
|
|
|
154
229
|
A message is the envelope as JSON (`id`, `type`, `version`, `occurredAt`, `payload`,
|
|
155
230
|
`traceparent`, `correlationId`, `tenantId`), persistent, with the routing key and the `type`
|
|
@@ -162,6 +237,8 @@ one consumer share its queue: each event goes to one of them.
|
|
|
162
237
|
|
|
163
238
|
## Retry and dead-letter behaviour
|
|
164
239
|
|
|
240
|
+

|
|
241
|
+
|
|
165
242
|
1. A message is acknowledged after every handler subscribed to its queue resolved. A declared
|
|
166
243
|
`@IntegrationEventHandler` (or `consume` of `@ddd-ts-kit/events` by hand) commits its work
|
|
167
244
|
and its idempotency mark in one unit of work: the acknowledgement then follows that commit,
|
|
@@ -172,9 +249,10 @@ one consumer share its queue: each event goes to one of them.
|
|
|
172
249
|
message is never requeued on the spot: no hot loop.
|
|
173
250
|
3. At the `retry.maxAttempts`-th failed delivery the copy goes to the dead queue instead, with
|
|
174
251
|
`x-dead-reason: max-attempts`.
|
|
175
|
-
4. A message whose body is not an envelope (unparseable,
|
|
176
|
-
`occurredAt
|
|
177
|
-
`x-dead-reason: poison` and
|
|
252
|
+
4. A message whose body is not an envelope (unparseable, without `id`, `type`, `version` or
|
|
253
|
+
`occurredAt`, or with a `tenantId` that `isTenantId` of the kernel refuses) is poison: it
|
|
254
|
+
goes straight to the dead queue with `x-dead-reason: poison` and
|
|
255
|
+
`x-error-code: POISON_MESSAGE`, and no handler sees it.
|
|
178
256
|
5. Only codes travel: `x-error-code` is the `code` of the error or its class name. The message
|
|
179
257
|
of an error is never logged, reported or attached, because it may quote payload data.
|
|
180
258
|
|
|
@@ -194,7 +272,7 @@ this package: do not log those.
|
|
|
194
272
|
|
|
195
273
|
**What a handler is told.** Next to the event, each handler receives the delivery
|
|
196
274
|
(`EventDelivery` of the kernel): `{ attempt, maxAttempts, system: 'rabbitmq', destination, consumer }`,
|
|
197
|
-
where `destination` is the work queue. Handlers of the application ignore it; `@ddd-ts-kit/tracing` reads it
|
|
275
|
+
where `destination` is the work queue and `consumer` the consumer name (`billing`). Handlers of the application ignore it; `@ddd-ts-kit/tracing` reads it
|
|
198
276
|
to make one CONSUMER span per attempt and to mark the redeliveries and the attempt after which
|
|
199
277
|
the event is buried. The bus names itself `messagingSystem: 'rabbitmq'`, and `traceparent` travels in
|
|
200
278
|
the envelope and as a real header, intact across a retry.
|
|
@@ -213,10 +291,22 @@ What to watch:
|
|
|
213
291
|
| `onFailure` with `kind: 'connection'` | this bus | reconnections, and declarations the broker refused (`AMQP_406`) |
|
|
214
292
|
| `bus.deliveriesInFlight` | this bus | handlers running now; stuck above 0 means a handler that hangs |
|
|
215
293
|
|
|
294
|
+
With an error tracker, `DeadLetterReports` of the kernel turns `onFailure` into reports of the
|
|
295
|
+
events given up, and of those only: `onFailure: (failure) => reports.onFailure(failure)`, and
|
|
296
|
+
`reports.observing(bus.forConsumer(name))` as the subscriber of each context, so that a report
|
|
297
|
+
carries what the handler threw. `init` wires it when the project has one.
|
|
298
|
+
|
|
216
299
|
Replaying dead messages, once the cause is fixed:
|
|
217
300
|
|
|
301
|
+
<!-- checked sample -->
|
|
218
302
|
```ts
|
|
303
|
+
import type { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';
|
|
304
|
+
|
|
305
|
+
declare const bus: RabbitMqEventBus;
|
|
306
|
+
|
|
307
|
+
// Up to 100 messages of billing.ordering.order.placed.dead, back to the work queue with x-attempt 1.
|
|
219
308
|
const replayed = await bus.replayDead({ consumer: 'billing', type: 'ordering.order.placed' }, 100);
|
|
309
|
+
console.log(`${String(replayed)} message(s) replayed`);
|
|
220
310
|
```
|
|
221
311
|
|
|
222
312
|
It moves up to that many messages from the dead queue back to the work queue with a fresh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ddd-ts-kit/rabbitmq",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "RabbitMQ provider for integration events: confirmed publishing for the outbox relay, durable queue per consumer, manual acknowledgement, bounded retries and dead-lettering.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
"dist"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@ddd-ts-kit/kernel": "0.16.
|
|
30
|
-
"@ddd-ts-kit/events": "0.16.
|
|
29
|
+
"@ddd-ts-kit/kernel": "0.16.1",
|
|
30
|
+
"@ddd-ts-kit/events": "0.16.1"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"amqplib": ">=0.10.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"amqplib": "latest",
|
|
37
|
-
"@ddd-ts-kit/testing": "0.16.
|
|
37
|
+
"@ddd-ts-kit/testing": "0.16.1"
|
|
38
38
|
}
|
|
39
39
|
}
|