@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,371 @@
1
+ import type { Checkpoint, CheckpointStore } from '../checkpoint/index.js';
2
+ import { NoOpCheckpointStore, ResumableContext } from '../checkpoint/index.js';
3
+ import type { Codec } from '../codec/index.js';
4
+ import { CodecDecodeError } from '../codec/index.js';
5
+ import {
6
+ SubscriberIsStubError,
7
+ SubscriberNotRegisteredError,
8
+ } from '../errors/index.js';
9
+ import type { SafeHooks } from '../hooks/index.js';
10
+ import type { RetryDecision, RetryPolicy } from '../retry/index.js';
11
+ import type { SchemaRegistry } from '../schema/index.js';
12
+ import type { MessageReceipt, Transport } from '../transport/index.js';
13
+ import type { Envelope, SubscriberDefinition } from '../types/index.js';
14
+ import { isResumableSubscriber } from '../types/index.js';
15
+
16
+ /**
17
+ * Configuration for the processing pipeline.
18
+ */
19
+ export interface PipelineConfig {
20
+ readonly transport: Transport;
21
+ readonly schema: SchemaRegistry;
22
+ readonly codec: Codec;
23
+ readonly retryPolicy: RetryPolicy;
24
+ readonly hooks: SafeHooks;
25
+ /** Optional checkpoint store for resumable subscribers */
26
+ readonly checkpointStore?: CheckpointStore | undefined;
27
+ }
28
+
29
+ /**
30
+ * Result of pipeline processing.
31
+ */
32
+ export interface ProcessResult {
33
+ readonly success: boolean;
34
+ readonly envelope?: Envelope | undefined;
35
+ readonly subscriber?: SubscriberDefinition | undefined;
36
+ readonly error?: Error | undefined;
37
+ readonly decision?: RetryDecision | undefined;
38
+ readonly durationMs: number;
39
+ }
40
+
41
+ /**
42
+ * Processing pipeline for incoming messages.
43
+ *
44
+ * Handles the complete message lifecycle:
45
+ * 1. Decode envelope from raw bytes
46
+ * 2. Lookup subscriber from schema
47
+ * 3. Execute subscriber callback with hooks
48
+ * 4. Handle success/failure with retry policy
49
+ */
50
+ export class ProcessingPipeline {
51
+ private readonly transport: Transport;
52
+ private readonly schema: SchemaRegistry;
53
+ private readonly codec: Codec;
54
+ private readonly retryPolicy: RetryPolicy;
55
+ private readonly hooks: SafeHooks;
56
+ private readonly checkpointStore: CheckpointStore;
57
+
58
+ constructor(config: PipelineConfig) {
59
+ this.transport = config.transport;
60
+ this.schema = config.schema;
61
+ this.codec = config.codec;
62
+ this.retryPolicy = config.retryPolicy;
63
+ this.hooks = config.hooks;
64
+ this.checkpointStore = config.checkpointStore ?? new NoOpCheckpointStore();
65
+ }
66
+
67
+ /**
68
+ * Processes a raw message from the transport.
69
+ */
70
+ async process(
71
+ rawMessage: Uint8Array,
72
+ receipt: MessageReceipt,
73
+ ): Promise<ProcessResult> {
74
+ const startTime = performance.now();
75
+
76
+ // 1. Decode envelope
77
+ let envelope: Envelope;
78
+ try {
79
+ envelope = this.codec.decode(rawMessage);
80
+ } catch (error) {
81
+ const decodeError =
82
+ error instanceof CodecDecodeError
83
+ ? error
84
+ : new CodecDecodeError('Unknown decode error', error);
85
+
86
+ await this.transport.complete(receipt);
87
+ await this.hooks.onDecodeError({
88
+ error: decodeError,
89
+ rawMessage,
90
+ sourceQueue: receipt.sourceQueue,
91
+ transport: receipt.sourceTransport,
92
+ });
93
+
94
+ return {
95
+ success: false,
96
+ error: decodeError,
97
+ durationMs: performance.now() - startTime,
98
+ };
99
+ }
100
+
101
+ // 2. Lookup subscriber from schema
102
+ const subscriberDef = this.schema.getSubscriberDefinition(
103
+ envelope.docket.eventKey,
104
+ envelope.docket.targetSubscriber,
105
+ );
106
+
107
+ if (!subscriberDef) {
108
+ const error = new SubscriberNotRegisteredError(
109
+ envelope.docket.targetSubscriber,
110
+ envelope.docket.eventKey,
111
+ );
112
+
113
+ // Retry unhandled messages (likely deployment timing issue)
114
+ const decision = this.getUnhandledRetryDecision(envelope, receipt, error);
115
+ await this.handleRetryDecision(receipt, envelope, decision);
116
+
117
+ return {
118
+ success: false,
119
+ envelope,
120
+ error,
121
+ decision,
122
+ durationMs: performance.now() - startTime,
123
+ };
124
+ }
125
+
126
+ // Get executable subscriber
127
+ const subscriber = this.schema.getExecutableSubscriber(
128
+ envelope.docket.eventKey,
129
+ envelope.docket.targetSubscriber,
130
+ );
131
+
132
+ if (!subscriber) {
133
+ // Subscriber is a stub (remote implementation)
134
+ const error = new SubscriberIsStubError(envelope.docket.targetSubscriber);
135
+
136
+ // Retry stub messages (likely deployment timing issue)
137
+ const decision = this.getUnhandledRetryDecision(envelope, receipt, error);
138
+ await this.handleRetryDecision(receipt, envelope, decision);
139
+
140
+ return {
141
+ success: false,
142
+ envelope,
143
+ subscriber: subscriberDef,
144
+ error,
145
+ decision,
146
+ durationMs: performance.now() - startTime,
147
+ };
148
+ }
149
+
150
+ // 3. Execute subscriber callback with hooks
151
+ let result: unknown;
152
+ let error: Error | undefined;
153
+ let context: ResumableContext | undefined;
154
+
155
+ // For resumable subscribers, load existing checkpoint and create context
156
+ const isResumable = isResumableSubscriber(subscriber);
157
+ let existingCheckpoint: Checkpoint | undefined;
158
+
159
+ if (isResumable) {
160
+ existingCheckpoint = await this.checkpointStore.get(envelope.id);
161
+
162
+ if (existingCheckpoint) {
163
+ await this.hooks.onCheckpointLoaded?.({
164
+ envelope,
165
+ subscriber: subscriberDef,
166
+ checkpoint: existingCheckpoint,
167
+ cachedSteps: Object.keys(existingCheckpoint.completedSteps).length,
168
+ });
169
+ }
170
+
171
+ context = new ResumableContext({
172
+ store: this.checkpointStore,
173
+ envelope,
174
+ subscriber: subscriberDef,
175
+ existingCheckpoint,
176
+ hooks: {
177
+ onCheckpointHit: (ctx) => this.hooks.onCheckpointHit?.(ctx),
178
+ onCheckpointMiss: (ctx) => this.hooks.onCheckpointMiss?.(ctx),
179
+ },
180
+ });
181
+ }
182
+
183
+ await this.hooks.onWorkerWrap(envelope, subscriberDef, async () => {
184
+ await this.hooks.onWorkerBeforeProcess(envelope, subscriberDef);
185
+
186
+ try {
187
+ if (isResumable && context) {
188
+ // Resumable subscriber: pass context as second argument
189
+ // Cast needed because TypeScript can't narrow the union type based on isResumable
190
+ const resumableCallback = subscriber.callback as (
191
+ envelope: Envelope,
192
+ context: ResumableContext,
193
+ ) => Promise<void> | void;
194
+ result = await resumableCallback(envelope, context);
195
+ } else {
196
+ // Standard subscriber: just pass envelope
197
+ const standardCallback = subscriber.callback as (
198
+ envelope: Envelope,
199
+ ) => Promise<void> | void;
200
+ result = await standardCallback(envelope);
201
+ }
202
+ } catch (e) {
203
+ error = e instanceof Error ? e : new Error(String(e));
204
+ }
205
+ });
206
+
207
+ const durationMs = performance.now() - startTime;
208
+
209
+ // 4. Handle success
210
+ if (!error) {
211
+ // Clear checkpoint on success for resumable subscribers
212
+ if (context) {
213
+ await context.clear();
214
+ await this.hooks.onCheckpointCleared?.({
215
+ envelope,
216
+ subscriber: subscriberDef,
217
+ reason: 'success',
218
+ });
219
+ }
220
+
221
+ await this.transport.complete(receipt);
222
+ await this.hooks.onWorkerSuccess({
223
+ envelope,
224
+ subscriber: subscriberDef,
225
+ result,
226
+ durationMs,
227
+ transport: receipt.sourceTransport,
228
+ });
229
+
230
+ return {
231
+ success: true,
232
+ envelope,
233
+ subscriber: subscriberDef,
234
+ durationMs,
235
+ };
236
+ }
237
+
238
+ // 5. Handle failure - consult retry policy
239
+ const decision = this.retryPolicy.shouldRetry({
240
+ envelope,
241
+ error,
242
+ subscriber: subscriberDef,
243
+ receipt,
244
+ });
245
+
246
+ // Update envelope with error info
247
+ envelope.docket.lastError = error.message;
248
+ envelope.docket.firstError ??= error.message;
249
+
250
+ // Clear checkpoint on dead-letter (terminal state)
251
+ if (decision.action === 'dead-letter' && context) {
252
+ await context.clear();
253
+ await this.hooks.onCheckpointCleared?.({
254
+ envelope,
255
+ subscriber: subscriberDef,
256
+ reason: 'dead-letter',
257
+ });
258
+ }
259
+
260
+ await this.handleRetryDecision(receipt, envelope, decision);
261
+
262
+ await this.hooks.onWorkerError({
263
+ envelope,
264
+ subscriber: subscriberDef,
265
+ error,
266
+ durationMs,
267
+ decision,
268
+ transport: receipt.sourceTransport,
269
+ });
270
+
271
+ return {
272
+ success: false,
273
+ envelope,
274
+ subscriber: subscriberDef,
275
+ error,
276
+ decision,
277
+ durationMs,
278
+ };
279
+ }
280
+
281
+ private async handleRetryDecision(
282
+ receipt: MessageReceipt,
283
+ envelope: Envelope,
284
+ decision: RetryDecision,
285
+ ): Promise<void> {
286
+ switch (decision.action) {
287
+ case 'retry': {
288
+ // Increment attempts and schedule retry
289
+ envelope.docket.attempts++;
290
+ envelope.docket.scheduledFor = new Date(
291
+ Date.now() + decision.delay,
292
+ ).toISOString();
293
+
294
+ await this.transport.send(receipt.sourceQueue, envelope, {
295
+ delay: decision.delay,
296
+ });
297
+ await this.transport.complete(receipt);
298
+ break;
299
+ }
300
+
301
+ case 'dead-letter': {
302
+ await this.sendToDeadLetter(
303
+ receipt,
304
+ envelope,
305
+ decision.queue,
306
+ decision.reason,
307
+ );
308
+ break;
309
+ }
310
+
311
+ case 'discard': {
312
+ await this.transport.complete(receipt);
313
+ break;
314
+ }
315
+ }
316
+ }
317
+
318
+ private async sendToDeadLetter(
319
+ receipt: MessageReceipt,
320
+ envelope: Envelope,
321
+ dlqName: string,
322
+ reason: string,
323
+ ): Promise<void> {
324
+ envelope.docket.originalQueue ??= receipt.sourceQueue;
325
+
326
+ if (this.transport.sendToDeadLetter) {
327
+ await this.transport.sendToDeadLetter(receipt, dlqName, envelope, reason);
328
+ } else {
329
+ // Manual: send to DLQ then complete original
330
+ const fullDlqName = `${receipt.sourceQueue}.${dlqName}`;
331
+ await this.transport.send(fullDlqName, envelope);
332
+ await this.transport.complete(receipt);
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Gets retry decision for unhandled messages (subscriber not found).
338
+ * Uses same retry policy logic but sends to 'unhandled' DLQ after max attempts.
339
+ */
340
+ private getUnhandledRetryDecision(
341
+ envelope: Envelope,
342
+ receipt: MessageReceipt,
343
+ error: Error,
344
+ ): RetryDecision {
345
+ // Create a synthetic subscriber definition for retry policy
346
+ const syntheticSubscriber: SubscriberDefinition = {
347
+ name: envelope.docket.targetSubscriber,
348
+ description: 'Synthetic subscriber for unhandled message retry',
349
+ idempotent: 'yes', // Safe to retry unhandled messages
350
+ importance: envelope.docket.importance ?? 'should-investigate',
351
+ };
352
+
353
+ const decision = this.retryPolicy.shouldRetry({
354
+ envelope,
355
+ error,
356
+ subscriber: syntheticSubscriber,
357
+ receipt,
358
+ });
359
+
360
+ // Override dead-letter queue to 'unhandled' instead of 'undeliverable'
361
+ if (decision.action === 'dead-letter') {
362
+ return {
363
+ action: 'dead-letter',
364
+ queue: 'unhandled',
365
+ reason: decision.reason,
366
+ };
367
+ }
368
+
369
+ return decision;
370
+ }
371
+ }
@@ -0,0 +1,4 @@
1
+ export type { RetryContext, RetryDecision, RetryPolicy } from './policy.js';
2
+
3
+ export type { StandardRetryPolicyConfig } from './standard-policy.js';
4
+ export { defaultRetryConfig, StandardRetryPolicy } from './standard-policy.js';
@@ -0,0 +1,46 @@
1
+ import type { MessageReceipt } from '../transport/index.js';
2
+ import type { Envelope, SubscriberDefinition } from '../types/index.js';
3
+
4
+ /**
5
+ * Context provided to retry policy for decision making.
6
+ */
7
+ export interface RetryContext {
8
+ /** The message envelope */
9
+ readonly envelope: Envelope;
10
+
11
+ /** The error that caused the failure */
12
+ readonly error: Error;
13
+
14
+ /** The subscriber definition */
15
+ readonly subscriber: SubscriberDefinition;
16
+
17
+ /** Message receipt with delivery information */
18
+ readonly receipt: MessageReceipt;
19
+ }
20
+
21
+ /**
22
+ * Decision returned by retry policy.
23
+ */
24
+ export type RetryDecision =
25
+ | { readonly action: 'retry'; readonly delay: number }
26
+ | {
27
+ readonly action: 'dead-letter';
28
+ readonly queue: string;
29
+ readonly reason: string;
30
+ }
31
+ | { readonly action: 'discard'; readonly reason: string };
32
+
33
+ /**
34
+ * Interface for retry policies.
35
+ */
36
+ export interface RetryPolicy {
37
+ /**
38
+ * Determines what to do with a failed message.
39
+ */
40
+ shouldRetry(context: RetryContext): RetryDecision;
41
+
42
+ /**
43
+ * Calculates the delay for a retry attempt.
44
+ */
45
+ getDelay(context: RetryContext): number;
46
+ }
@@ -0,0 +1,282 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ DoRetry,
4
+ DontRetry,
5
+ EventAssertionError,
6
+ } from '../errors/retry-errors.js';
7
+ import type { MessageReceipt } from '../transport/transport.js';
8
+ import { createEnvelope } from '../types/envelope.js';
9
+ import type { SubscriberDefinition } from '../types/subscriber.js';
10
+ import type { RetryContext, RetryDecision } from './policy.js';
11
+ import { StandardRetryPolicy } from './standard-policy.js';
12
+
13
+ describe('StandardRetryPolicy', () => {
14
+ describe('shouldRetry', () => {
15
+ it('should dead-letter on EventAssertionError', () => {
16
+ const policy = new StandardRetryPolicy();
17
+ const context = createContext(new EventAssertionError('Invalid event'));
18
+
19
+ const decision = policy.shouldRetry(context);
20
+
21
+ expect(decision.action).toBe('dead-letter');
22
+ assertDeadLetter(decision);
23
+ expect(decision.queue).toBe('undeliverable');
24
+ expect(decision.reason).toContain('assertion error');
25
+ });
26
+
27
+ it('should dead-letter on DontRetry error', () => {
28
+ const policy = new StandardRetryPolicy();
29
+ const context = createContext(new DontRetry('Business rule violation'));
30
+
31
+ const decision = policy.shouldRetry(context);
32
+
33
+ expect(decision.action).toBe('dead-letter');
34
+ assertDeadLetter(decision);
35
+ expect(decision.queue).toBe('undeliverable');
36
+ expect(decision.reason).toContain('Business rule violation');
37
+ });
38
+
39
+ it('should retry on DoRetry error if under max attempts', () => {
40
+ const policy = new StandardRetryPolicy({ maxAttempts: 3 });
41
+ const context = createContext(new DoRetry('Temporary failure'), {
42
+ attemptNumber: 1,
43
+ });
44
+
45
+ const decision = policy.shouldRetry(context);
46
+
47
+ expect(decision.action).toBe('retry');
48
+ assertRetry(decision);
49
+ expect(decision.delay).toBeGreaterThan(0);
50
+ });
51
+
52
+ it('should dead-letter on DoRetry error if max attempts exceeded', () => {
53
+ const policy = new StandardRetryPolicy({ maxAttempts: 3 });
54
+ const context = createContext(new DoRetry('Temporary failure'), {
55
+ attemptNumber: 3,
56
+ });
57
+
58
+ const decision = policy.shouldRetry(context);
59
+
60
+ expect(decision.action).toBe('dead-letter');
61
+ assertDeadLetter(decision);
62
+ expect(decision.reason).toContain('max attempts exceeded');
63
+ });
64
+
65
+ it('should dead-letter when max attempts exceeded', () => {
66
+ const policy = new StandardRetryPolicy({ maxAttempts: 3 });
67
+ const context = createContext(new Error('Generic error'), {
68
+ attemptNumber: 3,
69
+ });
70
+
71
+ const decision = policy.shouldRetry(context);
72
+
73
+ expect(decision.action).toBe('dead-letter');
74
+ assertDeadLetter(decision);
75
+ expect(decision.queue).toBe('undeliverable');
76
+ expect(decision.reason).toContain('max attempts exceeded (3)');
77
+ });
78
+
79
+ it('should dead-letter non-idempotent subscriber on redelivery', () => {
80
+ const policy = new StandardRetryPolicy();
81
+ const context = createContext(
82
+ new Error('Some error'),
83
+ { attemptNumber: 1, redelivered: true },
84
+ { idempotent: 'no' },
85
+ );
86
+
87
+ const decision = policy.shouldRetry(context);
88
+
89
+ expect(decision.action).toBe('dead-letter');
90
+ assertDeadLetter(decision);
91
+ expect(decision.reason).toContain('Non-idempotent subscriber');
92
+ });
93
+
94
+ it('should retry idempotent subscriber on redelivery', () => {
95
+ const policy = new StandardRetryPolicy();
96
+ const context = createContext(
97
+ new Error('Some error'),
98
+ { attemptNumber: 1, redelivered: true },
99
+ { idempotent: 'yes' },
100
+ );
101
+
102
+ const decision = policy.shouldRetry(context);
103
+
104
+ expect(decision.action).toBe('retry');
105
+ });
106
+
107
+ it('should dead-letter unknown idempotency subscriber on redelivery (unknown == no)', () => {
108
+ const policy = new StandardRetryPolicy();
109
+ const context = createContext(
110
+ new Error('Some error'),
111
+ { attemptNumber: 1, redelivered: true },
112
+ { idempotent: 'unknown' },
113
+ );
114
+
115
+ const decision = policy.shouldRetry(context);
116
+
117
+ expect(decision.action).toBe('dead-letter');
118
+ assertDeadLetter(decision);
119
+ expect(decision.reason).toContain('Non-idempotent subscriber');
120
+ });
121
+
122
+ it('should retry resumable subscriber on redelivery', () => {
123
+ const policy = new StandardRetryPolicy();
124
+ const context = createContext(
125
+ new Error('Some error'),
126
+ { attemptNumber: 1, redelivered: true },
127
+ { idempotent: 'resumable' },
128
+ );
129
+
130
+ const decision = policy.shouldRetry(context);
131
+
132
+ expect(decision.action).toBe('retry');
133
+ });
134
+
135
+ it('should retry generic errors with backoff', () => {
136
+ const policy = new StandardRetryPolicy({ maxAttempts: 5 });
137
+ const context = createContext(new Error('Generic error'), {
138
+ attemptNumber: 1,
139
+ });
140
+
141
+ const decision = policy.shouldRetry(context);
142
+
143
+ expect(decision.action).toBe('retry');
144
+ assertRetry(decision);
145
+ expect(decision.delay).toBeDefined();
146
+ });
147
+ });
148
+
149
+ describe('getDelay', () => {
150
+ it('should calculate exponential backoff', () => {
151
+ const policy = new StandardRetryPolicy({
152
+ baseDelay: 1000,
153
+ backoffMultiplier: 2,
154
+ });
155
+
156
+ const delay1 = policy.getDelay(
157
+ createContext(new Error(), { attemptNumber: 1 }),
158
+ );
159
+ const delay2 = policy.getDelay(
160
+ createContext(new Error(), { attemptNumber: 2 }),
161
+ );
162
+ const delay3 = policy.getDelay(
163
+ createContext(new Error(), { attemptNumber: 3 }),
164
+ );
165
+
166
+ expect(delay1).toBe(1000); // 1000 * 2^0
167
+ expect(delay2).toBe(2000); // 1000 * 2^1
168
+ expect(delay3).toBe(4000); // 1000 * 2^2
169
+ });
170
+
171
+ it('should cap delay at maxDelay', () => {
172
+ const policy = new StandardRetryPolicy({
173
+ baseDelay: 1000,
174
+ backoffMultiplier: 10,
175
+ maxDelay: 5000,
176
+ });
177
+
178
+ const delay = policy.getDelay(
179
+ createContext(new Error(), { attemptNumber: 5 }),
180
+ );
181
+
182
+ expect(delay).toBe(5000);
183
+ });
184
+ });
185
+
186
+ describe('configuration', () => {
187
+ it('should use default configuration', () => {
188
+ const policy = new StandardRetryPolicy();
189
+ const context = createContext(new Error(), { attemptNumber: 1 });
190
+
191
+ const decision = policy.shouldRetry(context);
192
+
193
+ // Default maxAttempts is 3, so should retry on attempt 1
194
+ expect(decision.action).toBe('retry');
195
+ });
196
+
197
+ it('should accept custom configuration', () => {
198
+ const policy = new StandardRetryPolicy({
199
+ maxAttempts: 1,
200
+ baseDelay: 500,
201
+ maxDelay: 1000,
202
+ backoffMultiplier: 1.5,
203
+ });
204
+
205
+ // With maxAttempts: 1, first attempt should dead-letter
206
+ const context = createContext(new Error(), { attemptNumber: 1 });
207
+ const decision = policy.shouldRetry(context);
208
+
209
+ expect(decision.action).toBe('dead-letter');
210
+ });
211
+
212
+ it('should merge partial configuration with defaults', () => {
213
+ const policy = new StandardRetryPolicy({ maxAttempts: 10 });
214
+ // Use attemptNumber: 5 but deliveryCount: 3 to avoid poison detection
215
+ const context = createContext(new Error(), {
216
+ attemptNumber: 5,
217
+ deliveryCount: 3,
218
+ });
219
+
220
+ // Should still retry because we increased maxAttempts
221
+ const decision = policy.shouldRetry(context);
222
+ expect(decision.action).toBe('retry');
223
+ });
224
+ });
225
+ });
226
+
227
+ function assertDeadLetter(decision: RetryDecision): asserts decision is {
228
+ action: 'dead-letter';
229
+ queue: string;
230
+ reason: string;
231
+ } {
232
+ if (decision.action !== 'dead-letter') {
233
+ throw new Error(`Expected dead-letter action, got ${decision.action}`);
234
+ }
235
+ }
236
+
237
+ function assertRetry(
238
+ decision: RetryDecision,
239
+ ): asserts decision is { action: 'retry'; delay: number } {
240
+ if (decision.action !== 'retry') {
241
+ throw new Error(`Expected retry action, got ${decision.action}`);
242
+ }
243
+ }
244
+
245
+ function createContext(
246
+ error: Error,
247
+ receiptOverrides: Partial<MessageReceipt> = {},
248
+ subscriberOverrides: Partial<SubscriberDefinition> = {},
249
+ ): RetryContext {
250
+ const receipt: MessageReceipt = {
251
+ handle: {},
252
+ redelivered: false,
253
+ attemptNumber: 1,
254
+ deliveryCount:
255
+ receiptOverrides.deliveryCount ?? receiptOverrides.attemptNumber ?? 1,
256
+ sourceQueue: 'test-queue',
257
+ sourceTransport: 'mock',
258
+ ...receiptOverrides,
259
+ };
260
+
261
+ const subscriber: SubscriberDefinition = {
262
+ name: 'test-subscriber',
263
+ description: 'Test subscriber',
264
+ idempotent: 'yes',
265
+ importance: 'should-investigate',
266
+ ...subscriberOverrides,
267
+ };
268
+
269
+ const envelope = createEnvelope({
270
+ eventKey: 'test.event',
271
+ targetSubscriber: 'test-subscriber',
272
+ data: { test: 'data' },
273
+ importance: 'should-investigate',
274
+ });
275
+
276
+ return {
277
+ envelope,
278
+ error,
279
+ receipt,
280
+ subscriber,
281
+ };
282
+ }