@zdavison/matador 4.0.1 → 4.0.3

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 (38) hide show
  1. package/dist/core/fanout.d.ts +62 -0
  2. package/dist/core/fanout.d.ts.map +1 -1
  3. package/dist/core/fanout.js +217 -15
  4. package/dist/core/fanout.test.js +904 -6
  5. package/dist/core/matador.d.ts +33 -0
  6. package/dist/core/matador.d.ts.map +1 -1
  7. package/dist/core/matador.js +6 -1
  8. package/dist/core/matador.test.js +243 -7
  9. package/dist/errors/index.d.ts +1 -1
  10. package/dist/errors/index.d.ts.map +1 -1
  11. package/dist/errors/index.js +2 -2
  12. package/dist/errors/matador-errors.d.ts +10 -8
  13. package/dist/errors/matador-errors.d.ts.map +1 -1
  14. package/dist/errors/matador-errors.js +18 -14
  15. package/dist/index.cjs +345 -73
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/transport/local/local-transport.d.ts +6 -0
  23. package/dist/transport/local/local-transport.d.ts.map +1 -1
  24. package/dist/transport/local/local-transport.js +35 -18
  25. package/dist/transport/local/local-transport.test.js +40 -4
  26. package/dist/transport/multi/multi-transport.d.ts +6 -0
  27. package/dist/transport/multi/multi-transport.d.ts.map +1 -1
  28. package/dist/transport/multi/multi-transport.js +20 -0
  29. package/dist/transport/multi/multi-transport.test.js +82 -0
  30. package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.js +45 -6
  31. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts +6 -0
  32. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -1
  33. package/dist/transport/rabbitmq/rabbitmq-transport.js +83 -40
  34. package/dist/transport/transport.d.ts +12 -0
  35. package/dist/transport/transport.d.ts.map +1 -1
  36. package/dist/types/event.d.ts +17 -0
  37. package/dist/types/event.d.ts.map +1 -1
  38. package/package.json +1 -1
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, mock } from 'bun:test';
2
2
  import { TransportSendError } from '../errors/index.js';
3
3
  import { SafeHooks } from '../hooks/index.js';
4
4
  import { SchemaRegistry } from '../schema/index.js';
5
- import { TopologyBuilder } from '../topology/index.js';
5
+ import { TopologyBuilder, resolveTargetQueueName } from '../topology/index.js';
6
+ import { LocalTransport, MultiTransport } from '../transport/index.js';
6
7
  import { MatadorEvent, createSubscriber, createSubscriberStub, } from '../types/index.js';
7
8
  import { FanoutEngine } from './fanout.js';
8
9
  class UserCreatedEvent extends MatadorEvent {
@@ -34,6 +35,20 @@ class UserCreatedEventWithMetadata extends MatadorEvent {
34
35
  this.metadata = metadata;
35
36
  }
36
37
  }
38
+ /**
39
+ * Waits for a triggered retry-buffer flush to fully settle, including the
40
+ * inter-item throttle sleeps — a single macrotask tick isn't enough once the
41
+ * flush loop sleeps between items.
42
+ */
43
+ async function waitForFlush(fanout) {
44
+ const deadline = Date.now() + 1000;
45
+ while (fanout.eventsBeingEnqueuedCount > 0) {
46
+ if (Date.now() > deadline) {
47
+ throw new Error('waitForFlush: timed out waiting for flush to settle');
48
+ }
49
+ await new Promise((resolve) => setTimeout(resolve, 1));
50
+ }
51
+ }
37
52
  const testTopology = TopologyBuilder.create()
38
53
  .withNamespace('test')
39
54
  .addQueue('events')
@@ -514,7 +529,7 @@ describe('FanoutEngine', () => {
514
529
  });
515
530
  });
516
531
  describe('error handling', () => {
517
- it('should capture error when transport.send fails', async () => {
532
+ it('should capture error when transport.send fails and buffer is disabled', async () => {
518
533
  const sendError = new Error('Network timeout');
519
534
  const failingTransport = {
520
535
  ...transport,
@@ -539,7 +554,7 @@ describe('FanoutEngine', () => {
539
554
  userId: '123',
540
555
  email: 'test@example.com',
541
556
  });
542
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
557
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
543
558
  expect(result.subscribersSent).toBe(0);
544
559
  expect(result.subscribersSkipped).toBe(0);
545
560
  expect(result.errors).toHaveLength(1);
@@ -581,7 +596,7 @@ describe('FanoutEngine', () => {
581
596
  userId: '123',
582
597
  email: 'test@example.com',
583
598
  });
584
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
599
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
585
600
  expect(result.subscribersSent).toBe(1);
586
601
  expect(result.subscribersSkipped).toBe(0);
587
602
  expect(result.errors).toHaveLength(1);
@@ -611,7 +626,7 @@ describe('FanoutEngine', () => {
611
626
  userId: '123',
612
627
  email: 'test@example.com',
613
628
  });
614
- const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
629
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
615
630
  expect(result.errors).toHaveLength(1);
616
631
  expect(result.errors[0]?.error).toBeInstanceOf(TransportSendError);
617
632
  expect(result.errors[0]?.error.message).toContain('matador.test.events');
@@ -867,7 +882,9 @@ describe('FanoutEngine', () => {
867
882
  userId: '123',
868
883
  email: 'test@example.com',
869
884
  });
870
- await fanoutWithHooks.send(UserCreatedEvent, event);
885
+ await fanoutWithHooks.send(UserCreatedEvent, event, {
886
+ throwOnBufferedFailure: true,
887
+ });
871
888
  expect(onEnqueueError).toHaveBeenCalledTimes(1);
872
889
  expect(onEnqueueError).toHaveBeenCalledWith({
873
890
  envelope: expect.any(Object),
@@ -1069,4 +1086,885 @@ describe('FanoutEngine', () => {
1069
1086
  expect(envelope.docket.importance).toBe('should-investigate');
1070
1087
  });
1071
1088
  });
1089
+ describe('retry buffering', () => {
1090
+ it('should buffer a failed send and not return it as an error', async () => {
1091
+ const subscriber = createSubscriber({
1092
+ name: 'handle-user',
1093
+ description: 'Handles user events',
1094
+ callback: async () => { },
1095
+ });
1096
+ schema.register(UserCreatedEvent, [subscriber]);
1097
+ const failingTransport = {
1098
+ ...transport,
1099
+ send: mock(async () => {
1100
+ throw new Error('Connection lost');
1101
+ }),
1102
+ };
1103
+ const fanoutWithFailingTransport = new FanoutEngine({
1104
+ transport: failingTransport,
1105
+ schema,
1106
+ hooks,
1107
+ topology: testTopology,
1108
+ defaultQueue: 'events',
1109
+ });
1110
+ const event = new UserCreatedEvent({
1111
+ userId: '123',
1112
+ email: 'test@example.com',
1113
+ });
1114
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
1115
+ expect(result.errors).toHaveLength(0);
1116
+ expect(result.subscribersSent).toBe(0);
1117
+ });
1118
+ it('should buffer stub failures too', async () => {
1119
+ const stub = createSubscriberStub({ name: 'remote-service' });
1120
+ schema.register(UserCreatedEvent, [stub]);
1121
+ const failingTransport = {
1122
+ ...transport,
1123
+ send: mock(async () => {
1124
+ throw new Error('Connection lost');
1125
+ }),
1126
+ };
1127
+ const fanoutWithFailingTransport = new FanoutEngine({
1128
+ transport: failingTransport,
1129
+ schema,
1130
+ hooks,
1131
+ topology: testTopology,
1132
+ defaultQueue: 'events',
1133
+ });
1134
+ const event = new UserCreatedEvent({
1135
+ userId: '123',
1136
+ email: 'test@example.com',
1137
+ });
1138
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event);
1139
+ expect(result.errors).toHaveLength(0);
1140
+ expect(result.subscribersSent).toBe(0);
1141
+ });
1142
+ it('should buffer instead of reporting success when a stub subscriber has no local subscriber', async () => {
1143
+ // LocalTransport rejects sends to a queue with no active subscriber in
1144
+ // this process. A stub subscriber's real implementation lives in another
1145
+ // service, so this queue will never have one here — the send must fail
1146
+ // and be buffered, not reported as delivered.
1147
+ const stub = createSubscriberStub({ name: 'remote-service' });
1148
+ schema.register(UserCreatedEvent, [stub]);
1149
+ const onEnqueueSuccess = mock(async () => { });
1150
+ const hooksInstance = new SafeHooks({ onEnqueueSuccess });
1151
+ const localTransport = new LocalTransport();
1152
+ await localTransport.connect();
1153
+ await localTransport.applyTopology(testTopology);
1154
+ const fanoutWithLocal = new FanoutEngine({
1155
+ transport: localTransport,
1156
+ schema,
1157
+ hooks: hooksInstance,
1158
+ topology: testTopology,
1159
+ defaultQueue: 'events',
1160
+ // Fast interval so the test doesn't have to wait on the 30s default
1161
+ // to prove the message is actually held for retry, not dropped.
1162
+ retryIntervalMs: 10,
1163
+ });
1164
+ const event = new UserCreatedEvent({
1165
+ userId: '123',
1166
+ email: 'test@example.com',
1167
+ });
1168
+ const result = await fanoutWithLocal.send(UserCreatedEvent, event);
1169
+ expect(result.errors).toHaveLength(0);
1170
+ expect(result.subscribersSent).toBe(0);
1171
+ expect(onEnqueueSuccess).not.toHaveBeenCalled();
1172
+ // The message never reached LocalTransport's own storage (enqueue()
1173
+ // throws before storing), so it can only still exist in-memory if
1174
+ // FanoutEngine actually buffered it. Prove that by attaching a
1175
+ // subscriber and letting the periodic flush retry the send — if the
1176
+ // message had been dropped instead of buffered, nothing would arrive.
1177
+ const received = [];
1178
+ await localTransport.subscribe(resolveTargetQueueName(testTopology, 'events'), async (envelope) => {
1179
+ received.push(envelope);
1180
+ });
1181
+ await new Promise((resolve) => setTimeout(resolve, 30));
1182
+ expect(received).toHaveLength(1);
1183
+ expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
1184
+ fanoutWithLocal.dispose();
1185
+ });
1186
+ it('should flush buffered messages when onConnected fires', async () => {
1187
+ let connectedCallback;
1188
+ const failThenSucceed = mock(async () => {
1189
+ throw new Error('Connection lost');
1190
+ });
1191
+ const transportWithReconnect = {
1192
+ ...transport,
1193
+ send: failThenSucceed,
1194
+ onConnected: (cb) => {
1195
+ connectedCallback = cb;
1196
+ return () => { };
1197
+ },
1198
+ };
1199
+ const fanoutWithReconnect = new FanoutEngine({
1200
+ transport: transportWithReconnect,
1201
+ schema,
1202
+ hooks,
1203
+ topology: testTopology,
1204
+ defaultQueue: 'events',
1205
+ });
1206
+ const subscriber = createSubscriber({
1207
+ name: 'handle-user',
1208
+ description: 'Handles user events',
1209
+ callback: async () => { },
1210
+ });
1211
+ schema.register(UserCreatedEvent, [subscriber]);
1212
+ const event = new UserCreatedEvent({
1213
+ userId: '123',
1214
+ email: 'test@example.com',
1215
+ });
1216
+ // First send — transport fails, message should be buffered
1217
+ await fanoutWithReconnect.send(UserCreatedEvent, event);
1218
+ expect(failThenSucceed).toHaveBeenCalledTimes(1);
1219
+ // Simulate reconnect — now send succeeds
1220
+ transportWithReconnect.send.mockImplementation(async () => 'mock');
1221
+ expect(connectedCallback).toBeDefined();
1222
+ connectedCallback?.();
1223
+ // Give the async flush a chance to run
1224
+ await waitForFlush(fanoutWithReconnect);
1225
+ expect(transportWithReconnect.send).toHaveBeenCalledTimes(2);
1226
+ });
1227
+ it('should re-buffer on flush failure and retry on next reconnect', async () => {
1228
+ let connectedCallback;
1229
+ const transportWithReconnect = {
1230
+ ...transport,
1231
+ send: mock(async () => {
1232
+ throw new Error('Still down');
1233
+ }),
1234
+ onConnected: (cb) => {
1235
+ connectedCallback = cb;
1236
+ return () => { };
1237
+ },
1238
+ };
1239
+ const fanoutWithReconnect = new FanoutEngine({
1240
+ transport: transportWithReconnect,
1241
+ schema,
1242
+ hooks,
1243
+ topology: testTopology,
1244
+ defaultQueue: 'events',
1245
+ });
1246
+ const subscriber = createSubscriber({
1247
+ name: 'handle-user',
1248
+ description: 'Handles user events',
1249
+ callback: async () => { },
1250
+ });
1251
+ schema.register(UserCreatedEvent, [subscriber]);
1252
+ const event = new UserCreatedEvent({
1253
+ userId: '123',
1254
+ email: 'test@example.com',
1255
+ });
1256
+ await fanoutWithReconnect.send(UserCreatedEvent, event);
1257
+ expect(transportWithReconnect.send).toHaveBeenCalledTimes(1);
1258
+ // Simulate reconnect — flush fails, item should be re-buffered
1259
+ connectedCallback?.();
1260
+ await waitForFlush(fanoutWithReconnect);
1261
+ // send was attempted again during the flush
1262
+ expect(transportWithReconnect.send).toHaveBeenCalledTimes(2);
1263
+ // Simulate a second reconnect — should retry again
1264
+ connectedCallback?.();
1265
+ await waitForFlush(fanoutWithReconnect);
1266
+ expect(transportWithReconnect.send).toHaveBeenCalledTimes(3);
1267
+ });
1268
+ it('should not buffer when transport works fine', async () => {
1269
+ let connectedCallback;
1270
+ const successTransport = {
1271
+ ...transport,
1272
+ onConnected: (cb) => {
1273
+ connectedCallback = cb;
1274
+ return () => { };
1275
+ },
1276
+ };
1277
+ const fanoutWithReconnect = new FanoutEngine({
1278
+ transport: successTransport,
1279
+ schema,
1280
+ hooks,
1281
+ topology: testTopology,
1282
+ defaultQueue: 'events',
1283
+ });
1284
+ const subscriber = createSubscriber({
1285
+ name: 'handle-user',
1286
+ description: 'Handles user events',
1287
+ callback: async () => { },
1288
+ });
1289
+ schema.register(UserCreatedEvent, [subscriber]);
1290
+ const event = new UserCreatedEvent({
1291
+ userId: '123',
1292
+ email: 'test@example.com',
1293
+ });
1294
+ const result = await fanoutWithReconnect.send(UserCreatedEvent, event);
1295
+ expect(result.subscribersSent).toBe(1);
1296
+ expect(result.errors).toHaveLength(0);
1297
+ // onConnected fires — nothing buffered so flush is a no-op
1298
+ connectedCallback?.();
1299
+ await waitForFlush(fanoutWithReconnect);
1300
+ // Only one call total (the original send — not a spurious re-send)
1301
+ expect(successTransport.send).toHaveBeenCalledTimes(1);
1302
+ });
1303
+ it('should report error in result when buffer is false', async () => {
1304
+ const subscriber = createSubscriber({
1305
+ name: 'handle-user',
1306
+ description: 'Handles user events',
1307
+ callback: async () => { },
1308
+ });
1309
+ schema.register(UserCreatedEvent, [subscriber]);
1310
+ const failingTransport = {
1311
+ ...transport,
1312
+ send: mock(async () => {
1313
+ throw new Error('Connection lost');
1314
+ }),
1315
+ };
1316
+ const fanoutWithFailingTransport = new FanoutEngine({
1317
+ transport: failingTransport,
1318
+ schema,
1319
+ hooks,
1320
+ topology: testTopology,
1321
+ defaultQueue: 'events',
1322
+ });
1323
+ const event = new UserCreatedEvent({
1324
+ userId: '123',
1325
+ email: 'test@example.com',
1326
+ });
1327
+ const result = await fanoutWithFailingTransport.send(UserCreatedEvent, event, { buffer: false });
1328
+ expect(result.errors).toHaveLength(1);
1329
+ expect(result.errors[0]?.subscriberName).toBe('handle-user');
1330
+ expect(result.errors[0]?.error).toBeInstanceOf(TransportSendError);
1331
+ });
1332
+ it('should both buffer and report error when reportBufferedFailure is true', async () => {
1333
+ let connectedCallback;
1334
+ const failingTransport = {
1335
+ ...transport,
1336
+ send: mock(async () => {
1337
+ throw new Error('Connection lost');
1338
+ }),
1339
+ onConnected: (cb) => {
1340
+ connectedCallback = cb;
1341
+ return () => { };
1342
+ },
1343
+ };
1344
+ const fanoutWithReconnect = new FanoutEngine({
1345
+ transport: failingTransport,
1346
+ schema,
1347
+ hooks,
1348
+ topology: testTopology,
1349
+ defaultQueue: 'events',
1350
+ });
1351
+ const subscriber = createSubscriber({
1352
+ name: 'handle-user',
1353
+ description: 'Handles user events',
1354
+ callback: async () => { },
1355
+ });
1356
+ schema.register(UserCreatedEvent, [subscriber]);
1357
+ const event = new UserCreatedEvent({
1358
+ userId: '123',
1359
+ email: 'test@example.com',
1360
+ });
1361
+ const result = await fanoutWithReconnect.send(UserCreatedEvent, event, {
1362
+ throwOnBufferedFailure: true,
1363
+ });
1364
+ // Error is reported immediately
1365
+ expect(result.errors).toHaveLength(1);
1366
+ expect(result.errors[0]?.subscriberName).toBe('handle-user');
1367
+ // But it was also buffered — it should retry on reconnect
1368
+ failingTransport.send.mockImplementation(async () => 'mock');
1369
+ connectedCallback?.();
1370
+ await waitForFlush(fanoutWithReconnect);
1371
+ expect(failingTransport.send).toHaveBeenCalledTimes(2);
1372
+ });
1373
+ it('should unsubscribe the onConnected listener when dispose() is called', () => {
1374
+ const unsubscribe = mock(() => { });
1375
+ const transportWithOnConnected = {
1376
+ ...transport,
1377
+ onConnected: mock((_cb) => unsubscribe),
1378
+ };
1379
+ const fanout = new FanoutEngine({
1380
+ transport: transportWithOnConnected,
1381
+ schema,
1382
+ hooks,
1383
+ topology: testTopology,
1384
+ defaultQueue: 'events',
1385
+ });
1386
+ expect(unsubscribe).not.toHaveBeenCalled();
1387
+ fanout.dispose();
1388
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
1389
+ });
1390
+ it('should drop and report error when retry buffer is full', async () => {
1391
+ const subscriber = createSubscriber({
1392
+ name: 'handle-user',
1393
+ description: 'Handles user events',
1394
+ callback: async () => { },
1395
+ });
1396
+ schema.register(UserCreatedEvent, [subscriber]);
1397
+ const failingTransport = {
1398
+ ...transport,
1399
+ send: mock(async () => {
1400
+ throw new Error('Connection lost');
1401
+ }),
1402
+ };
1403
+ const fanoutWithSmallBuffer = new FanoutEngine({
1404
+ transport: failingTransport,
1405
+ schema,
1406
+ hooks,
1407
+ topology: testTopology,
1408
+ defaultQueue: 'events',
1409
+ maxRetryBufferSize: 1,
1410
+ });
1411
+ const event = new UserCreatedEvent({
1412
+ userId: '123',
1413
+ email: 'test@example.com',
1414
+ });
1415
+ // First send fills the buffer
1416
+ const first = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1417
+ expect(first.errors).toHaveLength(0);
1418
+ // Second send hits the full buffer — must report an error
1419
+ const second = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1420
+ expect(second.errors).toHaveLength(1);
1421
+ expect(second.errors[0]?.error).toBeInstanceOf(TransportSendError);
1422
+ });
1423
+ it('should report buffer-full drop via onEnqueueError on initial send', async () => {
1424
+ const onEnqueueError = mock(async () => { });
1425
+ const hooksInstance = new SafeHooks({
1426
+ onEnqueueError,
1427
+ });
1428
+ const failingTransport = {
1429
+ ...transport,
1430
+ send: mock(async () => {
1431
+ throw new Error('Connection lost');
1432
+ }),
1433
+ };
1434
+ const subscriber = createSubscriber({
1435
+ name: 'handle-user',
1436
+ description: 'Handles user events',
1437
+ callback: async () => { },
1438
+ });
1439
+ schema.register(UserCreatedEvent, [subscriber]);
1440
+ const fanoutWithSmallBuffer = new FanoutEngine({
1441
+ transport: failingTransport,
1442
+ schema,
1443
+ hooks: hooksInstance,
1444
+ topology: testTopology,
1445
+ defaultQueue: 'events',
1446
+ maxRetryBufferSize: 1,
1447
+ });
1448
+ const event = new UserCreatedEvent({
1449
+ userId: '123',
1450
+ email: 'test@example.com',
1451
+ });
1452
+ // First send fills the buffer — no error reported to caller or hook
1453
+ const first = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1454
+ expect(first.errors).toHaveLength(0);
1455
+ expect(onEnqueueError).not.toHaveBeenCalled();
1456
+ // Second send finds the buffer full — onEnqueueError fires and error is returned
1457
+ const second = await fanoutWithSmallBuffer.send(UserCreatedEvent, event);
1458
+ expect(second.errors).toHaveLength(1);
1459
+ expect(onEnqueueError).toHaveBeenCalledTimes(1);
1460
+ });
1461
+ it('should not buffer when a fallback transport succeeds after the primary fails', async () => {
1462
+ const rabbitMock = {
1463
+ ...transport,
1464
+ name: 'rabbitmq',
1465
+ send: mock(async () => {
1466
+ throw new Error('RabbitMQ down');
1467
+ }),
1468
+ };
1469
+ const localMock = {
1470
+ ...transport,
1471
+ name: 'local',
1472
+ send: mock(async () => 'local'),
1473
+ };
1474
+ const multiTransport = new MultiTransport({
1475
+ transports: [rabbitMock, localMock],
1476
+ });
1477
+ const fanoutWithMulti = new FanoutEngine({
1478
+ transport: multiTransport,
1479
+ schema,
1480
+ hooks,
1481
+ topology: testTopology,
1482
+ defaultQueue: 'events',
1483
+ });
1484
+ const subscriber = createSubscriber({
1485
+ name: 'handle-user',
1486
+ description: 'Handles user events',
1487
+ callback: async () => { },
1488
+ });
1489
+ schema.register(UserCreatedEvent, [subscriber]);
1490
+ const event = new UserCreatedEvent({
1491
+ userId: '123',
1492
+ email: 'test@example.com',
1493
+ });
1494
+ const result = await fanoutWithMulti.send(UserCreatedEvent, event);
1495
+ expect(result.subscribersSent).toBe(1);
1496
+ expect(result.errors).toHaveLength(0);
1497
+ expect(rabbitMock.send).toHaveBeenCalledTimes(1);
1498
+ expect(localMock.send).toHaveBeenCalledTimes(1);
1499
+ expect(fanoutWithMulti.eventsBeingEnqueuedCount).toBe(0);
1500
+ });
1501
+ it('should buffer (not report success) when a real MultiTransport falls back to local for a stub subscriber', async () => {
1502
+ const stub = createSubscriberStub({ name: 'remote-service' });
1503
+ schema.register(UserCreatedEvent, [stub]);
1504
+ const rabbitMock = {
1505
+ ...transport,
1506
+ name: 'rabbitmq',
1507
+ send: mock(async () => {
1508
+ throw new Error('RabbitMQ down');
1509
+ }),
1510
+ };
1511
+ const localTransport = new LocalTransport();
1512
+ await localTransport.connect();
1513
+ await localTransport.applyTopology(testTopology);
1514
+ const multiTransport = new MultiTransport({
1515
+ transports: [rabbitMock, localTransport],
1516
+ });
1517
+ const onEnqueueSuccess = mock(async () => { });
1518
+ const hooksInstance = new SafeHooks({ onEnqueueSuccess });
1519
+ const fanoutWithMulti = new FanoutEngine({
1520
+ transport: multiTransport,
1521
+ schema,
1522
+ hooks: hooksInstance,
1523
+ topology: testTopology,
1524
+ defaultQueue: 'events',
1525
+ // Fast interval so the test doesn't have to wait on the 30s default
1526
+ // to prove the message is actually held for retry, not dropped.
1527
+ retryIntervalMs: 10,
1528
+ });
1529
+ const event = new UserCreatedEvent({
1530
+ userId: '123',
1531
+ email: 'test@example.com',
1532
+ });
1533
+ const result = await fanoutWithMulti.send(UserCreatedEvent, event);
1534
+ expect(result.subscribersSent).toBe(0);
1535
+ expect(result.errors).toHaveLength(0);
1536
+ expect(onEnqueueSuccess).not.toHaveBeenCalled();
1537
+ expect(rabbitMock.send).toHaveBeenCalledTimes(1);
1538
+ // Neither rabbitmq nor local ever stored the message durably (rabbit
1539
+ // threw, local's enqueue() throws with no subscriber), so it can only
1540
+ // still be in memory if FanoutEngine actually buffered it. Prove that
1541
+ // by attaching a local subscriber and letting the periodic flush retry.
1542
+ const received = [];
1543
+ await localTransport.subscribe(resolveTargetQueueName(testTopology, 'events'), async (envelope) => {
1544
+ received.push(envelope);
1545
+ });
1546
+ await new Promise((resolve) => setTimeout(resolve, 30));
1547
+ expect(received).toHaveLength(1);
1548
+ expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
1549
+ fanoutWithMulti.dispose();
1550
+ });
1551
+ it('should re-buffer a stub message that falls back to local again on flush, then deliver once the primary recovers', async () => {
1552
+ const stub = createSubscriberStub({ name: 'remote-service' });
1553
+ schema.register(UserCreatedEvent, [stub]);
1554
+ let rabbitUp = false;
1555
+ let connectedCallback;
1556
+ const rabbitMock = {
1557
+ ...transport,
1558
+ name: 'rabbitmq',
1559
+ send: mock(async () => {
1560
+ if (!rabbitUp)
1561
+ throw new Error('RabbitMQ down');
1562
+ return 'rabbitmq';
1563
+ }),
1564
+ onConnected: (cb) => {
1565
+ connectedCallback = cb;
1566
+ return () => { };
1567
+ },
1568
+ };
1569
+ const localTransport = new LocalTransport();
1570
+ await localTransport.connect();
1571
+ await localTransport.applyTopology(testTopology);
1572
+ const multiTransport = new MultiTransport({
1573
+ transports: [rabbitMock, localTransport],
1574
+ });
1575
+ const onEnqueueSuccess = mock(async () => { });
1576
+ const hooksInstance = new SafeHooks({ onEnqueueSuccess });
1577
+ const fanoutWithMulti = new FanoutEngine({
1578
+ transport: multiTransport,
1579
+ schema,
1580
+ hooks: hooksInstance,
1581
+ topology: testTopology,
1582
+ defaultQueue: 'events',
1583
+ });
1584
+ const event = new UserCreatedEvent({
1585
+ userId: '123',
1586
+ email: 'test@example.com',
1587
+ });
1588
+ // Initial send: rabbit down, falls back to local, buffered (not delivered).
1589
+ await fanoutWithMulti.send(UserCreatedEvent, event);
1590
+ expect(onEnqueueSuccess).not.toHaveBeenCalled();
1591
+ // Reconnect fires while rabbit is still down — flush falls back to local
1592
+ // again and must re-buffer, not report success.
1593
+ connectedCallback?.();
1594
+ await waitForFlush(fanoutWithMulti);
1595
+ expect(onEnqueueSuccess).not.toHaveBeenCalled();
1596
+ // Rabbit recovers — next flush should deliver for real via rabbitmq.
1597
+ rabbitUp = true;
1598
+ connectedCallback?.();
1599
+ await waitForFlush(fanoutWithMulti);
1600
+ expect(onEnqueueSuccess).toHaveBeenCalledTimes(1);
1601
+ expect(onEnqueueSuccess).toHaveBeenCalledWith(expect.objectContaining({ transport: 'rabbitmq' }));
1602
+ });
1603
+ it('should retry via the periodic interval even without a reconnect event', async () => {
1604
+ // Covers the case where the transport stays connected the whole time
1605
+ // but an individual publish keeps failing — onConnected never fires
1606
+ // again for that, so only the interval can recover it.
1607
+ const subscriber = createSubscriber({
1608
+ name: 'handle-user',
1609
+ description: 'Handles user events',
1610
+ callback: async () => { },
1611
+ });
1612
+ schema.register(UserCreatedEvent, [subscriber]);
1613
+ let shouldFail = true;
1614
+ const flakyTransport = {
1615
+ ...transport,
1616
+ send: mock(async () => {
1617
+ if (shouldFail)
1618
+ throw new Error('Publish nacked');
1619
+ return 'mock';
1620
+ }),
1621
+ // Deliberately no onConnected — the transport never disconnects.
1622
+ };
1623
+ const fanoutWithInterval = new FanoutEngine({
1624
+ transport: flakyTransport,
1625
+ schema,
1626
+ hooks,
1627
+ topology: testTopology,
1628
+ defaultQueue: 'events',
1629
+ retryIntervalMs: 20,
1630
+ });
1631
+ const event = new UserCreatedEvent({
1632
+ userId: '123',
1633
+ email: 'test@example.com',
1634
+ });
1635
+ await fanoutWithInterval.send(UserCreatedEvent, event);
1636
+ expect(flakyTransport.send).toHaveBeenCalledTimes(1);
1637
+ shouldFail = false;
1638
+ await new Promise((resolve) => setTimeout(resolve, 50));
1639
+ expect(flakyTransport.send).toHaveBeenCalledTimes(2);
1640
+ fanoutWithInterval.dispose();
1641
+ });
1642
+ it('should stop retrying the rest of the buffer after 10 consecutive failures in a flush pass', async () => {
1643
+ let connectedCallback;
1644
+ let shouldFail = true;
1645
+ const flakyTransport = {
1646
+ ...transport,
1647
+ send: mock(async () => {
1648
+ if (shouldFail)
1649
+ throw new Error('Broker rejecting everything');
1650
+ return 'mock';
1651
+ }),
1652
+ onConnected: (cb) => {
1653
+ connectedCallback = cb;
1654
+ return () => { };
1655
+ },
1656
+ };
1657
+ const fanoutWithReconnect = new FanoutEngine({
1658
+ transport: flakyTransport,
1659
+ schema,
1660
+ hooks,
1661
+ topology: testTopology,
1662
+ defaultQueue: 'events',
1663
+ });
1664
+ const subscriber = createSubscriber({
1665
+ name: 'handle-user',
1666
+ description: 'Handles user events',
1667
+ callback: async () => { },
1668
+ });
1669
+ schema.register(UserCreatedEvent, [subscriber]);
1670
+ const event = new UserCreatedEvent({
1671
+ userId: '123',
1672
+ email: 'test@example.com',
1673
+ });
1674
+ // 12 sends buffered; flush bails after the 10th consecutive failure,
1675
+ // leaving 2 untried.
1676
+ for (let i = 0; i < 12; i++) {
1677
+ await fanoutWithReconnect.send(UserCreatedEvent, event);
1678
+ }
1679
+ expect(flakyTransport.send).toHaveBeenCalledTimes(12);
1680
+ connectedCallback?.();
1681
+ await waitForFlush(fanoutWithReconnect);
1682
+ expect(flakyTransport.send).toHaveBeenCalledTimes(22);
1683
+ // Recovery: next flush delivers all 12 re-buffered/untried items.
1684
+ shouldFail = false;
1685
+ connectedCallback?.();
1686
+ await waitForFlush(fanoutWithReconnect);
1687
+ expect(flakyTransport.send).toHaveBeenCalledTimes(34);
1688
+ });
1689
+ it('should respect a custom maxConsecutiveFlushFailures threshold', async () => {
1690
+ let connectedCallback;
1691
+ const flakyTransport = {
1692
+ ...transport,
1693
+ send: mock(async () => {
1694
+ throw new Error('Broker rejecting everything');
1695
+ }),
1696
+ onConnected: (cb) => {
1697
+ connectedCallback = cb;
1698
+ return () => { };
1699
+ },
1700
+ };
1701
+ const fanoutWithCustomThreshold = new FanoutEngine({
1702
+ transport: flakyTransport,
1703
+ schema,
1704
+ hooks,
1705
+ topology: testTopology,
1706
+ defaultQueue: 'events',
1707
+ maxConsecutiveFlushFailures: 3,
1708
+ });
1709
+ const subscriber = createSubscriber({
1710
+ name: 'handle-user',
1711
+ description: 'Handles user events',
1712
+ callback: async () => { },
1713
+ });
1714
+ schema.register(UserCreatedEvent, [subscriber]);
1715
+ const event = new UserCreatedEvent({
1716
+ userId: '123',
1717
+ email: 'test@example.com',
1718
+ });
1719
+ // 5 sends buffered; flush should bail after the 3rd consecutive
1720
+ // failure, leaving 2 untried, instead of the default threshold of 10.
1721
+ for (let i = 0; i < 5; i++) {
1722
+ await fanoutWithCustomThreshold.send(UserCreatedEvent, event);
1723
+ }
1724
+ expect(flakyTransport.send).toHaveBeenCalledTimes(5);
1725
+ connectedCallback?.();
1726
+ await waitForFlush(fanoutWithCustomThreshold);
1727
+ expect(flakyTransport.send).toHaveBeenCalledTimes(8);
1728
+ });
1729
+ it('should never bail out of a flush pass when maxConsecutiveFlushFailures is disabled (0)', async () => {
1730
+ let connectedCallback;
1731
+ const flakyTransport = {
1732
+ ...transport,
1733
+ send: mock(async () => {
1734
+ throw new Error('Broker rejecting everything');
1735
+ }),
1736
+ onConnected: (cb) => {
1737
+ connectedCallback = cb;
1738
+ return () => { };
1739
+ },
1740
+ };
1741
+ const fanoutWithBreakerDisabled = new FanoutEngine({
1742
+ transport: flakyTransport,
1743
+ schema,
1744
+ hooks,
1745
+ topology: testTopology,
1746
+ defaultQueue: 'events',
1747
+ maxConsecutiveFlushFailures: 0,
1748
+ });
1749
+ const subscriber = createSubscriber({
1750
+ name: 'handle-user',
1751
+ description: 'Handles user events',
1752
+ callback: async () => { },
1753
+ });
1754
+ schema.register(UserCreatedEvent, [subscriber]);
1755
+ const event = new UserCreatedEvent({
1756
+ userId: '123',
1757
+ email: 'test@example.com',
1758
+ });
1759
+ // 15 sends buffered, well past the default breaker threshold of 10;
1760
+ // with the breaker disabled every single one should still be attempted.
1761
+ for (let i = 0; i < 15; i++) {
1762
+ await fanoutWithBreakerDisabled.send(UserCreatedEvent, event);
1763
+ }
1764
+ expect(flakyTransport.send).toHaveBeenCalledTimes(15);
1765
+ connectedCallback?.();
1766
+ await waitForFlush(fanoutWithBreakerDisabled);
1767
+ expect(flakyTransport.send).toHaveBeenCalledTimes(30);
1768
+ });
1769
+ it('should not trip the failure breaker on isolated failures interspersed with successes', async () => {
1770
+ let connectedCallback;
1771
+ let phase = 'buffering';
1772
+ let flushCallIndex = 0;
1773
+ const onEnqueueSuccess = mock(async () => { });
1774
+ const hooksInstance = new SafeHooks({ onEnqueueSuccess });
1775
+ const mostlyHealthyTransport = {
1776
+ ...transport,
1777
+ send: mock(async () => {
1778
+ if (phase === 'buffering') {
1779
+ throw new Error('Initially down');
1780
+ }
1781
+ flushCallIndex++;
1782
+ // Every 3rd flushed item fails — isolated, never two in a row.
1783
+ if (flushCallIndex % 3 === 0)
1784
+ throw new Error('Occasional nack');
1785
+ return 'mock';
1786
+ }),
1787
+ onConnected: (cb) => {
1788
+ connectedCallback = cb;
1789
+ return () => { };
1790
+ },
1791
+ };
1792
+ const fanoutWithReconnect = new FanoutEngine({
1793
+ transport: mostlyHealthyTransport,
1794
+ schema,
1795
+ hooks: hooksInstance,
1796
+ topology: testTopology,
1797
+ defaultQueue: 'events',
1798
+ });
1799
+ const subscriber = createSubscriber({
1800
+ name: 'handle-user',
1801
+ description: 'Handles user events',
1802
+ callback: async () => { },
1803
+ });
1804
+ schema.register(UserCreatedEvent, [subscriber]);
1805
+ const event = new UserCreatedEvent({
1806
+ userId: '123',
1807
+ email: 'test@example.com',
1808
+ });
1809
+ for (let i = 0; i < 9; i++) {
1810
+ await fanoutWithReconnect.send(UserCreatedEvent, event);
1811
+ }
1812
+ expect(mostlyHealthyTransport.send).toHaveBeenCalledTimes(9);
1813
+ // Every 3rd flushed item fails, but never two in a row, so the
1814
+ // breaker (threshold 10) never trips and all 9 get attempted.
1815
+ phase = 'flushing';
1816
+ connectedCallback?.();
1817
+ await waitForFlush(fanoutWithReconnect);
1818
+ expect(mostlyHealthyTransport.send).toHaveBeenCalledTimes(9 + 9);
1819
+ expect(onEnqueueSuccess).toHaveBeenCalledTimes(9 - 3);
1820
+ });
1821
+ it('should drop untried messages that no longer fit once maxRetryBufferSize is hit by concurrent buffering', async () => {
1822
+ let connectedCallback;
1823
+ let phase = 'buffering';
1824
+ let flushCallIndex = 0;
1825
+ let triggeredConcurrentBuffering = false;
1826
+ let fanoutWithReconnect;
1827
+ const onEnqueueError = mock(async () => { });
1828
+ const hooksInstance = new SafeHooks({ onEnqueueError });
1829
+ const subscriber = createSubscriber({
1830
+ name: 'handle-user',
1831
+ description: 'Handles user events',
1832
+ callback: async () => { },
1833
+ });
1834
+ schema.register(UserCreatedEvent, [subscriber]);
1835
+ const event = new UserCreatedEvent({
1836
+ userId: '123',
1837
+ email: 'test@example.com',
1838
+ });
1839
+ const flakyTransport = {
1840
+ ...transport,
1841
+ send: mock(async () => {
1842
+ if (phase === 'buffering') {
1843
+ throw new Error('Initially down');
1844
+ }
1845
+ flushCallIndex++;
1846
+ // Simulate a concurrent send() buffering more items mid-flush.
1847
+ if (flushCallIndex === 5 && !triggeredConcurrentBuffering) {
1848
+ triggeredConcurrentBuffering = true;
1849
+ for (let i = 0; i < 4; i++) {
1850
+ await fanoutWithReconnect.send(UserCreatedEvent, event);
1851
+ }
1852
+ }
1853
+ throw new Error('Still down');
1854
+ }),
1855
+ onConnected: (cb) => {
1856
+ connectedCallback = cb;
1857
+ return () => { };
1858
+ },
1859
+ };
1860
+ fanoutWithReconnect = new FanoutEngine({
1861
+ transport: flakyTransport,
1862
+ schema,
1863
+ hooks: hooksInstance,
1864
+ topology: testTopology,
1865
+ defaultQueue: 'events',
1866
+ maxRetryBufferSize: 13,
1867
+ });
1868
+ // 11 sends buffered under the cap of 13 — nothing dropped yet.
1869
+ for (let i = 0; i < 11; i++) {
1870
+ await fanoutWithReconnect.send(UserCreatedEvent, event);
1871
+ }
1872
+ expect(flakyTransport.send).toHaveBeenCalledTimes(11);
1873
+ // Flush: 10 consecutive failures trip the breaker, leaving the 11th
1874
+ // item untried. The 4 concurrently-buffered items (injected mid-pass)
1875
+ // fill the buffer to its cap of 13 by the time the streak trips, so
1876
+ // both the 10th flush item (existing buffer-full path) and the
1877
+ // untried 11th item (rebufferUntried's buffer-full path) get dropped.
1878
+ phase = 'flushing';
1879
+ connectedCallback?.();
1880
+ await waitForFlush(fanoutWithReconnect);
1881
+ // 11 initial + 10 flush-loop attempts + 4 injected.
1882
+ expect(flakyTransport.send).toHaveBeenCalledTimes(11 + 10 + 4);
1883
+ expect(onEnqueueError).toHaveBeenCalledTimes(2);
1884
+ });
1885
+ it('should stop the periodic retry timer after dispose()', async () => {
1886
+ const subscriber = createSubscriber({
1887
+ name: 'handle-user',
1888
+ description: 'Handles user events',
1889
+ callback: async () => { },
1890
+ });
1891
+ schema.register(UserCreatedEvent, [subscriber]);
1892
+ const failingTransport = {
1893
+ ...transport,
1894
+ send: mock(async () => {
1895
+ throw new Error('Connection lost');
1896
+ }),
1897
+ };
1898
+ const fanoutWithInterval = new FanoutEngine({
1899
+ transport: failingTransport,
1900
+ schema,
1901
+ hooks,
1902
+ topology: testTopology,
1903
+ defaultQueue: 'events',
1904
+ retryIntervalMs: 20,
1905
+ });
1906
+ const event = new UserCreatedEvent({
1907
+ userId: '123',
1908
+ email: 'test@example.com',
1909
+ });
1910
+ await fanoutWithInterval.send(UserCreatedEvent, event);
1911
+ expect(failingTransport.send).toHaveBeenCalledTimes(1);
1912
+ fanoutWithInterval.dispose();
1913
+ await new Promise((resolve) => setTimeout(resolve, 50));
1914
+ // No further flush attempts after dispose, despite the buffered message.
1915
+ expect(failingTransport.send).toHaveBeenCalledTimes(1);
1916
+ });
1917
+ it('should drop a message after exceeding maxRetryAttempts instead of retrying forever', async () => {
1918
+ const subscriber = createSubscriber({
1919
+ name: 'handle-user',
1920
+ description: 'Handles user events',
1921
+ callback: async () => { },
1922
+ });
1923
+ schema.register(UserCreatedEvent, [subscriber]);
1924
+ let connectedCallback;
1925
+ const failingTransport = {
1926
+ ...transport,
1927
+ send: mock(async () => {
1928
+ throw new Error('Still down');
1929
+ }),
1930
+ onConnected: (cb) => {
1931
+ connectedCallback = cb;
1932
+ return () => { };
1933
+ },
1934
+ };
1935
+ const onEnqueueError = mock(async () => { });
1936
+ const hooksInstance = new SafeHooks({
1937
+ onEnqueueError,
1938
+ });
1939
+ const fanoutWithMaxAttempts = new FanoutEngine({
1940
+ transport: failingTransport,
1941
+ schema,
1942
+ hooks: hooksInstance,
1943
+ topology: testTopology,
1944
+ defaultQueue: 'events',
1945
+ maxRetryAttempts: 2,
1946
+ });
1947
+ const event = new UserCreatedEvent({
1948
+ userId: '123',
1949
+ email: 'test@example.com',
1950
+ });
1951
+ // Initial send fails and buffers (attempts: 0).
1952
+ await fanoutWithMaxAttempts.send(UserCreatedEvent, event);
1953
+ expect(onEnqueueError).not.toHaveBeenCalled();
1954
+ // First flush fails: attempts becomes 1, still under the cap, re-buffered.
1955
+ connectedCallback?.();
1956
+ await waitForFlush(fanoutWithMaxAttempts);
1957
+ expect(onEnqueueError).not.toHaveBeenCalled();
1958
+ // Second flush fails: attempts becomes 2, meets the cap, dropped for good.
1959
+ connectedCallback?.();
1960
+ await waitForFlush(fanoutWithMaxAttempts);
1961
+ expect(onEnqueueError).toHaveBeenCalledTimes(1);
1962
+ // A further reconnect must not retry it again — it's gone.
1963
+ connectedCallback?.();
1964
+ await waitForFlush(fanoutWithMaxAttempts);
1965
+ expect(onEnqueueError).toHaveBeenCalledTimes(1);
1966
+ // 1 initial + 2 flush attempts = 3 total send() calls; no 4th.
1967
+ expect(failingTransport.send).toHaveBeenCalledTimes(3);
1968
+ });
1969
+ });
1072
1970
  });