@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.
Files changed (256) hide show
  1. package/cli.ts +453 -0
  2. package/dist/checkpoint/context.d.ts +59 -0
  3. package/dist/checkpoint/context.d.ts.map +1 -0
  4. package/dist/checkpoint/context.js +140 -0
  5. package/dist/checkpoint/context.test.d.ts +2 -0
  6. package/dist/checkpoint/context.test.d.ts.map +1 -0
  7. package/dist/checkpoint/context.test.js +424 -0
  8. package/dist/checkpoint/index.d.ts +7 -0
  9. package/dist/checkpoint/index.d.ts.map +1 -0
  10. package/dist/checkpoint/index.js +6 -0
  11. package/dist/checkpoint/stores/memory.d.ts +29 -0
  12. package/dist/checkpoint/stores/memory.d.ts.map +1 -0
  13. package/dist/checkpoint/stores/memory.js +39 -0
  14. package/dist/checkpoint/stores/noop.d.ts +14 -0
  15. package/dist/checkpoint/stores/noop.d.ts.map +1 -0
  16. package/dist/checkpoint/stores/noop.js +18 -0
  17. package/dist/checkpoint/stores/stores.test.d.ts +2 -0
  18. package/dist/checkpoint/stores/stores.test.d.ts.map +1 -0
  19. package/dist/checkpoint/stores/stores.test.js +146 -0
  20. package/dist/checkpoint/types.d.ts +119 -0
  21. package/dist/checkpoint/types.d.ts.map +1 -0
  22. package/dist/checkpoint/types.js +1 -0
  23. package/dist/codec/codec.d.ts +29 -0
  24. package/dist/codec/codec.d.ts.map +1 -0
  25. package/dist/codec/codec.js +15 -0
  26. package/dist/codec/header-aware-codec.d.ts +36 -0
  27. package/dist/codec/header-aware-codec.d.ts.map +1 -0
  28. package/dist/codec/header-aware-codec.js +1 -0
  29. package/dist/codec/index.d.ts +6 -0
  30. package/dist/codec/index.d.ts.map +1 -0
  31. package/dist/codec/index.js +3 -0
  32. package/dist/codec/json-codec.d.ts +13 -0
  33. package/dist/codec/json-codec.d.ts.map +1 -0
  34. package/dist/codec/json-codec.js +64 -0
  35. package/dist/codec/rabbitmq-codec.d.ts +28 -0
  36. package/dist/codec/rabbitmq-codec.d.ts.map +1 -0
  37. package/dist/codec/rabbitmq-codec.js +242 -0
  38. package/dist/codec/rabbitmq-codec.test.d.ts +2 -0
  39. package/dist/codec/rabbitmq-codec.test.d.ts.map +1 -0
  40. package/dist/codec/rabbitmq-codec.test.js +433 -0
  41. package/dist/core/fanout.d.ts +59 -0
  42. package/dist/core/fanout.d.ts.map +1 -0
  43. package/dist/core/fanout.js +121 -0
  44. package/dist/core/fanout.test.d.ts +2 -0
  45. package/dist/core/fanout.test.d.ts.map +1 -0
  46. package/dist/core/fanout.test.js +1055 -0
  47. package/dist/core/index.d.ts +7 -0
  48. package/dist/core/index.d.ts.map +1 -0
  49. package/dist/core/index.js +3 -0
  50. package/dist/core/matador.d.ts +133 -0
  51. package/dist/core/matador.d.ts.map +1 -0
  52. package/dist/core/matador.js +222 -0
  53. package/dist/core/matador.test.d.ts +2 -0
  54. package/dist/core/matador.test.d.ts.map +1 -0
  55. package/dist/core/matador.test.js +446 -0
  56. package/dist/core/shutdown.d.ts +78 -0
  57. package/dist/core/shutdown.d.ts.map +1 -0
  58. package/dist/core/shutdown.js +111 -0
  59. package/dist/core/shutdown.test.d.ts +2 -0
  60. package/dist/core/shutdown.test.d.ts.map +1 -0
  61. package/dist/core/shutdown.test.js +524 -0
  62. package/dist/errors/checkpoint-errors.d.ts +30 -0
  63. package/dist/errors/checkpoint-errors.d.ts.map +1 -0
  64. package/dist/errors/checkpoint-errors.js +49 -0
  65. package/dist/errors/has-description.d.ts +18 -0
  66. package/dist/errors/has-description.d.ts.map +1 -0
  67. package/dist/errors/has-description.js +9 -0
  68. package/dist/errors/index.d.ts +6 -0
  69. package/dist/errors/index.d.ts.map +1 -0
  70. package/dist/errors/index.js +22 -0
  71. package/dist/errors/matador-errors.d.ts +183 -0
  72. package/dist/errors/matador-errors.d.ts.map +1 -0
  73. package/dist/errors/matador-errors.js +376 -0
  74. package/dist/errors/retry-errors.d.ts +67 -0
  75. package/dist/errors/retry-errors.d.ts.map +1 -0
  76. package/dist/errors/retry-errors.js +108 -0
  77. package/dist/hooks/index.d.ts +4 -0
  78. package/dist/hooks/index.d.ts.map +1 -0
  79. package/dist/hooks/index.js +2 -0
  80. package/dist/hooks/safe-hooks.d.ts +34 -0
  81. package/dist/hooks/safe-hooks.d.ts.map +1 -0
  82. package/dist/hooks/safe-hooks.js +135 -0
  83. package/dist/hooks/types.d.ts +189 -0
  84. package/dist/hooks/types.d.ts.map +1 -0
  85. package/dist/hooks/types.js +9 -0
  86. package/dist/index.cjs +3156 -0
  87. package/dist/index.cjs.map +1 -0
  88. package/dist/index.d.cts +23 -0
  89. package/dist/index.d.ts +23 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +31 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/pipeline/index.d.ts +3 -0
  94. package/dist/pipeline/index.d.ts.map +1 -0
  95. package/dist/pipeline/index.js +1 -0
  96. package/dist/pipeline/pipeline.d.ts +60 -0
  97. package/dist/pipeline/pipeline.d.ts.map +1 -0
  98. package/dist/pipeline/pipeline.js +261 -0
  99. package/dist/pipeline/pipeline.test.d.ts +2 -0
  100. package/dist/pipeline/pipeline.test.d.ts.map +1 -0
  101. package/dist/pipeline/pipeline.test.js +1065 -0
  102. package/dist/retry/index.d.ts +4 -0
  103. package/dist/retry/index.d.ts.map +1 -0
  104. package/dist/retry/index.js +1 -0
  105. package/dist/retry/policy.d.ts +43 -0
  106. package/dist/retry/policy.d.ts.map +1 -0
  107. package/dist/retry/policy.js +1 -0
  108. package/dist/retry/standard-policy.d.ts +44 -0
  109. package/dist/retry/standard-policy.d.ts.map +1 -0
  110. package/dist/retry/standard-policy.js +102 -0
  111. package/dist/retry/standard-policy.test.d.ts +2 -0
  112. package/dist/retry/standard-policy.test.d.ts.map +1 -0
  113. package/dist/retry/standard-policy.test.js +190 -0
  114. package/dist/schema/index.d.ts +4 -0
  115. package/dist/schema/index.d.ts.map +1 -0
  116. package/dist/schema/index.js +2 -0
  117. package/dist/schema/registry.d.ts +63 -0
  118. package/dist/schema/registry.d.ts.map +1 -0
  119. package/dist/schema/registry.js +171 -0
  120. package/dist/schema/registry.test.d.ts +2 -0
  121. package/dist/schema/registry.test.d.ts.map +1 -0
  122. package/dist/schema/registry.test.js +278 -0
  123. package/dist/schema/types.d.ts +158 -0
  124. package/dist/schema/types.d.ts.map +1 -0
  125. package/dist/schema/types.js +74 -0
  126. package/dist/schema/types.test.d.ts +2 -0
  127. package/dist/schema/types.test.d.ts.map +1 -0
  128. package/dist/schema/types.test.js +243 -0
  129. package/dist/topology/builder.d.ts +66 -0
  130. package/dist/topology/builder.d.ts.map +1 -0
  131. package/dist/topology/builder.js +156 -0
  132. package/dist/topology/builder.test.d.ts +2 -0
  133. package/dist/topology/builder.test.d.ts.map +1 -0
  134. package/dist/topology/builder.test.js +222 -0
  135. package/dist/topology/index.d.ts +5 -0
  136. package/dist/topology/index.d.ts.map +1 -0
  137. package/dist/topology/index.js +2 -0
  138. package/dist/topology/types.d.ts +76 -0
  139. package/dist/topology/types.d.ts.map +1 -0
  140. package/dist/topology/types.js +18 -0
  141. package/dist/transport/capabilities.d.ts +66 -0
  142. package/dist/transport/capabilities.d.ts.map +1 -0
  143. package/dist/transport/capabilities.js +18 -0
  144. package/dist/transport/connection-manager.d.ts +95 -0
  145. package/dist/transport/connection-manager.d.ts.map +1 -0
  146. package/dist/transport/connection-manager.js +144 -0
  147. package/dist/transport/index.d.ts +11 -0
  148. package/dist/transport/index.d.ts.map +1 -0
  149. package/dist/transport/index.js +5 -0
  150. package/dist/transport/local/local-transport.d.ts +62 -0
  151. package/dist/transport/local/local-transport.d.ts.map +1 -0
  152. package/dist/transport/local/local-transport.js +241 -0
  153. package/dist/transport/local/local-transport.test.d.ts +2 -0
  154. package/dist/transport/local/local-transport.test.d.ts.map +1 -0
  155. package/dist/transport/local/local-transport.test.js +192 -0
  156. package/dist/transport/multi/multi-transport.d.ts +94 -0
  157. package/dist/transport/multi/multi-transport.d.ts.map +1 -0
  158. package/dist/transport/multi/multi-transport.js +184 -0
  159. package/dist/transport/multi/multi-transport.test.d.ts +2 -0
  160. package/dist/transport/multi/multi-transport.test.d.ts.map +1 -0
  161. package/dist/transport/multi/multi-transport.test.js +236 -0
  162. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts +73 -0
  163. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -0
  164. package/dist/transport/rabbitmq/rabbitmq-transport.js +518 -0
  165. package/dist/transport/transport.d.ts +152 -0
  166. package/dist/transport/transport.d.ts.map +1 -0
  167. package/dist/transport/transport.js +1 -0
  168. package/dist/types/common.d.ts +41 -0
  169. package/dist/types/common.d.ts.map +1 -0
  170. package/dist/types/common.js +12 -0
  171. package/dist/types/envelope.d.ts +82 -0
  172. package/dist/types/envelope.d.ts.map +1 -0
  173. package/dist/types/envelope.js +36 -0
  174. package/dist/types/event.d.ts +96 -0
  175. package/dist/types/event.d.ts.map +1 -0
  176. package/dist/types/event.js +26 -0
  177. package/dist/types/event.test.d.ts +2 -0
  178. package/dist/types/event.test.d.ts.map +1 -0
  179. package/dist/types/event.test.js +130 -0
  180. package/dist/types/index.d.ts +9 -0
  181. package/dist/types/index.d.ts.map +1 -0
  182. package/dist/types/index.js +4 -0
  183. package/dist/types/subscriber.d.ts +207 -0
  184. package/dist/types/subscriber.d.ts.map +1 -0
  185. package/dist/types/subscriber.js +99 -0
  186. package/examples/config.ts +126 -0
  187. package/examples/event.ts +26 -0
  188. package/examples/order-event.json +19 -0
  189. package/package.json +66 -0
  190. package/src/checkpoint/context.test.ts +510 -0
  191. package/src/checkpoint/context.ts +213 -0
  192. package/src/checkpoint/index.ts +30 -0
  193. package/src/checkpoint/stores/memory.ts +47 -0
  194. package/src/checkpoint/stores/noop.ts +22 -0
  195. package/src/checkpoint/stores/stores.test.ts +177 -0
  196. package/src/checkpoint/types.ts +147 -0
  197. package/src/codec/codec.ts +42 -0
  198. package/src/codec/header-aware-codec.ts +41 -0
  199. package/src/codec/index.ts +11 -0
  200. package/src/codec/json-codec.ts +69 -0
  201. package/src/codec/rabbitmq-codec.test.ts +516 -0
  202. package/src/codec/rabbitmq-codec.ts +336 -0
  203. package/src/core/fanout.test.ts +1351 -0
  204. package/src/core/fanout.ts +184 -0
  205. package/src/core/index.ts +12 -0
  206. package/src/core/matador.test.ts +575 -0
  207. package/src/core/matador.ts +344 -0
  208. package/src/core/shutdown.test.ts +853 -0
  209. package/src/core/shutdown.ts +165 -0
  210. package/src/errors/checkpoint-errors.ts +62 -0
  211. package/src/errors/has-description.ts +25 -0
  212. package/src/errors/index.ts +57 -0
  213. package/src/errors/matador-errors.ts +477 -0
  214. package/src/errors/retry-errors.ts +134 -0
  215. package/src/hooks/index.ts +15 -0
  216. package/src/hooks/safe-hooks.ts +223 -0
  217. package/src/hooks/types.ts +248 -0
  218. package/src/index.ts +231 -0
  219. package/src/pipeline/index.ts +2 -0
  220. package/src/pipeline/pipeline.test.ts +1317 -0
  221. package/src/pipeline/pipeline.ts +371 -0
  222. package/src/retry/index.ts +4 -0
  223. package/src/retry/policy.ts +46 -0
  224. package/src/retry/standard-policy.test.ts +282 -0
  225. package/src/retry/standard-policy.ts +156 -0
  226. package/src/schema/index.ts +16 -0
  227. package/src/schema/registry.test.ts +339 -0
  228. package/src/schema/registry.ts +226 -0
  229. package/src/schema/types.test.ts +281 -0
  230. package/src/schema/types.ts +217 -0
  231. package/src/topology/builder.test.ts +275 -0
  232. package/src/topology/builder.ts +199 -0
  233. package/src/topology/index.ts +15 -0
  234. package/src/topology/types.ts +109 -0
  235. package/src/transport/capabilities.ts +88 -0
  236. package/src/transport/connection-manager.ts +218 -0
  237. package/src/transport/index.ts +42 -0
  238. package/src/transport/local/local-transport.test.ts +262 -0
  239. package/src/transport/local/local-transport.ts +327 -0
  240. package/src/transport/multi/multi-transport.test.ts +320 -0
  241. package/src/transport/multi/multi-transport.ts +294 -0
  242. package/src/transport/rabbitmq/rabbitmq-transport.ts +753 -0
  243. package/src/transport/transport.ts +200 -0
  244. package/src/types/common.ts +53 -0
  245. package/src/types/envelope.ts +152 -0
  246. package/src/types/event.test.ts +157 -0
  247. package/src/types/event.ts +118 -0
  248. package/src/types/index.ts +52 -0
  249. package/src/types/subscriber.ts +310 -0
  250. package/test/e2e/multi-transport.e2e.test.ts +236 -0
  251. package/test/e2e/rabbitmq-transport.e2e.test.ts +327 -0
  252. package/test/e2e/transport-compliance.e2e.test.ts +505 -0
  253. package/test/integration/matador.integration.test.ts +634 -0
  254. package/tsconfig.json +29 -0
  255. package/tsconfig.tsbuildinfo +1 -0
  256. package/tsup.config.ts +13 -0
@@ -0,0 +1,217 @@
1
+ import type {
2
+ AnySubscriber,
3
+ EventClass,
4
+ MatadorEvent,
5
+ Subscriber,
6
+ SubscriberStub,
7
+ } from '../types/index.js';
8
+
9
+ /**
10
+ * Type-safe schema entry for a single event type (object format).
11
+ * The type parameter enforces that subscribers match the event type.
12
+ */
13
+ export interface SchemaEntry<T extends MatadorEvent = MatadorEvent> {
14
+ /** The event class */
15
+ readonly eventClass: EventClass<T['data']>;
16
+
17
+ /** Subscribers for this event */
18
+ readonly subscribers: readonly (Subscriber<T> | SubscriberStub)[];
19
+ }
20
+
21
+ /**
22
+ * Type-safe schema entry as a tuple: [EventClass, Subscribers[]]
23
+ * The type parameter enforces that subscribers match the event type.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * const entry: SchemaEntryTuple<UserCreatedEvent> = [
28
+ * UserCreatedEvent,
29
+ * [createSubscriber<UserCreatedEvent>('handler', async (env) => {})]
30
+ * ];
31
+ * ```
32
+ */
33
+ export type SchemaEntryTuple<T extends MatadorEvent = MatadorEvent> = readonly [
34
+ eventClass: EventClass<T['data']>,
35
+ subscribers: readonly (Subscriber<T> | SubscriberStub)[],
36
+ ];
37
+
38
+ /**
39
+ * Creates a type-safe schema entry tuple.
40
+ * Ensures that subscribers are compatible with the event class at compile time.
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const schema = {
45
+ * [UserCreatedEvent.key]: bind(UserCreatedEvent, [
46
+ * createSubscriber<UserCreatedEvent>('send-email', async (env) => {
47
+ * console.log(env.data.email); // Type-safe access
48
+ * }),
49
+ * ]),
50
+ * } satisfies MatadorSchema;
51
+ * ```
52
+ */
53
+ export function bind<T extends MatadorEvent>(
54
+ eventClass: EventClass<T['data']>,
55
+ subscribers: readonly (Subscriber<T> | SubscriberStub)[],
56
+ ): SchemaEntryTuple<T> {
57
+ return [eventClass, subscribers] as const;
58
+ }
59
+
60
+ /**
61
+ * Minimal type for event class in schema storage.
62
+ * Only requires the static `key` property for routing.
63
+ * Constructor constraint is omitted to allow heterogeneous event types.
64
+ */
65
+ // biome-ignore lint/suspicious/noExplicitAny: Constructor accepts any data type for variance compatibility
66
+ export type AnyEventClass = {
67
+ readonly key: string;
68
+ new (data: any): MatadorEvent;
69
+ };
70
+
71
+ /**
72
+ * Runtime schema entry stored in MatadorSchema (object format).
73
+ * Uses AnySubscriber and AnyEventClass to allow heterogeneous event types.
74
+ * Type safety is enforced at definition time via `schemaEntry()` helper.
75
+ */
76
+ export interface RuntimeSchemaEntry {
77
+ readonly eventClass: AnyEventClass;
78
+ readonly subscribers: readonly AnySubscriber[];
79
+ }
80
+
81
+ /**
82
+ * Runtime schema tuple stored in MatadorSchema.
83
+ * Uses AnySubscriber and AnyEventClass to allow heterogeneous event types.
84
+ * Type safety is enforced at definition time via `schemaEntry()` helper.
85
+ */
86
+ export type RuntimeSchemaEntryTuple = readonly [
87
+ eventClass: AnyEventClass,
88
+ subscribers: readonly AnySubscriber[],
89
+ ];
90
+
91
+ /**
92
+ * Matador schema mapping event keys to their definitions.
93
+ * Supports both object format (SchemaEntry) and tuple format (SchemaEntryTuple).
94
+ *
95
+ * For type-safe schema definitions, use the `bind` helper function
96
+ * which ensures subscribers are compatible with their event class.
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * // Type-safe with bind helper:
101
+ * const schema = {
102
+ * [UserCreatedEvent.key]: bind(UserCreatedEvent, [emailSubscriber]),
103
+ * } satisfies MatadorSchema;
104
+ *
105
+ * // Or inline (less type-safe):
106
+ * const schema: MatadorSchema = {
107
+ * [UserCreatedEvent.key]: [UserCreatedEvent, [emailSubscriber]],
108
+ * };
109
+ * ```
110
+ */
111
+ export type MatadorSchema = {
112
+ readonly [eventKey: string]: RuntimeSchemaEntry | RuntimeSchemaEntryTuple;
113
+ };
114
+
115
+ /**
116
+ * Type guard to check if a schema entry is in tuple format.
117
+ */
118
+ export function isSchemaEntryTuple(
119
+ entry: RuntimeSchemaEntry | RuntimeSchemaEntryTuple,
120
+ ): entry is RuntimeSchemaEntryTuple {
121
+ return Array.isArray(entry);
122
+ }
123
+
124
+ /**
125
+ * Options for schema registration.
126
+ */
127
+ export interface RegisterOptions {
128
+ /** Override existing registration if present */
129
+ readonly override?: boolean;
130
+ }
131
+
132
+ /**
133
+ * Result of schema validation.
134
+ */
135
+ export interface SchemaValidationResult {
136
+ readonly valid: boolean;
137
+ readonly issues: readonly SchemaIssue[];
138
+ }
139
+
140
+ /**
141
+ * Individual schema issue.
142
+ */
143
+ export interface SchemaIssue {
144
+ readonly severity: 'error' | 'warning';
145
+ readonly eventKey: string;
146
+ readonly message: string;
147
+ }
148
+
149
+ /**
150
+ * Plugin definition for adding subscribers to multiple events.
151
+ */
152
+ export interface SchemaPlugin {
153
+ /** The subscriber to add to events */
154
+ readonly subscriber: AnySubscriber;
155
+
156
+ /** Event keys to exclude from this plugin */
157
+ readonly exclusions?: readonly string[];
158
+ }
159
+
160
+ /**
161
+ * Installs plugins (global subscribers) into a schema.
162
+ * Each plugin subscriber is added to all events except those in its exclusions list.
163
+ *
164
+ * @param schema - The base schema to extend
165
+ * @param plugins - Array of plugins to install
166
+ * @returns A new schema with plugin subscribers added
167
+ *
168
+ * @example
169
+ * ```typescript
170
+ * const baseSchema: MatadorSchema = {
171
+ * [UserCreatedEvent.key]: [UserCreatedEvent, [sendWelcomeEmail]],
172
+ * [OrderPlacedEvent.key]: [OrderPlacedEvent, [processOrder]],
173
+ * [ChatMessageSent.key]: [ChatMessageSent, [notifyRecipient]],
174
+ * };
175
+ *
176
+ * const schema = installPlugins(baseSchema, [
177
+ * {
178
+ * subscriber: logToBigQuery,
179
+ * exclusions: [ChatMessageSent.key], // Don't log chat messages
180
+ * },
181
+ * {
182
+ * subscriber: trackAnalytics,
183
+ * // No exclusions - added to all events
184
+ * },
185
+ * ]);
186
+ * ```
187
+ */
188
+ export function installPlugins(
189
+ schema: MatadorSchema,
190
+ plugins: readonly SchemaPlugin[],
191
+ ): MatadorSchema {
192
+ const result: Record<string, RuntimeSchemaEntry | RuntimeSchemaEntryTuple> =
193
+ {};
194
+
195
+ for (const [eventKey, entry] of Object.entries(schema)) {
196
+ // Get existing event class and subscribers
197
+ const [eventClass, existingSubscribers] = isSchemaEntryTuple(entry)
198
+ ? [entry[0], entry[1]]
199
+ : [entry.eventClass, entry.subscribers];
200
+
201
+ // Collect subscribers to add from plugins
202
+ const pluginSubscribers: AnySubscriber[] = [];
203
+ for (const plugin of plugins) {
204
+ if (!plugin.exclusions?.includes(eventKey)) {
205
+ pluginSubscribers.push(plugin.subscriber);
206
+ }
207
+ }
208
+
209
+ // Create new entry with combined subscribers
210
+ result[eventKey] = [
211
+ eventClass,
212
+ [...existingSubscribers, ...pluginSubscribers],
213
+ ];
214
+ }
215
+
216
+ return result;
217
+ }
@@ -0,0 +1,275 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { TopologyBuilder, TopologyValidationError } from './builder.js';
3
+
4
+ describe('TopologyBuilder', () => {
5
+ describe('withNamespace', () => {
6
+ it('should set the namespace', () => {
7
+ const topology = TopologyBuilder.create()
8
+ .withNamespace('myapp')
9
+ .addQueue('events')
10
+ .build();
11
+
12
+ expect(topology.namespace).toBe('myapp');
13
+ });
14
+
15
+ it('should reject empty namespace', () => {
16
+ const builder = TopologyBuilder.create()
17
+ .withNamespace('')
18
+ .addQueue('events');
19
+
20
+ expect(() => builder.build()).toThrow(TopologyValidationError);
21
+ });
22
+
23
+ it('should reject namespace starting with number', () => {
24
+ const builder = TopologyBuilder.create()
25
+ .withNamespace('123app')
26
+ .addQueue('events');
27
+
28
+ expect(() => builder.build()).toThrow(TopologyValidationError);
29
+ });
30
+
31
+ it('should allow hyphens and underscores in namespace', () => {
32
+ const topology = TopologyBuilder.create()
33
+ .withNamespace('my-app_v2')
34
+ .addQueue('events')
35
+ .build();
36
+
37
+ expect(topology.namespace).toBe('my-app_v2');
38
+ });
39
+ });
40
+
41
+ describe('addQueue', () => {
42
+ it('should add a queue with defaults', () => {
43
+ const topology = TopologyBuilder.create()
44
+ .withNamespace('test')
45
+ .addQueue('events')
46
+ .build();
47
+
48
+ expect(topology.queues).toHaveLength(1);
49
+ expect(topology.queues[0]?.name).toBe('events');
50
+ });
51
+
52
+ it('should add queue with options', () => {
53
+ const topology = TopologyBuilder.create()
54
+ .withNamespace('test')
55
+ .addQueue('events', {
56
+ concurrency: 5,
57
+ consumerTimeout: 30000,
58
+ priorities: true,
59
+ })
60
+ .build();
61
+
62
+ expect(topology.queues[0]?.concurrency).toBe(5);
63
+ expect(topology.queues[0]?.consumerTimeout).toBe(30000);
64
+ expect(topology.queues[0]?.priorities).toBe(true);
65
+ });
66
+
67
+ it('should add multiple queues', () => {
68
+ const topology = TopologyBuilder.create()
69
+ .withNamespace('test')
70
+ .addQueue('events')
71
+ .addQueue('notifications')
72
+ .addQueue('analytics')
73
+ .build();
74
+
75
+ expect(topology.queues).toHaveLength(3);
76
+ });
77
+
78
+ it('should reject duplicate queue names', () => {
79
+ const builder = TopologyBuilder.create()
80
+ .withNamespace('test')
81
+ .addQueue('events')
82
+ .addQueue('events');
83
+
84
+ expect(() => builder.build()).toThrow('Duplicate queue name');
85
+ });
86
+
87
+ it('should reject empty queue name', () => {
88
+ const builder = TopologyBuilder.create()
89
+ .withNamespace('test')
90
+ .addQueue('');
91
+
92
+ expect(() => builder.build()).toThrow('Queue name cannot be empty');
93
+ });
94
+
95
+ it('should reject queue names starting with number', () => {
96
+ const builder = TopologyBuilder.create()
97
+ .withNamespace('test')
98
+ .addQueue('123queue');
99
+
100
+ expect(() => builder.build()).toThrow('must start with a letter');
101
+ });
102
+
103
+ it('should reject invalid concurrency', () => {
104
+ const builder = TopologyBuilder.create()
105
+ .withNamespace('test')
106
+ .addQueue('events', { concurrency: 0 });
107
+
108
+ expect(() => builder.build()).toThrow('concurrency must be at least 1');
109
+ });
110
+
111
+ it('should reject negative consumer timeout', () => {
112
+ const builder = TopologyBuilder.create()
113
+ .withNamespace('test')
114
+ .addQueue('events', { consumerTimeout: -1 });
115
+
116
+ expect(() => builder.build()).toThrow(
117
+ 'consumer timeout must be non-negative',
118
+ );
119
+ });
120
+ });
121
+
122
+ describe('withDeadLetter', () => {
123
+ it('should configure dead letter settings', () => {
124
+ const topology = TopologyBuilder.create()
125
+ .withNamespace('test')
126
+ .addQueue('events')
127
+ .withDeadLetter({
128
+ unhandled: { enabled: false },
129
+ undeliverable: { enabled: true },
130
+ })
131
+ .build();
132
+
133
+ expect(topology.deadLetter.unhandled.enabled).toBe(false);
134
+ expect(topology.deadLetter.undeliverable.enabled).toBe(true);
135
+ });
136
+
137
+ it('should merge with defaults', () => {
138
+ const topology = TopologyBuilder.create()
139
+ .withNamespace('test')
140
+ .addQueue('events')
141
+ .withDeadLetter({ unhandled: { enabled: false } })
142
+ .build();
143
+
144
+ // undeliverable should keep default
145
+ expect(topology.deadLetter.unhandled.enabled).toBe(false);
146
+ expect(topology.deadLetter.undeliverable.enabled).toBe(true);
147
+ });
148
+ });
149
+
150
+ describe('withRetry', () => {
151
+ it('should configure retry settings', () => {
152
+ const topology = TopologyBuilder.create()
153
+ .withNamespace('test')
154
+ .addQueue('events')
155
+ .withRetry({
156
+ enabled: true,
157
+ defaultDelayMs: 5000,
158
+ maxDelayMs: 600000,
159
+ })
160
+ .build();
161
+
162
+ expect(topology.retry.enabled).toBe(true);
163
+ expect(topology.retry.defaultDelayMs).toBe(5000);
164
+ expect(topology.retry.maxDelayMs).toBe(600000);
165
+ });
166
+
167
+ it('should reject negative default delay', () => {
168
+ const builder = TopologyBuilder.create()
169
+ .withNamespace('test')
170
+ .addQueue('events')
171
+ .withRetry({ defaultDelayMs: -1 });
172
+
173
+ expect(() => builder.build()).toThrow('delay must be non-negative');
174
+ });
175
+
176
+ it('should reject max delay less than default', () => {
177
+ const builder = TopologyBuilder.create()
178
+ .withNamespace('test')
179
+ .addQueue('events')
180
+ .withRetry({
181
+ defaultDelayMs: 10000,
182
+ maxDelayMs: 5000,
183
+ });
184
+
185
+ expect(() => builder.build()).toThrow(
186
+ 'Max retry delay must be greater than or equal to default',
187
+ );
188
+ });
189
+ });
190
+
191
+ describe('withoutRetry', () => {
192
+ it('should disable retry', () => {
193
+ const topology = TopologyBuilder.create()
194
+ .withNamespace('test')
195
+ .addQueue('events')
196
+ .withoutRetry()
197
+ .build();
198
+
199
+ expect(topology.retry.enabled).toBe(false);
200
+ });
201
+ });
202
+
203
+ describe('withoutDeadLetter', () => {
204
+ it('should disable dead letter queues', () => {
205
+ const topology = TopologyBuilder.create()
206
+ .withNamespace('test')
207
+ .addQueue('events')
208
+ .withoutDeadLetter()
209
+ .build();
210
+
211
+ expect(topology.deadLetter.unhandled.enabled).toBe(false);
212
+ expect(topology.deadLetter.undeliverable.enabled).toBe(false);
213
+ });
214
+ });
215
+
216
+ describe('validate', () => {
217
+ it('should return issues without throwing', () => {
218
+ const builder = TopologyBuilder.create();
219
+ const issues = builder.validate();
220
+
221
+ expect(issues.length).toBeGreaterThan(0);
222
+ expect(issues).toContain('Namespace is required');
223
+ expect(issues).toContain('At least one queue is required');
224
+ });
225
+
226
+ it('should return empty array for valid topology', () => {
227
+ const builder = TopologyBuilder.create()
228
+ .withNamespace('test')
229
+ .addQueue('events');
230
+
231
+ const issues = builder.validate();
232
+ expect(issues).toHaveLength(0);
233
+ });
234
+ });
235
+
236
+ describe('build', () => {
237
+ it('should throw TopologyValidationError with issues', () => {
238
+ const builder = TopologyBuilder.create();
239
+
240
+ try {
241
+ builder.build();
242
+ expect(true).toBe(false); // Should not reach here
243
+ } catch (error) {
244
+ expect(error).toBeInstanceOf(TopologyValidationError);
245
+ expect((error as TopologyValidationError).issues).toContain(
246
+ 'Namespace is required',
247
+ );
248
+ }
249
+ });
250
+
251
+ it('should return immutable topology', () => {
252
+ const topology = TopologyBuilder.create()
253
+ .withNamespace('test')
254
+ .addQueue('events')
255
+ .build();
256
+
257
+ // Verify structure
258
+ expect(topology.namespace).toBe('test');
259
+ expect(topology.queues).toHaveLength(1);
260
+ expect(topology.deadLetter.unhandled.enabled).toBe(true);
261
+ expect(topology.retry.enabled).toBe(true);
262
+ });
263
+ });
264
+
265
+ describe('exact queue option', () => {
266
+ it('should mark queue as exact (external)', () => {
267
+ const topology = TopologyBuilder.create()
268
+ .withNamespace('test')
269
+ .addQueue('external-queue', { exact: true })
270
+ .build();
271
+
272
+ expect(topology.queues[0]?.exact).toBe(true);
273
+ });
274
+ });
275
+ });
@@ -0,0 +1,199 @@
1
+ import type { HasDescription } from '../errors/index.js';
2
+ import type {
3
+ DeadLetterConfig,
4
+ QueueDefinition,
5
+ RetryConfig,
6
+ Topology,
7
+ } from './types.js';
8
+
9
+ /**
10
+ * Options for adding a queue.
11
+ * Excludes 'name' as that is provided as the first argument to addQueue.
12
+ */
13
+ export type QueueOptions = Omit<QueueDefinition, 'name'>;
14
+
15
+ /**
16
+ * Error thrown when topology validation fails.
17
+ */
18
+ export class TopologyValidationError extends Error implements HasDescription {
19
+ readonly description =
20
+ 'The topology configuration is invalid. Check the issues array for ' +
21
+ 'specific validation failures such as missing namespace, invalid queue ' +
22
+ 'names, or conflicting settings. This error occurs during Matador ' +
23
+ 'initialization and must be fixed in the configuration.';
24
+
25
+ constructor(
26
+ message: string,
27
+ public readonly issues: readonly string[],
28
+ ) {
29
+ super(message);
30
+ this.name = 'TopologyValidationError';
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Fluent builder for creating Topology configurations.
36
+ */
37
+ export class TopologyBuilder {
38
+ /**
39
+ * Creates a new TopologyBuilder instance.
40
+ */
41
+ static create(): TopologyBuilder {
42
+ return new TopologyBuilder();
43
+ }
44
+
45
+ private namespace = '';
46
+ private queues: QueueDefinition[] = [];
47
+ private deadLetter: DeadLetterConfig = {
48
+ unhandled: { enabled: true },
49
+ undeliverable: { enabled: true },
50
+ };
51
+ private retry: RetryConfig = {
52
+ enabled: true,
53
+ defaultDelayMs: 1000,
54
+ maxDelayMs: 300000, // 5 minutes
55
+ };
56
+
57
+ /**
58
+ * Sets the namespace prefix for all queues.
59
+ */
60
+ withNamespace(namespace: string): this {
61
+ this.namespace = namespace;
62
+ return this;
63
+ }
64
+
65
+ /**
66
+ * Adds a queue to the topology.
67
+ */
68
+ addQueue(name: string, options: QueueOptions = {}): this {
69
+ this.queues.push({ name, ...options });
70
+ return this;
71
+ }
72
+
73
+ /**
74
+ * Alias for addQueue().
75
+ */
76
+ queue(name: string, options: QueueOptions = {}): this {
77
+ return this.addQueue(name, options);
78
+ }
79
+
80
+ /**
81
+ * Configures dead-letter queue settings.
82
+ */
83
+ withDeadLetter(config: Partial<DeadLetterConfig>): this {
84
+ this.deadLetter = {
85
+ unhandled: config.unhandled ?? this.deadLetter.unhandled,
86
+ undeliverable: config.undeliverable ?? this.deadLetter.undeliverable,
87
+ };
88
+ return this;
89
+ }
90
+
91
+ /**
92
+ * Configures retry settings.
93
+ */
94
+ withRetry(config: Partial<RetryConfig>): this {
95
+ this.retry = {
96
+ enabled: config.enabled ?? this.retry.enabled,
97
+ defaultDelayMs: config.defaultDelayMs ?? this.retry.defaultDelayMs,
98
+ maxDelayMs: config.maxDelayMs ?? this.retry.maxDelayMs,
99
+ };
100
+ return this;
101
+ }
102
+
103
+ /**
104
+ * Disables retry functionality.
105
+ */
106
+ withoutRetry(): this {
107
+ this.retry = { ...this.retry, enabled: false };
108
+ return this;
109
+ }
110
+
111
+ /**
112
+ * Disables dead-letter queues.
113
+ */
114
+ withoutDeadLetter(): this {
115
+ this.deadLetter = {
116
+ unhandled: { enabled: false },
117
+ undeliverable: { enabled: false },
118
+ };
119
+ return this;
120
+ }
121
+
122
+ /**
123
+ * Validates the topology configuration.
124
+ */
125
+ validate(): readonly string[] {
126
+ const issues: string[] = [];
127
+
128
+ if (!this.namespace || this.namespace.trim() === '') {
129
+ issues.push('Namespace is required');
130
+ } else if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(this.namespace)) {
131
+ issues.push(
132
+ 'Namespace must start with a letter and contain only alphanumeric characters, underscores, and hyphens',
133
+ );
134
+ }
135
+
136
+ if (this.queues.length === 0) {
137
+ issues.push('At least one queue is required');
138
+ }
139
+
140
+ const queueNames = new Set<string>();
141
+ for (const queue of this.queues) {
142
+ if (!queue.name || queue.name.trim() === '') {
143
+ issues.push('Queue name cannot be empty');
144
+ } else if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(queue.name)) {
145
+ issues.push(
146
+ `Queue name "${queue.name}" must start with a letter and contain only alphanumeric characters, underscores, and hyphens`,
147
+ );
148
+ } else if (queueNames.has(queue.name)) {
149
+ issues.push(`Duplicate queue name: "${queue.name}"`);
150
+ } else {
151
+ queueNames.add(queue.name);
152
+ }
153
+
154
+ if (queue.concurrency !== undefined && queue.concurrency < 1) {
155
+ issues.push(`Queue "${queue.name}" concurrency must be at least 1`);
156
+ }
157
+
158
+ if (queue.consumerTimeout !== undefined && queue.consumerTimeout < 0) {
159
+ issues.push(
160
+ `Queue "${queue.name}" consumer timeout must be non-negative`,
161
+ );
162
+ }
163
+ }
164
+
165
+ if (this.retry.enabled) {
166
+ if (this.retry.defaultDelayMs < 0) {
167
+ issues.push('Default retry delay must be non-negative');
168
+ }
169
+ if (this.retry.maxDelayMs < this.retry.defaultDelayMs) {
170
+ issues.push(
171
+ 'Max retry delay must be greater than or equal to default delay',
172
+ );
173
+ }
174
+ }
175
+
176
+ return issues;
177
+ }
178
+
179
+ /**
180
+ * Builds the topology configuration.
181
+ * @throws TopologyValidationError if validation fails
182
+ */
183
+ build(): Topology {
184
+ const issues = this.validate();
185
+ if (issues.length > 0) {
186
+ throw new TopologyValidationError(
187
+ `Invalid topology: ${issues.join('; ')}`,
188
+ issues,
189
+ );
190
+ }
191
+
192
+ return {
193
+ namespace: this.namespace,
194
+ queues: [...this.queues],
195
+ deadLetter: this.deadLetter,
196
+ retry: this.retry,
197
+ };
198
+ }
199
+ }
@@ -0,0 +1,15 @@
1
+ export type {
2
+ DeadLetterConfig,
3
+ DeadLetterQueueConfig,
4
+ QueueDefinition,
5
+ RetryConfig,
6
+ Topology,
7
+ } from './types.js';
8
+ export {
9
+ getDeadLetterQueueName,
10
+ getQualifiedQueueName,
11
+ getRetryQueueName,
12
+ } from './types.js';
13
+
14
+ export type { QueueOptions } from './builder.js';
15
+ export { TopologyBuilder, TopologyValidationError } from './builder.js';