@amqp-contract/worker 0.11.0 → 0.13.0

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/dist/index.cjs CHANGED
@@ -5,36 +5,16 @@ let node_util = require("node:util");
5
5
 
6
6
  //#region src/errors.ts
7
7
  /**
8
- * Base error class for worker errors
9
- */
10
- var WorkerError = class extends Error {
11
- constructor(message) {
12
- super(message);
13
- this.name = "WorkerError";
14
- const ErrorConstructor = Error;
15
- if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
16
- }
17
- };
18
- /**
19
- * Error for technical/runtime failures in worker operations
20
- * This includes validation failures, parsing failures, and processing failures
21
- */
22
- var TechnicalError = class extends WorkerError {
23
- constructor(message, cause) {
24
- super(message);
25
- this.cause = cause;
26
- this.name = "TechnicalError";
27
- }
28
- };
29
- /**
30
8
  * Error thrown when message validation fails
31
9
  */
32
- var MessageValidationError = class extends WorkerError {
10
+ var MessageValidationError = class extends Error {
33
11
  constructor(consumerName, issues) {
34
12
  super(`Message validation failed for consumer "${consumerName}"`);
35
13
  this.consumerName = consumerName;
36
14
  this.issues = issues;
37
15
  this.name = "MessageValidationError";
16
+ const ErrorConstructor = Error;
17
+ if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
38
18
  }
39
19
  };
40
20
  /**
@@ -44,11 +24,13 @@ var MessageValidationError = class extends WorkerError {
44
24
  * Use this error type when the operation might succeed if retried.
45
25
  * The worker will apply exponential backoff and retry the message.
46
26
  */
47
- var RetryableError = class extends WorkerError {
27
+ var RetryableError = class extends Error {
48
28
  constructor(message, cause) {
49
29
  super(message);
50
30
  this.cause = cause;
51
31
  this.name = "RetryableError";
32
+ const ErrorConstructor = Error;
33
+ if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
52
34
  }
53
35
  };
54
36
  /**
@@ -58,34 +40,173 @@ var RetryableError = class extends WorkerError {
58
40
  * Use this error type when retrying would not help - the message will be
59
41
  * immediately sent to the dead letter queue (DLQ) if configured.
60
42
  */
61
- var NonRetryableError = class extends WorkerError {
43
+ var NonRetryableError = class extends Error {
62
44
  constructor(message, cause) {
63
45
  super(message);
64
46
  this.cause = cause;
65
47
  this.name = "NonRetryableError";
48
+ const ErrorConstructor = Error;
49
+ if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
66
50
  }
67
51
  };
52
+ /**
53
+ * Type guard to check if an error is a RetryableError.
54
+ *
55
+ * Use this to check error types in catch blocks or error handlers.
56
+ *
57
+ * @param error - The error to check
58
+ * @returns True if the error is a RetryableError
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * import { isRetryableError } from '@amqp-contract/worker';
63
+ *
64
+ * try {
65
+ * await processMessage();
66
+ * } catch (error) {
67
+ * if (isRetryableError(error)) {
68
+ * console.log('Will retry:', error.message);
69
+ * } else {
70
+ * console.log('Permanent failure:', error);
71
+ * }
72
+ * }
73
+ * ```
74
+ */
75
+ function isRetryableError(error) {
76
+ return error instanceof RetryableError;
77
+ }
78
+ /**
79
+ * Type guard to check if an error is a NonRetryableError.
80
+ *
81
+ * Use this to check error types in catch blocks or error handlers.
82
+ *
83
+ * @param error - The error to check
84
+ * @returns True if the error is a NonRetryableError
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * import { isNonRetryableError } from '@amqp-contract/worker';
89
+ *
90
+ * try {
91
+ * await processMessage();
92
+ * } catch (error) {
93
+ * if (isNonRetryableError(error)) {
94
+ * console.log('Will not retry:', error.message);
95
+ * }
96
+ * }
97
+ * ```
98
+ */
99
+ function isNonRetryableError(error) {
100
+ return error instanceof NonRetryableError;
101
+ }
102
+ /**
103
+ * Type guard to check if an error is any HandlerError (RetryableError or NonRetryableError).
104
+ *
105
+ * @param error - The error to check
106
+ * @returns True if the error is a HandlerError
107
+ *
108
+ * @example
109
+ * ```typescript
110
+ * import { isHandlerError } from '@amqp-contract/worker';
111
+ *
112
+ * function handleError(error: unknown) {
113
+ * if (isHandlerError(error)) {
114
+ * // error is RetryableError | NonRetryableError
115
+ * console.log('Handler error:', error.name, error.message);
116
+ * }
117
+ * }
118
+ * ```
119
+ */
120
+ function isHandlerError(error) {
121
+ return isRetryableError(error) || isNonRetryableError(error);
122
+ }
123
+ /**
124
+ * Create a RetryableError with less verbosity.
125
+ *
126
+ * This is a shorthand factory function for creating RetryableError instances.
127
+ * Use it for cleaner error creation in handlers.
128
+ *
129
+ * @param message - Error message describing the failure
130
+ * @param cause - Optional underlying error that caused this failure
131
+ * @returns A new RetryableError instance
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * import { retryable } from '@amqp-contract/worker';
136
+ * import { Future, Result } from '@swan-io/boxed';
137
+ *
138
+ * const handler = ({ payload }) =>
139
+ * Future.fromPromise(processPayment(payload))
140
+ * .mapOk(() => undefined)
141
+ * .mapError((e) => retryable('Payment service unavailable', e));
142
+ *
143
+ * // Equivalent to:
144
+ * // .mapError((e) => new RetryableError('Payment service unavailable', e));
145
+ * ```
146
+ */
147
+ function retryable(message, cause) {
148
+ return new RetryableError(message, cause);
149
+ }
150
+ /**
151
+ * Create a NonRetryableError with less verbosity.
152
+ *
153
+ * This is a shorthand factory function for creating NonRetryableError instances.
154
+ * Use it for cleaner error creation in handlers.
155
+ *
156
+ * @param message - Error message describing the failure
157
+ * @param cause - Optional underlying error that caused this failure
158
+ * @returns A new NonRetryableError instance
159
+ *
160
+ * @example
161
+ * ```typescript
162
+ * import { nonRetryable } from '@amqp-contract/worker';
163
+ * import { Future, Result } from '@swan-io/boxed';
164
+ *
165
+ * const handler = ({ payload }) => {
166
+ * if (!isValidPayload(payload)) {
167
+ * return Future.value(Result.Error(nonRetryable('Invalid payload format')));
168
+ * }
169
+ * return Future.value(Result.Ok(undefined));
170
+ * };
171
+ *
172
+ * // Equivalent to:
173
+ * // return Future.value(Result.Error(new NonRetryableError('Invalid payload format')));
174
+ * ```
175
+ */
176
+ function nonRetryable(message, cause) {
177
+ return new NonRetryableError(message, cause);
178
+ }
68
179
 
69
180
  //#endregion
70
181
  //#region src/decompression.ts
71
182
  const gunzipAsync = (0, node_util.promisify)(node_zlib.gunzip);
72
183
  const inflateAsync = (0, node_util.promisify)(node_zlib.inflate);
73
184
  /**
185
+ * Supported content encodings for message decompression.
186
+ */
187
+ const SUPPORTED_ENCODINGS = ["gzip", "deflate"];
188
+ /**
189
+ * Type guard to check if a string is a supported encoding.
190
+ */
191
+ function isSupportedEncoding(encoding) {
192
+ return SUPPORTED_ENCODINGS.includes(encoding.toLowerCase());
193
+ }
194
+ /**
74
195
  * Decompress a buffer based on the content-encoding header.
75
196
  *
76
197
  * @param buffer - The buffer to decompress
77
198
  * @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
78
- * @returns A promise that resolves to the decompressed buffer
79
- * @throws Error if decompression fails or if the encoding is unsupported
199
+ * @returns A Future with the decompressed buffer or a TechnicalError
80
200
  *
81
201
  * @internal
82
202
  */
83
- async function decompressBuffer(buffer, contentEncoding) {
84
- if (!contentEncoding) return buffer;
85
- switch (contentEncoding.toLowerCase()) {
86
- case "gzip": return gunzipAsync(buffer);
87
- case "deflate": return inflateAsync(buffer);
88
- default: throw new Error(`Unsupported content-encoding: ${contentEncoding}`);
203
+ function decompressBuffer(buffer, contentEncoding) {
204
+ if (!contentEncoding) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(buffer));
205
+ const normalizedEncoding = contentEncoding.toLowerCase();
206
+ if (!isSupportedEncoding(normalizedEncoding)) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)));
207
+ switch (normalizedEncoding) {
208
+ case "gzip": return _swan_io_boxed.Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress gzip", error));
209
+ case "deflate": return _swan_io_boxed.Future.fromPromise(inflateAsync(buffer)).mapError((error) => new _amqp_contract_core.TechnicalError("Failed to decompress deflate", error));
89
210
  }
90
211
  }
91
212
 
@@ -98,17 +219,6 @@ function isHandlerTuple(entry) {
98
219
  return Array.isArray(entry) && entry.length === 2;
99
220
  }
100
221
  /**
101
- * Type guard to check if a value is a Standard Schema v1 compliant schema.
102
- */
103
- function isStandardSchema(value) {
104
- if (typeof value !== "object" || value === null) return false;
105
- if (!("~standard" in value)) return false;
106
- const standard = value["~standard"];
107
- if (typeof standard !== "object" || standard === null) return false;
108
- if (!("validate" in standard)) return false;
109
- return typeof standard.validate === "function";
110
- }
111
- /**
112
222
  * Type-safe AMQP worker for consuming messages from RabbitMQ.
113
223
  *
114
224
  * This class provides automatic message validation, connection management,
@@ -204,7 +314,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
204
314
  urls,
205
315
  connectionOptions
206
316
  }), handlers, logger, telemetry);
207
- return worker.waitForConnectionReady().flatMapOk(() => worker.validateRetryConfiguration()).flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
317
+ return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
208
318
  }
209
319
  /**
210
320
  * Close the AMQP channel and connection.
@@ -223,7 +333,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
223
333
  * ```
224
334
  */
225
335
  close() {
226
- return _swan_io_boxed.Future.all(Array.from(this.consumerTags).map((consumerTag) => _swan_io_boxed.Future.fromPromise(this.amqpClient.channel.cancel(consumerTag)).mapErrorToResult((error) => {
336
+ return _swan_io_boxed.Future.all(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).mapErrorToResult((error) => {
227
337
  this.logger?.warn("Failed to cancel consumer during close", {
228
338
  consumerTag,
229
339
  error
@@ -231,213 +341,103 @@ var TypedAmqpWorker = class TypedAmqpWorker {
231
341
  return _swan_io_boxed.Result.Ok(void 0);
232
342
  }))).map(_swan_io_boxed.Result.all).tapOk(() => {
233
343
  this.consumerTags.clear();
234
- }).flatMapOk(() => _swan_io_boxed.Future.fromPromise(this.amqpClient.close())).mapError((error) => new TechnicalError("Failed to close AMQP connection", error)).mapOk(() => void 0);
235
- }
236
- /**
237
- * Validate retry configuration for all consumers.
238
- *
239
- * For quorum-native mode, validates that the queue is properly configured.
240
- * For TTL-backoff mode, wait queues are created by setupAmqpTopology in the core package.
241
- */
242
- validateRetryConfiguration() {
243
- if (!this.contract.consumers) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
244
- for (const consumerName of Object.keys(this.contract.consumers)) {
245
- const consumer = this.contract.consumers[consumerName];
246
- if (!consumer) continue;
247
- const queue = consumer.queue;
248
- if ((queue.retry?.mode ?? "ttl-backoff") === "quorum-native") {
249
- const validationError = this.validateQuorumNativeConfigForConsumer(String(consumerName), consumer);
250
- if (validationError) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(validationError));
251
- this.logger?.info("Using quorum-native retry mode", {
252
- consumerName: String(consumerName),
253
- queueName: queue.name
254
- });
255
- } else if (queue.deadLetter) this.logger?.info("Using TTL-backoff retry mode", {
256
- consumerName: String(consumerName),
257
- queueName: queue.name
258
- });
259
- else this.logger?.warn("Queue has no deadLetter configured - retries will use nack with requeue", {
260
- consumerName: String(consumerName),
261
- queueName: queue.name
262
- });
263
- }
264
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
344
+ }).flatMapOk(() => this.amqpClient.close()).mapOk(() => void 0);
265
345
  }
266
346
  /**
267
- * Get the resolved retry configuration for a consumer's queue.
268
- * Reads retry config from the queue definition in the contract.
347
+ * Get the retry configuration for a consumer's queue.
348
+ * Defaults are applied in the contract's defineQueue, so we just return the config.
269
349
  */
270
350
  getRetryConfigForConsumer(consumer) {
271
- const retryOptions = consumer.queue.retry;
272
- if (retryOptions?.mode === "quorum-native") return {
273
- mode: "quorum-native",
274
- maxRetries: 0,
275
- initialDelayMs: 0,
276
- maxDelayMs: 0,
277
- backoffMultiplier: 0,
278
- jitter: false
279
- };
280
- if (retryOptions?.mode === "ttl-backoff") return {
281
- mode: "ttl-backoff",
282
- maxRetries: retryOptions.maxRetries ?? 3,
283
- initialDelayMs: retryOptions.initialDelayMs ?? 1e3,
284
- maxDelayMs: retryOptions.maxDelayMs ?? 3e4,
285
- backoffMultiplier: retryOptions.backoffMultiplier ?? 2,
286
- jitter: retryOptions.jitter ?? true
287
- };
288
- return {
289
- mode: "ttl-backoff",
290
- maxRetries: 3,
291
- initialDelayMs: 1e3,
292
- maxDelayMs: 3e4,
293
- backoffMultiplier: 2,
294
- jitter: true
295
- };
296
- }
297
- /**
298
- * Validate that quorum-native retry mode is properly configured for a specific consumer.
299
- *
300
- * Requirements for quorum-native mode:
301
- * - Consumer queue must be a quorum queue
302
- * - Consumer queue must have deliveryLimit configured
303
- * - Consumer queue should have DLX configured (warning if not)
304
- *
305
- * @returns TechnicalError if validation fails, null if valid
306
- */
307
- validateQuorumNativeConfigForConsumer(consumerName, consumer) {
308
- const queue = consumer.queue;
309
- if (queue.type !== "quorum") return new TechnicalError(`Consumer "${consumerName}" uses queue "${queue.name}" with type "${queue.type}". Quorum-native retry mode requires quorum queues.`);
310
- if (queue.deliveryLimit === void 0) return new TechnicalError(`Consumer "${consumerName}" uses queue "${queue.name}" without deliveryLimit configured. Quorum-native retry mode requires deliveryLimit to be set on the queue definition.`);
311
- if (!queue.deadLetter) this.logger?.warn(`Consumer "${consumerName}" uses queue "${queue.name}" without deadLetter configured. Messages exceeding deliveryLimit will be dropped instead of dead-lettered.`);
312
- return null;
351
+ return consumer.queue.retry;
313
352
  }
314
353
  /**
315
- * Start consuming messages for all consumers
354
+ * Start consuming messages for all consumers.
355
+ * TypeScript guarantees consumers exist (handlers require matching consumers).
316
356
  */
317
357
  consumeAll() {
318
- if (!this.contract.consumers) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError("No consumers defined in contract")));
319
- const consumerNames = Object.keys(this.contract.consumers);
320
- let maxPrefetch = 0;
321
- for (const consumerName of consumerNames) {
322
- const options = this.consumerOptions[consumerName];
323
- if (options?.prefetch !== void 0) {
324
- if (options.prefetch <= 0 || !Number.isInteger(options.prefetch)) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError(`Invalid prefetch value for "${String(consumerName)}": must be a positive integer`)));
325
- maxPrefetch = Math.max(maxPrefetch, options.prefetch);
326
- }
327
- }
328
- if (maxPrefetch > 0) this.amqpClient.channel.addSetup(async (channel) => {
358
+ const consumers = this.contract.consumers;
359
+ const consumerNames = Object.keys(consumers);
360
+ const maxPrefetch = consumerNames.reduce((max, name) => {
361
+ const prefetch = this.consumerOptions[name]?.prefetch;
362
+ return prefetch ? Math.max(max, prefetch) : max;
363
+ }, 0);
364
+ if (maxPrefetch > 0) this.amqpClient.addSetup(async (channel) => {
329
365
  await channel.prefetch(maxPrefetch);
330
366
  });
331
- return _swan_io_boxed.Future.all(consumerNames.map((consumerName) => this.consume(consumerName))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
367
+ return _swan_io_boxed.Future.all(consumerNames.map((name) => this.consume(name))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
332
368
  }
333
369
  waitForConnectionReady() {
334
- return _swan_io_boxed.Future.fromPromise(this.amqpClient.channel.waitForConnect()).mapError((error) => new TechnicalError("Failed to wait for connection ready", error));
370
+ return this.amqpClient.waitForConnect();
335
371
  }
336
372
  /**
337
- * Start consuming messages for a specific consumer
373
+ * Start consuming messages for a specific consumer.
374
+ * TypeScript guarantees consumer and handler exist for valid consumer names.
338
375
  */
339
376
  consume(consumerName) {
340
- const consumers = this.contract.consumers;
341
- if (!consumers) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError("No consumers defined in contract")));
342
- const consumer = consumers[consumerName];
343
- if (!consumer) {
344
- const availableConsumers = Object.keys(consumers);
345
- const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
346
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError(`Consumer not found: "${String(consumerName)}". Available consumers: ${available}`)));
347
- }
377
+ const consumer = this.contract.consumers[consumerName];
348
378
  const handler = this.actualHandlers[consumerName];
349
- if (!handler) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError(`Handler for "${String(consumerName)}" not provided`)));
350
379
  return this.consumeSingle(consumerName, consumer, handler);
351
380
  }
352
381
  /**
353
- * Parse and validate a message from AMQP
354
- * @returns `Future<Result<consumed message, void>>` - Ok with validated consumed message (payload + headers), or Error (already handled with nack)
382
+ * Validate data against a Standard Schema and handle errors.
355
383
  */
356
- parseAndValidateMessage(msg, consumer, consumerName) {
357
- const decompressMessage = _swan_io_boxed.Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).tapError((error) => {
358
- this.logger?.error("Error decompressing message", {
359
- consumerName: String(consumerName),
360
- queueName: consumer.queue.name,
361
- contentEncoding: msg.properties.contentEncoding,
384
+ validateSchema(schema, data, context, msg) {
385
+ const rawValidation = schema["~standard"].validate(data);
386
+ const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
387
+ return _swan_io_boxed.Future.fromPromise(validationPromise).mapError((error) => new _amqp_contract_core.TechnicalError(`Error validating ${context.field}`, error)).mapOkToResult((result) => {
388
+ if (result.issues) return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
389
+ return _swan_io_boxed.Result.Ok(result.value);
390
+ }).tapError((error) => {
391
+ this.logger?.error(`${context.field} validation failed`, {
392
+ consumerName: context.consumerName,
393
+ queueName: context.queueName,
362
394
  error
363
395
  });
364
- this.amqpClient.channel.nack(msg, false, false);
365
- }).mapError(() => void 0);
366
- const parseMessage = (buffer) => {
367
- const parseResult = _swan_io_boxed.Result.fromExecution(() => JSON.parse(buffer.toString()));
368
- if (parseResult.isError()) {
369
- this.logger?.error("Error parsing message", {
370
- consumerName: String(consumerName),
371
- queueName: consumer.queue.name,
372
- error: parseResult.error
373
- });
374
- this.amqpClient.channel.nack(msg, false, false);
375
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(void 0));
376
- }
377
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(parseResult.value));
378
- };
379
- const validatePayload = (parsedMessage) => {
380
- const rawValidation = consumer.message.payload["~standard"].validate(parsedMessage);
381
- return _swan_io_boxed.Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapError(() => void 0).mapOkToResult((validationResult) => {
382
- if (validationResult.issues) {
383
- const error = new MessageValidationError(String(consumerName), validationResult.issues);
384
- this.logger?.error("Message payload validation failed", {
385
- consumerName: String(consumerName),
386
- queueName: consumer.queue.name,
387
- error
388
- });
389
- this.amqpClient.channel.nack(msg, false, false);
390
- return _swan_io_boxed.Result.Error(void 0);
391
- }
392
- return _swan_io_boxed.Result.Ok(validationResult.value);
393
- });
396
+ this.amqpClient.nack(msg, false, false);
397
+ });
398
+ }
399
+ /**
400
+ * Parse and validate a message from AMQP.
401
+ * @returns Ok with validated message (payload + headers), or Error (message already nacked)
402
+ */
403
+ parseAndValidateMessage(msg, consumer, consumerName) {
404
+ const context = {
405
+ consumerName: String(consumerName),
406
+ queueName: consumer.queue.name
394
407
  };
395
- const validateHeaders = () => {
396
- const headersSchema = consumer.message.headers;
397
- if (!headersSchema) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
398
- if (!isStandardSchema(headersSchema)) {
399
- const error = new MessageValidationError(String(consumerName), "Invalid headers schema: not a Standard Schema v1 compliant schema");
400
- this.logger?.error("Message headers validation failed", {
401
- consumerName: String(consumerName),
402
- queueName: consumer.queue.name,
403
- error
404
- });
405
- this.amqpClient.channel.nack(msg, false, false);
406
- return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(void 0));
407
- }
408
- const validSchema = headersSchema;
409
- const rawHeaders = msg.properties.headers ?? {};
410
- const rawValidation = validSchema["~standard"].validate(rawHeaders);
411
- return _swan_io_boxed.Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapError(() => void 0).mapOkToResult((validationResult) => {
412
- if (validationResult.issues) {
413
- const error = new MessageValidationError(String(consumerName), validationResult.issues);
414
- this.logger?.error("Message headers validation failed", {
415
- consumerName: String(consumerName),
416
- queueName: consumer.queue.name,
417
- error
418
- });
419
- this.amqpClient.channel.nack(msg, false, false);
420
- return _swan_io_boxed.Result.Error(void 0);
421
- }
422
- return _swan_io_boxed.Result.Ok(validationResult.value);
408
+ const nackAndError = (message, error) => {
409
+ this.logger?.error(message, {
410
+ ...context,
411
+ error
423
412
  });
413
+ this.amqpClient.nack(msg, false, false);
414
+ return new _amqp_contract_core.TechnicalError(message, error);
424
415
  };
425
- const buildConsumedMessage = (validatedPayload) => {
426
- return validateHeaders().mapOk((validatedHeaders) => {
427
- return {
428
- payload: validatedPayload,
429
- headers: validatedHeaders
430
- };
416
+ const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).tapError((error) => {
417
+ this.logger?.error("Failed to decompress message", {
418
+ ...context,
419
+ error
431
420
  });
432
- };
433
- return decompressMessage.flatMapOk(parseMessage).flatMapOk(validatePayload).flatMapOk(buildConsumedMessage);
421
+ this.amqpClient.nack(msg, false, false);
422
+ }).mapOkToResult((buffer) => _swan_io_boxed.Result.fromExecution(() => JSON.parse(buffer.toString())).mapError((error) => nackAndError("Failed to parse JSON", error))).flatMapOk((parsed) => this.validateSchema(consumer.message.payload, parsed, {
423
+ ...context,
424
+ field: "payload"
425
+ }, msg));
426
+ const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
427
+ ...context,
428
+ field: "headers"
429
+ }, msg) : _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
430
+ return _swan_io_boxed.Future.allFromDict({
431
+ payload: parsePayload,
432
+ headers: parseHeaders
433
+ }).map(_swan_io_boxed.Result.allFromDict);
434
434
  }
435
435
  /**
436
436
  * Consume messages one at a time
437
437
  */
438
438
  consumeSingle(consumerName, consumer, handler) {
439
439
  const queueName = consumer.queue.name;
440
- return _swan_io_boxed.Future.fromPromise(this.amqpClient.channel.consume(queueName, async (msg) => {
440
+ return this.amqpClient.consume(queueName, async (msg) => {
441
441
  if (msg === null) {
442
442
  this.logger?.warn("Consumer cancelled by server", {
443
443
  consumerName: String(consumerName),
@@ -452,7 +452,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
452
452
  consumerName: String(consumerName),
453
453
  queueName
454
454
  });
455
- this.amqpClient.channel.ack(msg);
455
+ this.amqpClient.ack(msg);
456
456
  const durationMs = Date.now() - startTime;
457
457
  (0, _amqp_contract_core.endSpanSuccess)(span);
458
458
  (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), true, durationMs);
@@ -473,9 +473,9 @@ var TypedAmqpWorker = class TypedAmqpWorker {
473
473
  (0, _amqp_contract_core.endSpanError)(span, /* @__PURE__ */ new Error("Message validation failed"));
474
474
  (0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), false, durationMs);
475
475
  }).toPromise();
476
- })).tapOk((reply) => {
477
- this.consumerTags.add(reply.consumerTag);
478
- }).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
476
+ }).tapOk((consumerTag) => {
477
+ this.consumerTags.add(consumerTag);
478
+ }).mapError((error) => new _amqp_contract_core.TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
479
479
  }
480
480
  /**
481
481
  * Handle error in message processing with retry logic.
@@ -540,7 +540,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
540
540
  attemptsBeforeDeadLetter,
541
541
  error: error.message
542
542
  });
543
- this.amqpClient.channel.nack(msg, false, true);
543
+ this.amqpClient.nack(msg, false, true);
544
544
  return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
545
545
  }
546
546
  /**
@@ -622,14 +622,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
622
622
  const deadLetter = consumer.queue.deadLetter;
623
623
  if (!deadLetter) {
624
624
  this.logger?.warn("Cannot retry: queue does not have DLX configured, falling back to nack with requeue", { queueName });
625
- this.amqpClient.channel.nack(msg, false, true);
625
+ this.amqpClient.nack(msg, false, true);
626
626
  return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
627
627
  }
628
628
  const dlxName = deadLetter.exchange.name;
629
629
  const waitRoutingKey = `${queueName}-wait`;
630
- this.amqpClient.channel.ack(msg);
630
+ this.amqpClient.ack(msg);
631
631
  const content = this.parseMessageContentForRetry(msg, queueName);
632
- return _swan_io_boxed.Future.fromPromise(this.amqpClient.channel.publish(dlxName, waitRoutingKey, content, {
632
+ return this.amqpClient.publish(dlxName, waitRoutingKey, content, {
633
633
  ...msg.properties,
634
634
  expiration: delayMs.toString(),
635
635
  headers: {
@@ -638,14 +638,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
638
638
  "x-last-error": error.message,
639
639
  "x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now()
640
640
  }
641
- })).mapError((error$1) => new TechnicalError("Failed to publish message for retry", error$1)).mapOkToResult((published) => {
641
+ }).mapOkToResult((published) => {
642
642
  if (!published) {
643
643
  this.logger?.error("Failed to publish message for retry (write buffer full)", {
644
644
  queueName,
645
645
  waitRoutingKey,
646
646
  retryCount: newRetryCount
647
647
  });
648
- return _swan_io_boxed.Result.Error(new TechnicalError("Failed to publish message for retry (write buffer full)"));
648
+ return _swan_io_boxed.Result.Error(new _amqp_contract_core.TechnicalError("Failed to publish message for retry (write buffer full)"));
649
649
  }
650
650
  this.logger?.info("Message published for retry", {
651
651
  queueName,
@@ -667,7 +667,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
667
667
  queueName,
668
668
  deliveryTag: msg.fields.deliveryTag
669
669
  });
670
- this.amqpClient.channel.nack(msg, false, false);
670
+ this.amqpClient.nack(msg, false, false);
671
671
  }
672
672
  };
673
673
 
@@ -736,7 +736,11 @@ function defineHandlers(contract, handlers) {
736
736
  exports.MessageValidationError = MessageValidationError;
737
737
  exports.NonRetryableError = NonRetryableError;
738
738
  exports.RetryableError = RetryableError;
739
- exports.TechnicalError = TechnicalError;
740
739
  exports.TypedAmqpWorker = TypedAmqpWorker;
741
740
  exports.defineHandler = defineHandler;
742
- exports.defineHandlers = defineHandlers;
741
+ exports.defineHandlers = defineHandlers;
742
+ exports.isHandlerError = isHandlerError;
743
+ exports.isNonRetryableError = isNonRetryableError;
744
+ exports.isRetryableError = isRetryableError;
745
+ exports.nonRetryable = nonRetryable;
746
+ exports.retryable = retryable;