@zdavison/matador 3.0.2 → 3.0.5

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 CHANGED
@@ -252,26 +252,75 @@ async function runSendTestEvent(
252
252
  await dispatchEvent(config, config.testEvent, options);
253
253
  }
254
254
 
255
- async function dispatchEvent(
256
- config: ConfigExport,
257
- eventSpec: EventExport,
258
- options: { dryRun: boolean; timeout: number; verbose: boolean },
259
- ): Promise<void> {
260
- // Validate event exists in config
261
- const schemaEntry = config.schema[eventSpec.eventKey];
255
+ function resolveSchemaEntry(config: ConfigExport, eventKey: string) {
256
+ const schemaEntry = config.schema[eventKey];
262
257
  if (!schemaEntry) {
263
- logError(`Event "${eventSpec.eventKey}" not found in config`);
258
+ logError(`Event "${eventKey}" not found in config`);
264
259
  logInfo(`Available events: ${Object.keys(config.schema).join(', ')}`);
265
260
  process.exit(1);
266
261
  }
267
-
268
- // Extract EventClass and subscribers from schema entry
269
262
  const EventClass = isSchemaEntryTuple(schemaEntry)
270
263
  ? schemaEntry[0]
271
264
  : schemaEntry.eventClass;
272
265
  const subscribers = isSchemaEntryTuple(schemaEntry)
273
266
  ? schemaEntry[1]
274
267
  : schemaEntry.subscribers;
268
+ return { EventClass, subscribers };
269
+ }
270
+
271
+ function logEventDetails(eventSpec: EventExport): void {
272
+ logInfo(`Data: ${JSON.stringify(eventSpec.data, null, 2)}`);
273
+ if (eventSpec.before) {
274
+ logInfo(`Before: ${JSON.stringify(eventSpec.before, null, 2)}`);
275
+ }
276
+ if (eventSpec.options) {
277
+ logInfo(`Options: ${JSON.stringify(eventSpec.options, null, 2)}`);
278
+ }
279
+ }
280
+
281
+ function logSendResult(result: {
282
+ eventKey: string;
283
+ subscribersSent: number;
284
+ subscribersSkipped: number;
285
+ errors: ReadonlyArray<{ subscriberName: string; error: Error }>;
286
+ }): void {
287
+ logSection('Send Result');
288
+ logInfo(`Event key: ${result.eventKey}`);
289
+ logInfo(`Subscribers sent: ${result.subscribersSent}`);
290
+ logInfo(`Subscribers skipped: ${result.subscribersSkipped}`);
291
+
292
+ if (result.errors.length > 0) {
293
+ logWarning(`Dispatch errors: ${result.errors.length}`);
294
+ for (const err of result.errors) {
295
+ logError(` [${err.subscriberName}] ${err.error.message}`);
296
+ }
297
+ }
298
+ }
299
+
300
+ function buildCliHooks(config: ConfigExport, verbose: boolean): MatadorHooks {
301
+ return {
302
+ logger: verbose ? consoleLogger : undefined,
303
+ onWorkerSuccess: (ctx) => {
304
+ logSuccess(`[${ctx.subscriber.name}] processed in ${ctx.durationMs}ms`);
305
+ },
306
+ onWorkerError: (ctx) => {
307
+ logError(
308
+ `[${ctx.subscriber.name}] failed after ${ctx.durationMs}ms: ${ctx.error.message}`,
309
+ );
310
+ },
311
+ ...config.hooks,
312
+ };
313
+ }
314
+
315
+ async function dispatchEvent(
316
+ config: ConfigExport,
317
+ eventSpec: EventExport,
318
+ options: { dryRun: boolean; timeout: number; verbose: boolean },
319
+ ): Promise<void> {
320
+ const { EventClass, subscribers } = resolveSchemaEntry(
321
+ config,
322
+ eventSpec.eventKey,
323
+ );
275
324
 
276
325
  if (!subscribers || subscribers.length === 0) {
277
326
  logWarning(`No subscribers registered for event "${eventSpec.eventKey}"`);
@@ -288,7 +337,6 @@ async function dispatchEvent(
288
337
 
289
338
  logSection('Dispatching Event');
290
339
 
291
- // Create topology
292
340
  const topology =
293
341
  config.topology ??
294
342
  TopologyBuilder.create()
@@ -297,24 +345,9 @@ async function dispatchEvent(
297
345
  .withoutDeadLetter()
298
346
  .build();
299
347
 
300
- // Create transport
301
348
  const transport = new LocalTransport();
349
+ const hooks = buildCliHooks(config, options.verbose);
302
350
 
303
- // Create hooks for logging
304
- const hooks: MatadorHooks = {
305
- logger: options.verbose ? consoleLogger : undefined,
306
- onWorkerSuccess: (ctx) => {
307
- logSuccess(`[${ctx.subscriber.name}] processed in ${ctx.durationMs}ms`);
308
- },
309
- onWorkerError: (ctx) => {
310
- logError(
311
- `[${ctx.subscriber.name}] failed after ${ctx.durationMs}ms: ${ctx.error.message}`,
312
- );
313
- },
314
- ...config.hooks,
315
- };
316
-
317
- // Create Matador instance with schema and hooks
318
351
  const matador = new Matador(
319
352
  {
320
353
  transport,
@@ -329,35 +362,16 @@ async function dispatchEvent(
329
362
  await matador.start();
330
363
  logSuccess('[Matador] Started.');
331
364
 
332
- // Create and dispatch the event
333
365
  const event = new EventClass(eventSpec.data, eventSpec.before);
334
366
  logInfo(`Dispatching: ${eventSpec.eventKey}`);
335
367
 
336
368
  if (options.verbose) {
337
- logInfo(`Data: ${JSON.stringify(eventSpec.data, null, 2)}`);
338
- if (eventSpec.before) {
339
- logInfo(`Before: ${JSON.stringify(eventSpec.before, null, 2)}`);
340
- }
341
- if (eventSpec.options) {
342
- logInfo(`Options: ${JSON.stringify(eventSpec.options, null, 2)}`);
343
- }
369
+ logEventDetails(eventSpec);
344
370
  }
345
371
 
346
372
  const result = await matador.send(event, eventSpec.options);
373
+ logSendResult(result);
347
374
 
348
- logSection('Send Result');
349
- logInfo(`Event key: ${result.eventKey}`);
350
- logInfo(`Subscribers sent: ${result.subscribersSent}`);
351
- logInfo(`Subscribers skipped: ${result.subscribersSkipped}`);
352
-
353
- if (result.errors.length > 0) {
354
- logWarning(`Dispatch errors: ${result.errors.length}`);
355
- for (const err of result.errors) {
356
- logError(` [${err.subscriberName}] ${err.error.message}`);
357
- }
358
- }
359
-
360
- // Wait for processing
361
375
  logSection('Processing');
362
376
  const idle = await matador.waitForIdle(options.timeout);
363
377
 
@@ -413,8 +427,8 @@ async function main(): Promise<void> {
413
427
  process.exit(1);
414
428
  }
415
429
 
416
- const [configPath, eventPath] = positionals;
417
- await runSend(configPath!, eventPath!, {
430
+ const [configPath, eventPath] = positionals as [string, string];
431
+ await runSend(configPath, eventPath, {
418
432
  dryRun: values['dry-run'] ?? false,
419
433
  timeout: Number.parseInt(values.timeout ?? '5000', 10),
420
434
  verbose: values.verbose ?? false,
@@ -436,8 +450,8 @@ async function main(): Promise<void> {
436
450
  process.exit(1);
437
451
  }
438
452
 
439
- const [configPath] = positionals;
440
- await runSendTestEvent(configPath!, {
453
+ const [configPath] = positionals as [string];
454
+ await runSendTestEvent(configPath, {
441
455
  dryRun: values['dry-run'] ?? false,
442
456
  timeout: Number.parseInt(values.timeout ?? '5000', 10),
443
457
  verbose: values.verbose ?? false,
@@ -119,9 +119,13 @@ describe('ResumableContext', () => {
119
119
  });
120
120
  await context.io('step-1', () => 'result-1');
121
121
  let checkpoint = await store.get(envelope.id);
122
+ if (!checkpoint)
123
+ throw new Error('expected checkpoint after step-1');
122
124
  expect(Object.keys(checkpoint.completedSteps)).toHaveLength(1);
123
125
  await context.io('step-2', () => 'result-2');
124
126
  checkpoint = await store.get(envelope.id);
127
+ if (!checkpoint)
128
+ throw new Error('expected checkpoint after step-2');
125
129
  expect(Object.keys(checkpoint.completedSteps)).toHaveLength(2);
126
130
  });
127
131
  it('should support various JSON-serializable return types', async () => {
@@ -323,6 +323,37 @@ const V1_MESSAGE_FIXTURES = [
323
323
  // },
324
324
  // },
325
325
  ];
326
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
327
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/;
328
+ function assertV1Id(envelope, expectedId) {
329
+ if (expectedId !== undefined) {
330
+ expect(envelope.id).toBe(expectedId);
331
+ }
332
+ else {
333
+ expect(envelope.id).toMatch(UUID_PATTERN);
334
+ }
335
+ }
336
+ function assertV1Metadata(metadata, expected) {
337
+ if (expected !== undefined) {
338
+ expect(metadata).toEqual(expected);
339
+ }
340
+ else {
341
+ expect(metadata === undefined || Object.keys(metadata).length === 0).toBe(true);
342
+ }
343
+ }
344
+ function assertV1Translation(envelope, expected) {
345
+ expect(envelope.docket.eventKey).toBe(expected.eventKey);
346
+ expect(envelope.docket.targetSubscriber).toBe(expected.targetSubscriber);
347
+ expect(envelope.data).toEqual(expected.data);
348
+ assertV1Id(envelope, expected.id);
349
+ assertV1Metadata(envelope.docket.metadata, expected.metadata);
350
+ if (expected.correlationId !== undefined) {
351
+ expect(envelope.docket.correlationId).toBe(expected.correlationId);
352
+ }
353
+ expect(envelope.docket.attempts).toBe(expected.attempts ?? 1);
354
+ expect(envelope.docket.importance).toBe(expected.importance ?? 'should-investigate');
355
+ expect(envelope.docket.createdAt).toMatch(ISO_DATE_PATTERN);
356
+ }
326
357
  describe('RabbitMQCodec', () => {
327
358
  const codec = new RabbitMQCodec();
328
359
  describe('v1 to v2 translation', () => {
@@ -331,45 +362,7 @@ describe('RabbitMQCodec', () => {
331
362
  const body = new TextEncoder().encode(JSON.stringify(fixture.body));
332
363
  const headers = fixture.headers ?? {};
333
364
  const envelope = codec.decode(body, headers);
334
- // Check required fields
335
- expect(envelope.docket.eventKey).toBe(fixture.expected.eventKey);
336
- expect(envelope.docket.targetSubscriber).toBe(fixture.expected.targetSubscriber);
337
- expect(envelope.data).toEqual(fixture.expected.data);
338
- // Check optional ID (may be generated if not specified)
339
- if (fixture.expected.id !== undefined) {
340
- expect(envelope.id).toBe(fixture.expected.id);
341
- }
342
- else {
343
- expect(envelope.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
344
- }
345
- // Check metadata
346
- if (fixture.expected.metadata !== undefined) {
347
- expect(envelope.docket.metadata).toEqual(fixture.expected.metadata);
348
- }
349
- else {
350
- expect(envelope.docket.metadata === undefined ||
351
- Object.keys(envelope.docket.metadata).length === 0).toBe(true);
352
- }
353
- // Check correlation ID
354
- if (fixture.expected.correlationId !== undefined) {
355
- expect(envelope.docket.correlationId).toBe(fixture.expected.correlationId);
356
- }
357
- // Check attempts (defaults to 1)
358
- if (fixture.expected.attempts !== undefined) {
359
- expect(envelope.docket.attempts).toBe(fixture.expected.attempts);
360
- }
361
- else {
362
- expect(envelope.docket.attempts).toBe(1);
363
- }
364
- // Check importance (defaults to 'should-investigate')
365
- if (fixture.expected.importance !== undefined) {
366
- expect(envelope.docket.importance).toBe(fixture.expected.importance);
367
- }
368
- else {
369
- expect(envelope.docket.importance).toBe('should-investigate');
370
- }
371
- // Check createdAt is a valid ISO string
372
- expect(envelope.docket.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/);
365
+ assertV1Translation(envelope, fixture.expected);
373
366
  });
374
367
  }
375
368
  });
@@ -385,8 +378,10 @@ describe('RabbitMQCodec', () => {
385
378
  const before = Date.now();
386
379
  const envelope = codec.decode(encoded, {});
387
380
  const after = Date.now();
388
- expect(envelope.docket.scheduledFor).toBeDefined();
389
- const scheduledTime = new Date(envelope.docket.scheduledFor).getTime();
381
+ const { scheduledFor } = envelope.docket;
382
+ if (!scheduledFor)
383
+ throw new Error('expected scheduledFor to be set');
384
+ const scheduledTime = new Date(scheduledFor).getTime();
390
385
  expect(scheduledTime).toBeGreaterThanOrEqual(before + 5000);
391
386
  expect(scheduledTime).toBeLessThanOrEqual(after + 5000);
392
387
  });
@@ -151,6 +151,8 @@ describe('FanoutEngine', () => {
151
151
  await fanout.send(UserCreatedEvent, event);
152
152
  const sendCall = transport.send.mock
153
153
  .calls[0];
154
+ if (!sendCall)
155
+ throw new Error('expected transport.send to be called');
154
156
  const envelope = sendCall[1];
155
157
  expect(envelope.docket.eventKey).toBe('user.created');
156
158
  expect(envelope.docket.eventDescription).toBe('Fired when a new user is created');
@@ -176,6 +178,8 @@ describe('FanoutEngine', () => {
176
178
  await fanout.send(OrderPlacedEvent, event);
177
179
  const sendCall = transport.send.mock
178
180
  .calls[0];
181
+ if (!sendCall)
182
+ throw new Error('expected transport.send to be called');
179
183
  const envelope = sendCall[1];
180
184
  expect(envelope.docket.eventKey).toBe('order.placed');
181
185
  expect(envelope.docket.eventDescription).toBe('Fired when an order is placed');
@@ -819,7 +823,10 @@ describe('FanoutEngine', () => {
819
823
  transport: 'mock',
820
824
  });
821
825
  const calls = onEnqueueSuccess.mock.calls;
822
- const callArgs = calls[0][0];
826
+ const firstCall = calls[0];
827
+ if (!firstCall)
828
+ throw new Error('expected onEnqueueSuccess to be called');
829
+ const callArgs = firstCall[0];
823
830
  expect(callArgs.envelope.docket.eventKey).toBe('user.created');
824
831
  expect(callArgs.envelope.docket.targetSubscriber).toBe('handle-user');
825
832
  });
@@ -860,7 +867,10 @@ describe('FanoutEngine', () => {
860
867
  transport: 'mock',
861
868
  });
862
869
  const calls = onEnqueueError.mock.calls;
863
- const callArgs = calls[0][0];
870
+ const firstCall = calls[0];
871
+ if (!firstCall)
872
+ throw new Error('expected onEnqueueError to be called');
873
+ const callArgs = firstCall[0];
864
874
  expect(callArgs.envelope.docket.eventKey).toBe('user.created');
865
875
  expect(callArgs.error).toBeInstanceOf(TransportSendError);
866
876
  });
package/dist/index.cjs CHANGED
@@ -471,50 +471,11 @@ var TopologyBuilder = class _TopologyBuilder {
471
471
  * Validates the topology configuration.
472
472
  */
473
473
  validate() {
474
- const issues = [];
475
- if (!this.namespace || this.namespace.trim() === "") {
476
- issues.push("Namespace is required");
477
- } else if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(this.namespace)) {
478
- issues.push(
479
- "Namespace must start with a letter and contain only alphanumeric characters, underscores, and hyphens"
480
- );
481
- }
482
- if (this.queues.length === 0) {
483
- issues.push("At least one queue is required");
484
- }
485
- const queueNames = /* @__PURE__ */ new Set();
486
- for (const queue of this.queues) {
487
- if (!queue.name || queue.name.trim() === "") {
488
- issues.push("Queue name cannot be empty");
489
- } else if (!queue.exact && !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(queue.name)) {
490
- issues.push(
491
- `Queue name "${queue.name}" must start with a letter and contain only alphanumeric characters, underscores, and hyphens`
492
- );
493
- } else if (queueNames.has(queue.name)) {
494
- issues.push(`Duplicate queue name: "${queue.name}"`);
495
- } else {
496
- queueNames.add(queue.name);
497
- }
498
- if (queue.concurrency !== void 0 && queue.concurrency < 1) {
499
- issues.push(`Queue "${queue.name}" concurrency must be at least 1`);
500
- }
501
- if (queue.consumerTimeout !== void 0 && queue.consumerTimeout < 0) {
502
- issues.push(
503
- `Queue "${queue.name}" consumer timeout must be non-negative`
504
- );
505
- }
506
- }
507
- if (this.retry.enabled) {
508
- if (this.retry.defaultDelayMs < 0) {
509
- issues.push("Default retry delay must be non-negative");
510
- }
511
- if (this.retry.maxDelayMs < this.retry.defaultDelayMs) {
512
- issues.push(
513
- "Max retry delay must be greater than or equal to default delay"
514
- );
515
- }
516
- }
517
- return issues;
474
+ return [
475
+ ...validateNamespace(this.namespace),
476
+ ...validateQueues(this.queues),
477
+ ...validateRetry(this.retry)
478
+ ];
518
479
  }
519
480
  /**
520
481
  * Builds the topology configuration.
@@ -536,6 +497,68 @@ var TopologyBuilder = class _TopologyBuilder {
536
497
  };
537
498
  }
538
499
  };
500
+ var IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
501
+ function validateNamespace(namespace) {
502
+ if (!namespace || namespace.trim() === "") {
503
+ return ["Namespace is required"];
504
+ }
505
+ if (!IDENTIFIER_PATTERN.test(namespace)) {
506
+ return [
507
+ "Namespace must start with a letter and contain only alphanumeric characters, underscores, and hyphens"
508
+ ];
509
+ }
510
+ return [];
511
+ }
512
+ function validateQueueName(queue, seen) {
513
+ if (!queue.name || queue.name.trim() === "") {
514
+ return ["Queue name cannot be empty"];
515
+ }
516
+ if (!queue.exact && !IDENTIFIER_PATTERN.test(queue.name)) {
517
+ return [
518
+ `Queue name "${queue.name}" must start with a letter and contain only alphanumeric characters, underscores, and hyphens`
519
+ ];
520
+ }
521
+ if (seen.has(queue.name)) {
522
+ return [`Duplicate queue name: "${queue.name}"`];
523
+ }
524
+ seen.add(queue.name);
525
+ return [];
526
+ }
527
+ function validateQueueLimits(queue) {
528
+ const issues = [];
529
+ if (queue.concurrency !== void 0 && queue.concurrency < 1) {
530
+ issues.push(`Queue "${queue.name}" concurrency must be at least 1`);
531
+ }
532
+ if (queue.consumerTimeout !== void 0 && queue.consumerTimeout < 0) {
533
+ issues.push(`Queue "${queue.name}" consumer timeout must be non-negative`);
534
+ }
535
+ return issues;
536
+ }
537
+ function validateQueues(queues) {
538
+ const issues = [];
539
+ if (queues.length === 0) {
540
+ issues.push("At least one queue is required");
541
+ }
542
+ const seen = /* @__PURE__ */ new Set();
543
+ for (const queue of queues) {
544
+ issues.push(...validateQueueName(queue, seen));
545
+ issues.push(...validateQueueLimits(queue));
546
+ }
547
+ return issues;
548
+ }
549
+ function validateRetry(retry) {
550
+ if (!retry.enabled) return [];
551
+ const issues = [];
552
+ if (retry.defaultDelayMs < 0) {
553
+ issues.push("Default retry delay must be non-negative");
554
+ }
555
+ if (retry.maxDelayMs < retry.defaultDelayMs) {
556
+ issues.push(
557
+ "Max retry delay must be greater than or equal to default delay"
558
+ );
559
+ }
560
+ return issues;
561
+ }
539
562
 
540
563
  // src/types/common.ts
541
564
  function validResult() {
@@ -2645,6 +2668,7 @@ var MultiTransport = class {
2645
2668
  const errors = [];
2646
2669
  for (let i = 0; i < transportOrder.length; i++) {
2647
2670
  const transport = transportOrder[i];
2671
+ if (!transport) continue;
2648
2672
  try {
2649
2673
  return await transport.send(queue, envelope, options);
2650
2674
  } catch (error) {
@@ -3096,31 +3120,30 @@ var RabbitMQTransport = class {
3096
3120
  });
3097
3121
  });
3098
3122
  }
3123
+ buildWorkQueueOptions(topology, queueDef) {
3124
+ const queueOptions = {
3125
+ durable: true,
3126
+ arguments: {}
3127
+ };
3128
+ if (this.config.quorumQueues && !queueDef.exact) {
3129
+ queueOptions.arguments["x-queue-type"] = "quorum";
3130
+ }
3131
+ if (topology.deadLetter.unhandled.enabled || topology.deadLetter.undeliverable.enabled) {
3132
+ queueOptions.arguments["x-dead-letter-exchange"] = this.getDLXExchangeName(topology.namespace);
3133
+ }
3134
+ if (queueDef.priorities) {
3135
+ queueOptions.arguments["x-max-priority"] = 10;
3136
+ }
3137
+ if (queueDef.consumerTimeout) {
3138
+ queueOptions.arguments["x-consumer-timeout"] = queueDef.consumerTimeout;
3139
+ }
3140
+ return queueOptions;
3141
+ }
3099
3142
  async assertWorkQueue(channel, topology, queueDef) {
3100
3143
  const queueName = queueDef.exact ? queueDef.name : `${topology.namespace}.${queueDef.name}`;
3101
3144
  const rabbitmqOptions = queueDef.transport?.rabbitmq?.options;
3102
- if (rabbitmqOptions) {
3103
- await channel.assertQueue(queueName, rabbitmqOptions);
3104
- } else {
3105
- const queueOptions = {
3106
- durable: true,
3107
- arguments: {}
3108
- };
3109
- if (this.config.quorumQueues && !queueDef.exact) {
3110
- queueOptions.arguments["x-queue-type"] = "quorum";
3111
- }
3112
- const dlxExchange = this.getDLXExchangeName(topology.namespace);
3113
- if (topology.deadLetter.unhandled.enabled || topology.deadLetter.undeliverable.enabled) {
3114
- queueOptions.arguments["x-dead-letter-exchange"] = dlxExchange;
3115
- }
3116
- if (queueDef.priorities) {
3117
- queueOptions.arguments["x-max-priority"] = 10;
3118
- }
3119
- if (queueDef.consumerTimeout) {
3120
- queueOptions.arguments["x-consumer-timeout"] = queueDef.consumerTimeout;
3121
- }
3122
- await channel.assertQueue(queueName, queueOptions);
3123
- }
3145
+ const queueOptions = rabbitmqOptions ?? this.buildWorkQueueOptions(topology, queueDef);
3146
+ await channel.assertQueue(queueName, queueOptions);
3124
3147
  const mainExchange = this.getMainExchangeName(topology.namespace);
3125
3148
  await channel.bindQueue(queueName, mainExchange, queueName);
3126
3149
  if (this.delayedExchangeAvailable) {