@zdavison/matador 2.0.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/cli.ts +453 -0
- package/dist/checkpoint/context.d.ts +59 -0
- package/dist/checkpoint/context.d.ts.map +1 -0
- package/dist/checkpoint/context.js +140 -0
- package/dist/checkpoint/context.test.d.ts +2 -0
- package/dist/checkpoint/context.test.d.ts.map +1 -0
- package/dist/checkpoint/context.test.js +424 -0
- package/dist/checkpoint/index.d.ts +7 -0
- package/dist/checkpoint/index.d.ts.map +1 -0
- package/dist/checkpoint/index.js +6 -0
- package/dist/checkpoint/stores/memory.d.ts +29 -0
- package/dist/checkpoint/stores/memory.d.ts.map +1 -0
- package/dist/checkpoint/stores/memory.js +39 -0
- package/dist/checkpoint/stores/noop.d.ts +14 -0
- package/dist/checkpoint/stores/noop.d.ts.map +1 -0
- package/dist/checkpoint/stores/noop.js +18 -0
- package/dist/checkpoint/stores/stores.test.d.ts +2 -0
- package/dist/checkpoint/stores/stores.test.d.ts.map +1 -0
- package/dist/checkpoint/stores/stores.test.js +146 -0
- package/dist/checkpoint/types.d.ts +119 -0
- package/dist/checkpoint/types.d.ts.map +1 -0
- package/dist/checkpoint/types.js +1 -0
- package/dist/codec/codec.d.ts +29 -0
- package/dist/codec/codec.d.ts.map +1 -0
- package/dist/codec/codec.js +15 -0
- package/dist/codec/header-aware-codec.d.ts +36 -0
- package/dist/codec/header-aware-codec.d.ts.map +1 -0
- package/dist/codec/header-aware-codec.js +1 -0
- package/dist/codec/index.d.ts +6 -0
- package/dist/codec/index.d.ts.map +1 -0
- package/dist/codec/index.js +3 -0
- package/dist/codec/json-codec.d.ts +13 -0
- package/dist/codec/json-codec.d.ts.map +1 -0
- package/dist/codec/json-codec.js +64 -0
- package/dist/codec/rabbitmq-codec.d.ts +28 -0
- package/dist/codec/rabbitmq-codec.d.ts.map +1 -0
- package/dist/codec/rabbitmq-codec.js +242 -0
- package/dist/codec/rabbitmq-codec.test.d.ts +2 -0
- package/dist/codec/rabbitmq-codec.test.d.ts.map +1 -0
- package/dist/codec/rabbitmq-codec.test.js +433 -0
- package/dist/core/fanout.d.ts +59 -0
- package/dist/core/fanout.d.ts.map +1 -0
- package/dist/core/fanout.js +121 -0
- package/dist/core/fanout.test.d.ts +2 -0
- package/dist/core/fanout.test.d.ts.map +1 -0
- package/dist/core/fanout.test.js +1055 -0
- package/dist/core/index.d.ts +7 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +3 -0
- package/dist/core/matador.d.ts +133 -0
- package/dist/core/matador.d.ts.map +1 -0
- package/dist/core/matador.js +222 -0
- package/dist/core/matador.test.d.ts +2 -0
- package/dist/core/matador.test.d.ts.map +1 -0
- package/dist/core/matador.test.js +446 -0
- package/dist/core/shutdown.d.ts +78 -0
- package/dist/core/shutdown.d.ts.map +1 -0
- package/dist/core/shutdown.js +111 -0
- package/dist/core/shutdown.test.d.ts +2 -0
- package/dist/core/shutdown.test.d.ts.map +1 -0
- package/dist/core/shutdown.test.js +524 -0
- package/dist/errors/checkpoint-errors.d.ts +30 -0
- package/dist/errors/checkpoint-errors.d.ts.map +1 -0
- package/dist/errors/checkpoint-errors.js +49 -0
- package/dist/errors/has-description.d.ts +18 -0
- package/dist/errors/has-description.d.ts.map +1 -0
- package/dist/errors/has-description.js +9 -0
- package/dist/errors/index.d.ts +6 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +22 -0
- package/dist/errors/matador-errors.d.ts +183 -0
- package/dist/errors/matador-errors.d.ts.map +1 -0
- package/dist/errors/matador-errors.js +376 -0
- package/dist/errors/retry-errors.d.ts +67 -0
- package/dist/errors/retry-errors.d.ts.map +1 -0
- package/dist/errors/retry-errors.js +108 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/safe-hooks.d.ts +34 -0
- package/dist/hooks/safe-hooks.d.ts.map +1 -0
- package/dist/hooks/safe-hooks.js +135 -0
- package/dist/hooks/types.d.ts +189 -0
- package/dist/hooks/types.d.ts.map +1 -0
- package/dist/hooks/types.js +9 -0
- package/dist/index.cjs +3156 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/pipeline/index.d.ts +3 -0
- package/dist/pipeline/index.d.ts.map +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/pipeline.d.ts +60 -0
- package/dist/pipeline/pipeline.d.ts.map +1 -0
- package/dist/pipeline/pipeline.js +261 -0
- package/dist/pipeline/pipeline.test.d.ts +2 -0
- package/dist/pipeline/pipeline.test.d.ts.map +1 -0
- package/dist/pipeline/pipeline.test.js +1065 -0
- package/dist/retry/index.d.ts +4 -0
- package/dist/retry/index.d.ts.map +1 -0
- package/dist/retry/index.js +1 -0
- package/dist/retry/policy.d.ts +43 -0
- package/dist/retry/policy.d.ts.map +1 -0
- package/dist/retry/policy.js +1 -0
- package/dist/retry/standard-policy.d.ts +44 -0
- package/dist/retry/standard-policy.d.ts.map +1 -0
- package/dist/retry/standard-policy.js +102 -0
- package/dist/retry/standard-policy.test.d.ts +2 -0
- package/dist/retry/standard-policy.test.d.ts.map +1 -0
- package/dist/retry/standard-policy.test.js +190 -0
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +2 -0
- package/dist/schema/registry.d.ts +63 -0
- package/dist/schema/registry.d.ts.map +1 -0
- package/dist/schema/registry.js +171 -0
- package/dist/schema/registry.test.d.ts +2 -0
- package/dist/schema/registry.test.d.ts.map +1 -0
- package/dist/schema/registry.test.js +278 -0
- package/dist/schema/types.d.ts +158 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/types.js +74 -0
- package/dist/schema/types.test.d.ts +2 -0
- package/dist/schema/types.test.d.ts.map +1 -0
- package/dist/schema/types.test.js +243 -0
- package/dist/topology/builder.d.ts +66 -0
- package/dist/topology/builder.d.ts.map +1 -0
- package/dist/topology/builder.js +156 -0
- package/dist/topology/builder.test.d.ts +2 -0
- package/dist/topology/builder.test.d.ts.map +1 -0
- package/dist/topology/builder.test.js +222 -0
- package/dist/topology/index.d.ts +5 -0
- package/dist/topology/index.d.ts.map +1 -0
- package/dist/topology/index.js +2 -0
- package/dist/topology/types.d.ts +76 -0
- package/dist/topology/types.d.ts.map +1 -0
- package/dist/topology/types.js +18 -0
- package/dist/transport/capabilities.d.ts +66 -0
- package/dist/transport/capabilities.d.ts.map +1 -0
- package/dist/transport/capabilities.js +18 -0
- package/dist/transport/connection-manager.d.ts +95 -0
- package/dist/transport/connection-manager.d.ts.map +1 -0
- package/dist/transport/connection-manager.js +144 -0
- package/dist/transport/index.d.ts +11 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +5 -0
- package/dist/transport/local/local-transport.d.ts +62 -0
- package/dist/transport/local/local-transport.d.ts.map +1 -0
- package/dist/transport/local/local-transport.js +241 -0
- package/dist/transport/local/local-transport.test.d.ts +2 -0
- package/dist/transport/local/local-transport.test.d.ts.map +1 -0
- package/dist/transport/local/local-transport.test.js +192 -0
- package/dist/transport/multi/multi-transport.d.ts +94 -0
- package/dist/transport/multi/multi-transport.d.ts.map +1 -0
- package/dist/transport/multi/multi-transport.js +184 -0
- package/dist/transport/multi/multi-transport.test.d.ts +2 -0
- package/dist/transport/multi/multi-transport.test.d.ts.map +1 -0
- package/dist/transport/multi/multi-transport.test.js +236 -0
- package/dist/transport/rabbitmq/rabbitmq-transport.d.ts +73 -0
- package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -0
- package/dist/transport/rabbitmq/rabbitmq-transport.js +518 -0
- package/dist/transport/transport.d.ts +152 -0
- package/dist/transport/transport.d.ts.map +1 -0
- package/dist/transport/transport.js +1 -0
- package/dist/types/common.d.ts +41 -0
- package/dist/types/common.d.ts.map +1 -0
- package/dist/types/common.js +12 -0
- package/dist/types/envelope.d.ts +82 -0
- package/dist/types/envelope.d.ts.map +1 -0
- package/dist/types/envelope.js +36 -0
- package/dist/types/event.d.ts +96 -0
- package/dist/types/event.d.ts.map +1 -0
- package/dist/types/event.js +26 -0
- package/dist/types/event.test.d.ts +2 -0
- package/dist/types/event.test.d.ts.map +1 -0
- package/dist/types/event.test.js +130 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +4 -0
- package/dist/types/subscriber.d.ts +207 -0
- package/dist/types/subscriber.d.ts.map +1 -0
- package/dist/types/subscriber.js +99 -0
- package/examples/config.ts +126 -0
- package/examples/event.ts +26 -0
- package/examples/order-event.json +19 -0
- package/package.json +66 -0
- package/src/checkpoint/context.test.ts +510 -0
- package/src/checkpoint/context.ts +213 -0
- package/src/checkpoint/index.ts +30 -0
- package/src/checkpoint/stores/memory.ts +47 -0
- package/src/checkpoint/stores/noop.ts +22 -0
- package/src/checkpoint/stores/stores.test.ts +177 -0
- package/src/checkpoint/types.ts +147 -0
- package/src/codec/codec.ts +42 -0
- package/src/codec/header-aware-codec.ts +41 -0
- package/src/codec/index.ts +11 -0
- package/src/codec/json-codec.ts +69 -0
- package/src/codec/rabbitmq-codec.test.ts +516 -0
- package/src/codec/rabbitmq-codec.ts +336 -0
- package/src/core/fanout.test.ts +1351 -0
- package/src/core/fanout.ts +184 -0
- package/src/core/index.ts +12 -0
- package/src/core/matador.test.ts +575 -0
- package/src/core/matador.ts +344 -0
- package/src/core/shutdown.test.ts +853 -0
- package/src/core/shutdown.ts +165 -0
- package/src/errors/checkpoint-errors.ts +62 -0
- package/src/errors/has-description.ts +25 -0
- package/src/errors/index.ts +57 -0
- package/src/errors/matador-errors.ts +477 -0
- package/src/errors/retry-errors.ts +134 -0
- package/src/hooks/index.ts +15 -0
- package/src/hooks/safe-hooks.ts +223 -0
- package/src/hooks/types.ts +248 -0
- package/src/index.ts +231 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/pipeline.test.ts +1317 -0
- package/src/pipeline/pipeline.ts +371 -0
- package/src/retry/index.ts +4 -0
- package/src/retry/policy.ts +46 -0
- package/src/retry/standard-policy.test.ts +282 -0
- package/src/retry/standard-policy.ts +156 -0
- package/src/schema/index.ts +16 -0
- package/src/schema/registry.test.ts +339 -0
- package/src/schema/registry.ts +226 -0
- package/src/schema/types.test.ts +281 -0
- package/src/schema/types.ts +217 -0
- package/src/topology/builder.test.ts +275 -0
- package/src/topology/builder.ts +199 -0
- package/src/topology/index.ts +15 -0
- package/src/topology/types.ts +109 -0
- package/src/transport/capabilities.ts +88 -0
- package/src/transport/connection-manager.ts +218 -0
- package/src/transport/index.ts +42 -0
- package/src/transport/local/local-transport.test.ts +262 -0
- package/src/transport/local/local-transport.ts +327 -0
- package/src/transport/multi/multi-transport.test.ts +320 -0
- package/src/transport/multi/multi-transport.ts +294 -0
- package/src/transport/rabbitmq/rabbitmq-transport.ts +753 -0
- package/src/transport/transport.ts +200 -0
- package/src/types/common.ts +53 -0
- package/src/types/envelope.ts +152 -0
- package/src/types/event.test.ts +157 -0
- package/src/types/event.ts +118 -0
- package/src/types/index.ts +52 -0
- package/src/types/subscriber.ts +310 -0
- package/test/e2e/multi-transport.e2e.test.ts +236 -0
- package/test/e2e/rabbitmq-transport.e2e.test.ts +327 -0
- package/test/e2e/transport-compliance.e2e.test.ts +505 -0
- package/test/integration/matador.integration.test.ts +634 -0
- package/tsconfig.json +29 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsup.config.ts +13 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import type { TransportFallbackContext } from '../../hooks/index.js';
|
|
3
|
+
import type { Topology } from '../../topology/types.js';
|
|
4
|
+
import { createEnvelope } from '../../types/index.js';
|
|
5
|
+
import { LocalTransport } from '../local/local-transport.js';
|
|
6
|
+
import { MultiTransport } from './multi-transport.js';
|
|
7
|
+
|
|
8
|
+
describe('MultiTransport', () => {
|
|
9
|
+
let primary: LocalTransport;
|
|
10
|
+
let secondary: LocalTransport;
|
|
11
|
+
let transport: MultiTransport;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
primary = new LocalTransport();
|
|
15
|
+
secondary = new LocalTransport();
|
|
16
|
+
transport = new MultiTransport({
|
|
17
|
+
transports: [primary, secondary],
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('constructor', () => {
|
|
22
|
+
it('should throw if no transports provided', () => {
|
|
23
|
+
expect(() => new MultiTransport({ transports: [] })).toThrow(
|
|
24
|
+
'At least one transport is required',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should set name based on transport names', () => {
|
|
29
|
+
expect(transport.name).toBe('multi(local,local)');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should use primary transport capabilities', () => {
|
|
33
|
+
expect(transport.capabilities).toBe(primary.capabilities);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should expose primary transport', () => {
|
|
37
|
+
expect(transport.primary).toBe(primary);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should expose all transports', () => {
|
|
41
|
+
expect(transport.transports).toEqual([primary, secondary]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should default fallbackEnabled to true', () => {
|
|
45
|
+
expect(transport.fallbackEnabled).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should allow disabling fallback', () => {
|
|
49
|
+
const noFallback = new MultiTransport({
|
|
50
|
+
transports: [primary, secondary],
|
|
51
|
+
fallbackEnabled: false,
|
|
52
|
+
});
|
|
53
|
+
expect(noFallback.fallbackEnabled).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('connection', () => {
|
|
58
|
+
it('should start disconnected', () => {
|
|
59
|
+
expect(transport.isConnected()).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should connect all transports', async () => {
|
|
63
|
+
await transport.connect();
|
|
64
|
+
|
|
65
|
+
expect(transport.isConnected()).toBe(true);
|
|
66
|
+
expect(primary.isConnected()).toBe(true);
|
|
67
|
+
expect(secondary.isConnected()).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should disconnect all transports', async () => {
|
|
71
|
+
await transport.connect();
|
|
72
|
+
await transport.disconnect();
|
|
73
|
+
|
|
74
|
+
expect(transport.isConnected()).toBe(false);
|
|
75
|
+
expect(primary.isConnected()).toBe(false);
|
|
76
|
+
expect(secondary.isConnected()).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('topology', () => {
|
|
81
|
+
it('should apply topology to all transports', async () => {
|
|
82
|
+
const topology: Topology = {
|
|
83
|
+
namespace: 'test',
|
|
84
|
+
queues: [{ name: 'events' }],
|
|
85
|
+
deadLetter: {
|
|
86
|
+
unhandled: { enabled: true },
|
|
87
|
+
undeliverable: { enabled: true },
|
|
88
|
+
},
|
|
89
|
+
retry: {
|
|
90
|
+
enabled: true,
|
|
91
|
+
defaultDelayMs: 1000,
|
|
92
|
+
maxDelayMs: 30000,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
await transport.connect();
|
|
97
|
+
await transport.applyTopology(topology);
|
|
98
|
+
|
|
99
|
+
// Both transports should have the queue
|
|
100
|
+
expect(primary.getQueueSize('test.events')).toBe(0);
|
|
101
|
+
expect(secondary.getQueueSize('test.events')).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('send - primary success', () => {
|
|
106
|
+
beforeEach(async () => {
|
|
107
|
+
await transport.connect();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should send to primary transport when it succeeds', async () => {
|
|
111
|
+
const envelope = createTestEnvelope();
|
|
112
|
+
await transport.send('test-queue', envelope);
|
|
113
|
+
|
|
114
|
+
expect(primary.getQueueSize('test-queue')).toBe(1);
|
|
115
|
+
expect(secondary.getQueueSize('test-queue')).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should not call onEnqueueFallback when primary succeeds', async () => {
|
|
119
|
+
const onEnqueueFallback = mock(() => {});
|
|
120
|
+
const transportWithCallback = new MultiTransport(
|
|
121
|
+
{ transports: [primary, secondary] },
|
|
122
|
+
{ onEnqueueFallback },
|
|
123
|
+
);
|
|
124
|
+
await transportWithCallback.connect();
|
|
125
|
+
|
|
126
|
+
const envelope = createTestEnvelope();
|
|
127
|
+
await transportWithCallback.send('test-queue', envelope);
|
|
128
|
+
|
|
129
|
+
expect(onEnqueueFallback).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('send - fallback', () => {
|
|
134
|
+
beforeEach(async () => {
|
|
135
|
+
await transport.connect();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should fallback when primary fails', async () => {
|
|
139
|
+
// Make primary fail
|
|
140
|
+
await primary.disconnect();
|
|
141
|
+
|
|
142
|
+
const envelope = createTestEnvelope();
|
|
143
|
+
await transport.send('test-queue', envelope);
|
|
144
|
+
|
|
145
|
+
expect(secondary.getQueueSize('test-queue')).toBe(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should call onEnqueueFallback when fallback is used', async () => {
|
|
149
|
+
const fallbackContexts: TransportFallbackContext[] = [];
|
|
150
|
+
const transportWithCallback = new MultiTransport(
|
|
151
|
+
{ transports: [primary, secondary] },
|
|
152
|
+
{ onEnqueueFallback: (ctx) => fallbackContexts.push(ctx) },
|
|
153
|
+
);
|
|
154
|
+
await transportWithCallback.connect();
|
|
155
|
+
|
|
156
|
+
// Make primary fail
|
|
157
|
+
await primary.disconnect();
|
|
158
|
+
|
|
159
|
+
const envelope = createTestEnvelope();
|
|
160
|
+
await transportWithCallback.send('test-queue', envelope);
|
|
161
|
+
|
|
162
|
+
expect(fallbackContexts).toHaveLength(1);
|
|
163
|
+
const ctx = fallbackContexts[0]!;
|
|
164
|
+
expect(ctx.failedTransport).toBe('local');
|
|
165
|
+
expect(ctx.nextTransport).toBe('local');
|
|
166
|
+
expect(ctx.queue).toBe('test-queue');
|
|
167
|
+
expect(ctx.envelope).toBe(envelope);
|
|
168
|
+
expect(ctx.error.message).toContain('is not connected');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should throw when all transports fail', async () => {
|
|
172
|
+
await primary.disconnect();
|
|
173
|
+
await secondary.disconnect();
|
|
174
|
+
|
|
175
|
+
const envelope = createTestEnvelope();
|
|
176
|
+
await expect(transport.send('test-queue', envelope)).rejects.toThrow(
|
|
177
|
+
'All transports failed',
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should try transports in order', async () => {
|
|
182
|
+
const third = new LocalTransport();
|
|
183
|
+
const multiTransport = new MultiTransport({
|
|
184
|
+
transports: [primary, secondary, third],
|
|
185
|
+
});
|
|
186
|
+
await multiTransport.connect();
|
|
187
|
+
|
|
188
|
+
// Make primary and secondary fail
|
|
189
|
+
await primary.disconnect();
|
|
190
|
+
await secondary.disconnect();
|
|
191
|
+
|
|
192
|
+
const envelope = createTestEnvelope();
|
|
193
|
+
await multiTransport.send('test-queue', envelope);
|
|
194
|
+
|
|
195
|
+
expect(third.getQueueSize('test-queue')).toBe(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should not fallback when fallbackEnabled is false', async () => {
|
|
199
|
+
const noFallback = new MultiTransport({
|
|
200
|
+
transports: [primary, secondary],
|
|
201
|
+
fallbackEnabled: false,
|
|
202
|
+
});
|
|
203
|
+
await noFallback.connect();
|
|
204
|
+
|
|
205
|
+
// Make primary fail
|
|
206
|
+
await primary.disconnect();
|
|
207
|
+
|
|
208
|
+
const envelope = createTestEnvelope();
|
|
209
|
+
await expect(noFallback.send('test-queue', envelope)).rejects.toThrow(
|
|
210
|
+
'is not connected',
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Secondary should not have received the message
|
|
214
|
+
expect(secondary.getQueueSize('test-queue')).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should send to primary when fallbackEnabled is false and primary succeeds', async () => {
|
|
218
|
+
const noFallback = new MultiTransport({
|
|
219
|
+
transports: [primary, secondary],
|
|
220
|
+
fallbackEnabled: false,
|
|
221
|
+
});
|
|
222
|
+
await noFallback.connect();
|
|
223
|
+
|
|
224
|
+
const envelope = createTestEnvelope();
|
|
225
|
+
await noFallback.send('test-queue', envelope);
|
|
226
|
+
|
|
227
|
+
expect(primary.getQueueSize('test-queue')).toBe(1);
|
|
228
|
+
expect(secondary.getQueueSize('test-queue')).toBe(0);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('subscribe', () => {
|
|
233
|
+
beforeEach(async () => {
|
|
234
|
+
await transport.connect();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should receive messages from primary transport', async () => {
|
|
238
|
+
const receivedMessages: unknown[] = [];
|
|
239
|
+
|
|
240
|
+
await transport.subscribe('test-queue', async (envelope) => {
|
|
241
|
+
receivedMessages.push(envelope);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Send directly to primary (simulating normal operation)
|
|
245
|
+
await primary.send('test-queue', createTestEnvelope());
|
|
246
|
+
|
|
247
|
+
expect(receivedMessages).toHaveLength(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should receive messages that fell back to secondary transport', async () => {
|
|
251
|
+
const receivedMessages: unknown[] = [];
|
|
252
|
+
|
|
253
|
+
// Subscribe through MultiTransport
|
|
254
|
+
await transport.subscribe('test-queue', async (envelope) => {
|
|
255
|
+
receivedMessages.push(envelope);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Make primary fail for sends
|
|
259
|
+
await primary.disconnect();
|
|
260
|
+
|
|
261
|
+
// Send through MultiTransport - should fall back to secondary
|
|
262
|
+
const envelope = createTestEnvelope();
|
|
263
|
+
await transport.send('test-queue', envelope);
|
|
264
|
+
|
|
265
|
+
// Message went to secondary transport, subscriber should still receive it
|
|
266
|
+
expect(receivedMessages).toHaveLength(1);
|
|
267
|
+
expect((receivedMessages[0] as { id: string }).id).toBe(envelope.id);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('complete', () => {
|
|
272
|
+
beforeEach(async () => {
|
|
273
|
+
await transport.connect();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should complete using primary transport', async () => {
|
|
277
|
+
await primary.send('test-queue', createTestEnvelope());
|
|
278
|
+
|
|
279
|
+
const received = await primary.receiveOne('test-queue');
|
|
280
|
+
expect(received).not.toBeNull();
|
|
281
|
+
|
|
282
|
+
await transport.complete(received!.receipt);
|
|
283
|
+
|
|
284
|
+
expect(primary.getCompleted()).toHaveLength(1);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('sendToDeadLetter', () => {
|
|
289
|
+
beforeEach(async () => {
|
|
290
|
+
await transport.connect();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should send to dead letter using primary transport', async () => {
|
|
294
|
+
const envelope = createTestEnvelope();
|
|
295
|
+
await primary.send('test-queue', envelope);
|
|
296
|
+
|
|
297
|
+
const received = await primary.receiveOne('test-queue');
|
|
298
|
+
expect(received).not.toBeNull();
|
|
299
|
+
|
|
300
|
+
await transport.sendToDeadLetter(
|
|
301
|
+
received!.receipt,
|
|
302
|
+
'dlq',
|
|
303
|
+
envelope,
|
|
304
|
+
'test error',
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
expect(primary.getQueueSize('test-queue')).toBe(0);
|
|
308
|
+
expect(primary.getQueueSize('test-queue.dlq')).toBe(1);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
function createTestEnvelope() {
|
|
314
|
+
return createEnvelope({
|
|
315
|
+
eventKey: 'test.event',
|
|
316
|
+
targetSubscriber: 'test-subscriber',
|
|
317
|
+
data: { test: 'data' },
|
|
318
|
+
importance: 'should-investigate',
|
|
319
|
+
});
|
|
320
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { AllTransportsFailedError } from '../../errors/index.js';
|
|
2
|
+
import type { TransportFallbackContext } from '../../hooks/index.js';
|
|
3
|
+
import type { Topology } from '../../topology/types.js';
|
|
4
|
+
import type { Envelope } from '../../types/index.js';
|
|
5
|
+
import type { TransportCapabilities } from '../capabilities.js';
|
|
6
|
+
import type {
|
|
7
|
+
MessageHandler,
|
|
8
|
+
MessageReceipt,
|
|
9
|
+
SendOptions,
|
|
10
|
+
SubscribeOptions,
|
|
11
|
+
Subscription,
|
|
12
|
+
Transport,
|
|
13
|
+
} from '../transport.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Symbol key used to store the source transport on wrapped receipts.
|
|
17
|
+
*/
|
|
18
|
+
const SOURCE_TRANSPORT = Symbol('sourceTransport');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extracts the source transport from a tagged receipt.
|
|
22
|
+
*/
|
|
23
|
+
function getSourceTransport(receipt: MessageReceipt): Transport | undefined {
|
|
24
|
+
return (receipt as unknown as Record<symbol, Transport | undefined>)[
|
|
25
|
+
SOURCE_TRANSPORT
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configuration for MultiTransport.
|
|
31
|
+
*/
|
|
32
|
+
export interface MultiTransportConfig {
|
|
33
|
+
/**
|
|
34
|
+
* Ordered list of transports.
|
|
35
|
+
* First transport is primary, rest are used as fallbacks when primary fails
|
|
36
|
+
* (if fallback is enabled), or can be explicitly selected via the getDesiredBackend hook.
|
|
37
|
+
*/
|
|
38
|
+
readonly transports: readonly Transport[];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether to automatically try fallback transports when the primary/selected fails.
|
|
42
|
+
* When true (default), send() will try each transport in order until one succeeds.
|
|
43
|
+
* When false, send() will only use the selected transport and throw immediately on failure.
|
|
44
|
+
*
|
|
45
|
+
* @default true
|
|
46
|
+
*/
|
|
47
|
+
readonly fallbackEnabled?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hooks for MultiTransport.
|
|
52
|
+
*/
|
|
53
|
+
export interface MultiTransportHooks {
|
|
54
|
+
/**
|
|
55
|
+
* Hook to dynamically select which backend to use.
|
|
56
|
+
* Return the transport name (e.g., 'local', 'rabbitmq') or undefined to use primary.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* getDesiredBackend: async () => {
|
|
61
|
+
* if (process.env.SANDBOX === 'true') return 'local';
|
|
62
|
+
* const backend = await runtimeConfig.get('events.backend');
|
|
63
|
+
* return backend || 'rabbitmq';
|
|
64
|
+
* }
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
readonly getDesiredBackend?: () =>
|
|
68
|
+
| string
|
|
69
|
+
| undefined
|
|
70
|
+
| Promise<string | undefined>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Called when a fallback transport is used successfully after the selected one fails.
|
|
74
|
+
* Only called when fallbackEnabled is true.
|
|
75
|
+
*/
|
|
76
|
+
readonly onEnqueueFallback?: (context: TransportFallbackContext) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Computes merged capabilities from multiple transports.
|
|
81
|
+
* Uses the most restrictive/conservative interpretation.
|
|
82
|
+
*/
|
|
83
|
+
function mergeCapabilities(
|
|
84
|
+
transports: readonly Transport[],
|
|
85
|
+
): TransportCapabilities {
|
|
86
|
+
const primary = transports[0];
|
|
87
|
+
if (!primary) {
|
|
88
|
+
throw new Error('At least one transport is required');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// For multi-transport scenarios, we use primary's capabilities since that's
|
|
92
|
+
// what we'll use for subscriptions and most operations
|
|
93
|
+
return primary.capabilities;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A transport wrapper that manages multiple transports.
|
|
98
|
+
*
|
|
99
|
+
* Features:
|
|
100
|
+
* - All transports are connected upfront
|
|
101
|
+
* - send() tries transports in order until one succeeds (fallback behavior)
|
|
102
|
+
* - Specific transport can be selected via getDesiredBackend hook
|
|
103
|
+
* - Subscriptions are created on ALL transports
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* const transport = new MultiTransport(
|
|
108
|
+
* { transports: [rabbitMQTransport, localTransport] },
|
|
109
|
+
* { onEnqueueFallback: (ctx) => console.warn(`Fallback to ${ctx.nextTransport}`) },
|
|
110
|
+
* );
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export class MultiTransport implements Transport {
|
|
114
|
+
readonly name: string;
|
|
115
|
+
readonly capabilities: TransportCapabilities;
|
|
116
|
+
readonly primary: Transport;
|
|
117
|
+
|
|
118
|
+
/** All available transports, in order of preference (primary first). */
|
|
119
|
+
readonly transports: readonly Transport[];
|
|
120
|
+
|
|
121
|
+
/** Whether fallback to secondary transports is enabled. */
|
|
122
|
+
readonly fallbackEnabled: boolean;
|
|
123
|
+
|
|
124
|
+
private readonly hooks: MultiTransportHooks;
|
|
125
|
+
private connected = false;
|
|
126
|
+
|
|
127
|
+
constructor(config: MultiTransportConfig, hooks: MultiTransportHooks = {}) {
|
|
128
|
+
const primary = config.transports[0];
|
|
129
|
+
if (!primary) {
|
|
130
|
+
throw new Error('At least one transport is required');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.primary = primary;
|
|
134
|
+
this.transports = config.transports;
|
|
135
|
+
this.fallbackEnabled = config.fallbackEnabled ?? true;
|
|
136
|
+
this.hooks = hooks;
|
|
137
|
+
this.name = `multi(${this.transports.map((t) => t.name).join(',')})`;
|
|
138
|
+
this.capabilities = mergeCapabilities(this.transports);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async connect(): Promise<void> {
|
|
142
|
+
// Connect all transports in parallel
|
|
143
|
+
await Promise.all(this.transports.map((t) => t.connect()));
|
|
144
|
+
this.connected = true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async disconnect(): Promise<void> {
|
|
148
|
+
// Disconnect all transports in parallel
|
|
149
|
+
await Promise.all(this.transports.map((t) => t.disconnect()));
|
|
150
|
+
this.connected = false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
isConnected(): boolean {
|
|
154
|
+
// Consider connected if primary is connected
|
|
155
|
+
return this.connected && this.primary.isConnected();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async applyTopology(topology: Topology): Promise<void> {
|
|
159
|
+
// Apply topology to all transports so they're all ready
|
|
160
|
+
await Promise.all(this.transports.map((t) => t.applyTopology(topology)));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async send(
|
|
164
|
+
queue: string,
|
|
165
|
+
envelope: Envelope,
|
|
166
|
+
options?: SendOptions,
|
|
167
|
+
): Promise<Transport['name']> {
|
|
168
|
+
// Determine which transport to use
|
|
169
|
+
const selectedTransport = await this.selectTransport();
|
|
170
|
+
|
|
171
|
+
// If fallback is disabled, only use selected transport
|
|
172
|
+
if (!this.fallbackEnabled) {
|
|
173
|
+
return selectedTransport.send(queue, envelope, options);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Build transport order: selected first, then others
|
|
177
|
+
const transportOrder = this.getTransportOrder(selectedTransport);
|
|
178
|
+
|
|
179
|
+
const errors: Error[] = [];
|
|
180
|
+
|
|
181
|
+
for (let i = 0; i < transportOrder.length; i++) {
|
|
182
|
+
const transport = transportOrder[i]!;
|
|
183
|
+
try {
|
|
184
|
+
return await transport.send(queue, envelope, options);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
187
|
+
errors.push(err);
|
|
188
|
+
|
|
189
|
+
// Notify about fallback before trying next transport
|
|
190
|
+
const nextTransport = transportOrder[i + 1];
|
|
191
|
+
if (nextTransport && this.hooks.onEnqueueFallback) {
|
|
192
|
+
this.hooks.onEnqueueFallback({
|
|
193
|
+
envelope,
|
|
194
|
+
queue,
|
|
195
|
+
failedTransport: transport.name,
|
|
196
|
+
nextTransport: nextTransport.name,
|
|
197
|
+
error: err,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
// Continue to next transport
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// All transports failed
|
|
205
|
+
throw new AllTransportsFailedError(queue, errors);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Selects the transport to use based on the getDesiredBackend hook.
|
|
210
|
+
*/
|
|
211
|
+
private async selectTransport(): Promise<Transport> {
|
|
212
|
+
if (!this.hooks.getDesiredBackend) {
|
|
213
|
+
return this.primary;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const desiredBackend = await this.hooks.getDesiredBackend();
|
|
218
|
+
if (!desiredBackend) {
|
|
219
|
+
return this.primary;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const selected = this.transports.find((t) => t.name === desiredBackend);
|
|
223
|
+
if (selected) {
|
|
224
|
+
return selected;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Desired backend not found, use primary
|
|
228
|
+
return this.primary;
|
|
229
|
+
} catch {
|
|
230
|
+
// Hook threw, use primary
|
|
231
|
+
return this.primary;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Returns transports in order, with the selected transport first.
|
|
237
|
+
*/
|
|
238
|
+
private getTransportOrder(selected: Transport): readonly Transport[] {
|
|
239
|
+
if (selected === this.primary) {
|
|
240
|
+
return this.transports;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Put selected first, then the rest (excluding selected)
|
|
244
|
+
return [selected, ...this.transports.filter((t) => t !== selected)];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async subscribe(
|
|
248
|
+
queue: string,
|
|
249
|
+
handler: MessageHandler,
|
|
250
|
+
options?: SubscribeOptions,
|
|
251
|
+
): Promise<Subscription> {
|
|
252
|
+
// Subscribe on ALL transports so messages are processed regardless of
|
|
253
|
+
// which transport they were enqueued to
|
|
254
|
+
const subscriptions = await Promise.all(
|
|
255
|
+
this.transports.map((transport) => {
|
|
256
|
+
// Wrap handler to tag receipts with source transport
|
|
257
|
+
const wrappedHandler: MessageHandler = (envelope, receipt) => {
|
|
258
|
+
const taggedReceipt = Object.assign(receipt, {
|
|
259
|
+
[SOURCE_TRANSPORT]: transport,
|
|
260
|
+
});
|
|
261
|
+
return handler(envelope, taggedReceipt);
|
|
262
|
+
};
|
|
263
|
+
return transport.subscribe(queue, wrappedHandler, options);
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
unsubscribe: async () => {
|
|
269
|
+
await Promise.all(subscriptions.map((s) => s.unsubscribe()));
|
|
270
|
+
},
|
|
271
|
+
get isActive() {
|
|
272
|
+
return subscriptions.some((s) => s.isActive);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async complete(receipt: MessageReceipt): Promise<void> {
|
|
278
|
+
const transport = getSourceTransport(receipt) ?? this.primary;
|
|
279
|
+
return transport.complete(receipt);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async sendToDeadLetter(
|
|
283
|
+
receipt: MessageReceipt,
|
|
284
|
+
dlqName: string,
|
|
285
|
+
envelope: Envelope,
|
|
286
|
+
reason: string,
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
const transport = getSourceTransport(receipt) ?? this.primary;
|
|
289
|
+
if (transport.sendToDeadLetter) {
|
|
290
|
+
return transport.sendToDeadLetter(receipt, dlqName, envelope, reason);
|
|
291
|
+
}
|
|
292
|
+
throw new Error('Transport does not support dead letter routing');
|
|
293
|
+
}
|
|
294
|
+
}
|