@rocketmq/core 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -5,6 +5,10 @@
5
5
  * a single user-facing API. Hides all internal wiring behind `connect()`
6
6
  * and `mq.queue()`.
7
7
  *
8
+ * Supports two schema input styles:
9
+ * - Decorator classes: `mq.assertQueue('q', MyClass)`
10
+ * - Zod schemas: `mq.assertQueue('q', { name: 'Msg', schema: zodObj })`
11
+ *
8
12
  * Usage:
9
13
  * const mq = await connect();
10
14
  * const orders = mq.queue("orders", Order);
@@ -19,25 +23,60 @@ import {
19
23
  type AssertQueueOptions,
20
24
  type AssertQueueReply,
21
25
  type ConsumeMessage,
26
+ type ConsumeOptions,
22
27
  type EmptyReply,
23
28
  type PublishOptions,
24
29
  } from '@rocketmq/amqp';
25
- import { toProto } from '@rocketmq/protobuf';
26
- import { defaultRegistry, type SchemaRegistry } from '@rocketmq/schema';
30
+ import { defaultRegistry, type Constructor, type SchemaRegistry } from '@rocketmq/schema';
27
31
  import { JsonSerializer, type Serializer } from '@rocketmq/serializer';
32
+ import { z } from 'zod';
33
+ import { rethrowBrokerOr } from './error-parser.js';
28
34
  import { ConnectionError, ConsumeError, PublishError, QueueError } from './errors.js';
29
35
  import { QueueHandle } from './queue-handle.js';
36
+ import { isConstructorInput, resolveProto, type SchemaInput } from './schema-resolver.js';
37
+
38
+ export type TypedConsumeHandler<T> = (msg: T, raw: ConsumeMessage) => void | Promise<void>;
39
+
40
+ export interface RocketAssertQueueOptions extends AssertQueueOptions {
41
+ /** Force update an existing queue's schema even if it conflicts. */
42
+ schemaOverride?: boolean;
43
+ /** Remove the schema binding from an existing queue. */
44
+ schemaDelete?: boolean;
45
+ }
46
+
47
+ export interface RocketConsumeOptions extends ConsumeOptions {
48
+ /**
49
+ * Explicit schema class for consumer compatibility validation.
50
+ *
51
+ * TypeScript generics are erased at runtime, so `consume<T>` alone
52
+ * cannot send `T`'s proto to the broker. Pass the class here so the
53
+ * broker can verify the consumer's expected fields match the queue's
54
+ * declared schema.
55
+ *
56
+ * Example:
57
+ * await mq.consume<Order>('orders', handler, { consumerSchema: Order });
58
+ */
59
+ consumerSchema?: Constructor;
60
+ }
61
+
62
+ const NameSchema = z.string().min(1, 'Name cannot be empty').max(255, 'Name too long');
63
+
64
+ const RocketOptionsSchema = z.object({
65
+ url: z.string().url('AMQP connection URL must be a valid URL (e.g. amqp://localhost)'),
66
+ serializer: z.any().optional(),
67
+ });
30
68
 
31
69
  export interface RocketOptions {
32
- /** AMQP connection URL. Default: amqp://guest:guest@localhost:5672 */
33
- url?: string;
70
+ /** AMQP connection URL. */
71
+ url: string;
34
72
  /** Custom serializer. Default: JsonSerializer. */
35
73
  serializer?: Serializer;
36
74
  }
37
75
 
38
76
  /** Opens a connection + channel ready for schema-aware operations. */
39
- export async function connect(opts: RocketOptions = {}): Promise<RocketMQ> {
40
- const url = opts.url ?? 'amqp://guest:guest@localhost:5672';
77
+ export async function connect(opts: RocketOptions): Promise<RocketMQ> {
78
+ const validated = RocketOptionsSchema.parse(opts);
79
+ const url = validated.url;
41
80
  const serializer = opts.serializer ?? new JsonSerializer();
42
81
 
43
82
  try {
@@ -50,12 +89,24 @@ export async function connect(opts: RocketOptions = {}): Promise<RocketMQ> {
50
89
  }
51
90
 
52
91
  export class RocketMQ {
92
+ private lastChannelError: Error | null = null;
93
+ private readonly errorListener = (err: Error): void => {
94
+ this.lastChannelError = err;
95
+ };
96
+
53
97
  constructor(
54
98
  private readonly conn: AmqpConnection,
55
99
  private readonly ch: AmqpChannel,
56
100
  private readonly registry: SchemaRegistry,
57
101
  private readonly serializer: Serializer,
58
- ) {}
102
+ ) {
103
+ // amqplib emits an 'error' event on the channel when the broker sends a Channel.Close
104
+ // (e.g., due to a 406 PRECONDITION_FAILED for schema mismatches). If this event is
105
+ // unhandled, it will crash the Node process. amqplib also rejects the pending
106
+ // Promise (like consume or assertQueue), which we catch and format into a typed
107
+ // SchemaValidationError. So we can safely swallow the raw event here.
108
+ this.ch.raw.on('error', this.errorListener);
109
+ }
59
110
 
60
111
  /** Exposes the raw AmqpChannel for event listeners (e.g. broker errors). */
61
112
  get channel(): AmqpChannel {
@@ -68,63 +119,87 @@ export class RocketMQ {
68
119
  * Declares the queue with schema metadata in AMQP arguments so the
69
120
  * broker compiles and validates messages. Returns a QueueHandle<T>
70
121
  * for type-safe send/consume.
122
+ *
123
+ * Usage:
124
+ * const orders = await mq.queue('orders', OrderClass);
125
+ * const orders = await mq.queue('orders', zodSchema);
71
126
  */
72
127
  async queue<T>(
73
128
  name: string,
74
- schema: new (...args: unknown[]) => T,
75
- opts?: AssertQueueOptions,
129
+ schema: SchemaInput<T>,
130
+ opts?: RocketAssertQueueOptions,
76
131
  ): Promise<QueueHandle<T>> {
77
132
  await this.assertQueue(name, schema, opts);
78
- return new QueueHandle<T>(name, this.ch, this.registry, this.serializer);
133
+ return new QueueHandle<T>(name, this, schema);
79
134
  }
80
135
 
81
136
  /**
82
- * Declares a queue with an optional schema class.
137
+ * Declares a queue with an optional schema (decorator class or Zod).
83
138
  *
84
139
  * When a schema is provided the proto3 definition is sent as AMQP
85
140
  * queue arguments (`x-schema`, `x-schema-type`, `x-schema-message`)
86
141
  * so the broker compiles and validates messages inline.
142
+ *
143
+ * Usage:
144
+ * await mq.assertQueue('orders', OrderClass);
145
+ * await mq.assertQueue('orders', { name: 'Order', schema: zodSchema });
146
+ * await mq.assertQueue('orders', zodSchema); // message name derived from queue name
87
147
  */
88
148
  async assertQueue(
89
149
  name: string,
90
- schema?: Function,
91
- opts?: AssertQueueOptions,
150
+ schema?: SchemaInput,
151
+ opts?: RocketAssertQueueOptions,
92
152
  ): Promise<AssertQueueReply> {
153
+ NameSchema.parse(name);
154
+
93
155
  if (!schema) {
94
156
  return this.ch.assertQueue(name, opts);
95
157
  }
96
158
 
97
- const proto = toProto(schema);
98
- // WHY: decorators write to defaultRegistry, not the instance registry
99
- const fields = defaultRegistry.getFields(schema);
100
- const subject = defaultRegistry.getSubject(schema);
101
-
102
- this.registry.register(name, {
103
- ctor: schema,
104
- name: schema.name,
105
- subject,
106
- fields: [...fields],
107
- });
108
-
109
- // Schema metadata sent as queue arguments so the broker compiles
110
- // a proto descriptor and validates JSON payloads on publish.
111
- const schemaArgs: Record<string, string> = {
112
- 'x-schema': proto,
113
- 'x-schema-type': 'protobuf',
114
- 'x-schema-message': schema.name,
115
- };
159
+ const schemaArgs = this.buildSchemaQueueArgs(name, schema, opts);
116
160
 
117
161
  try {
118
162
  return await this.ch.assertQueue(name, {
119
163
  ...opts,
120
- arguments: {
121
- ...opts?.arguments,
122
- ...schemaArgs,
123
- },
164
+ arguments: { ...opts?.arguments, ...schemaArgs },
124
165
  });
125
166
  } catch (err) {
126
- throw new QueueError(`Failed to assert queue '${name}'`, err);
167
+ const actualErr = this.lastChannelError ?? err;
168
+ rethrowBrokerOr(actualErr, new QueueError(`Failed to assert queue '${name}'`, actualErr));
169
+ }
170
+ }
171
+
172
+ private buildSchemaQueueArgs(
173
+ name: string,
174
+ schema: SchemaInput,
175
+ opts?: RocketAssertQueueOptions,
176
+ ): Record<string, string | boolean> {
177
+ const resolved = resolveProto(schema, name);
178
+
179
+ // WHY: only register decorator classes — Zod schemas have no constructor
180
+ if (isConstructorInput(schema)) {
181
+ this.registerConstructorSchema(name, schema);
127
182
  }
183
+
184
+ const schemaArgs: Record<string, string | boolean> = {
185
+ 'x-schema': resolved.proto,
186
+ 'x-schema-type': 'protobuf',
187
+ 'x-schema-message': resolved.messageName,
188
+ };
189
+ if (opts?.schemaOverride) schemaArgs['x-schema-override'] = true;
190
+ if (opts?.schemaDelete) schemaArgs['x-schema-delete'] = true;
191
+
192
+ return schemaArgs;
193
+ }
194
+
195
+ /** Stores decorator class metadata in the registry for consumer lookups. */
196
+ private registerConstructorSchema(name: string, schema: Constructor): void {
197
+ this.registry.register(name, {
198
+ ctor: schema,
199
+ name: schema.name,
200
+ subject: defaultRegistry.getSubject(schema),
201
+ fields: [...defaultRegistry.getFields(schema)],
202
+ });
128
203
  }
129
204
 
130
205
  /** Declares an exchange (passthrough to AMQP layer). */
@@ -133,11 +208,14 @@ export class RocketMQ {
133
208
  type: string,
134
209
  opts?: AssertExchangeOptions,
135
210
  ): Promise<AssertExchangeReply> {
211
+ NameSchema.parse(name);
136
212
  return this.ch.assertExchange(name, type, opts);
137
213
  }
138
214
 
139
215
  /** Binds a queue to an exchange (passthrough to AMQP layer). */
140
216
  async bindQueue(queue: string, exchange: string, routingKey: string): Promise<EmptyReply> {
217
+ NameSchema.parse(queue);
218
+ NameSchema.parse(exchange);
141
219
  return this.ch.bindQueue(queue, exchange, routingKey);
142
220
  }
143
221
 
@@ -148,6 +226,7 @@ export class RocketMQ {
148
226
  * Validation is handled broker-side via the queue's compiled schema.
149
227
  */
150
228
  sendToQueue(queue: string, payload: Record<string, unknown>, opts?: PublishOptions): boolean {
229
+ NameSchema.parse(queue);
151
230
  try {
152
231
  const buf = this.serializer.serialize(payload);
153
232
  return this.ch.sendToQueue(queue, buf, {
@@ -156,7 +235,7 @@ export class RocketMQ {
156
235
  ...opts,
157
236
  });
158
237
  } catch (err) {
159
- throw new PublishError(queue, err);
238
+ throw new PublishError(queue, payload, err);
160
239
  }
161
240
  }
162
241
 
@@ -177,71 +256,105 @@ export class RocketMQ {
177
256
  ...opts,
178
257
  });
179
258
  } catch (err) {
180
- throw new PublishError(`exchange:${exchange} routingKey:${routingKey}`, err);
259
+ throw new PublishError(`exchange:${exchange} routingKey:${routingKey}`, payload, err);
181
260
  }
182
261
  }
183
262
 
184
263
  /**
185
- * Subscribes to a queue with JSON deserialization.
264
+ * Subscribes to a queue with JSON deserialization and broker-side
265
+ * consumer schema validation.
186
266
  *
187
- * When a schema was registered via `assertQueue(name, Schema)`,
188
- * the consumer's proto definition is sent to the broker for subset
189
- * checking the broker rejects consumers expecting fields the
190
- * queue's schema doesn't define.
267
+ * Accepts either a decorator class or a ZodSchemaInput for the schema
268
+ * parameter. The schema serves two purposes:
269
+ * - **Compile-time**: TypeScript infers `T`, so `msg` is fully typed.
270
+ * - **Runtime**: Its proto definition is sent to the broker as AMQP
271
+ * arguments so the broker can verify subset compatibility.
191
272
  *
192
273
  * Usage:
193
- * await mq.assertQueue("orders", Order);
194
- * await mq.consume("orders", (msg) => console.log(msg));
274
+ * await mq.consume("orders", Order, (msg) => console.log(msg.id));
275
+ * await mq.consume("orders", { name: "Order", schema: zodSchema }, handler);
276
+ * await mq.consume("orders", zodSchema, handler);
195
277
  */
196
- async consume<T = Record<string, unknown>>(
278
+ async consume<T>(
197
279
  queue: string,
198
- handler: (msg: T, raw: ConsumeMessage) => void,
199
- opts?: import('@rocketmq/amqp').ConsumeOptions,
280
+ schema: SchemaInput<T>,
281
+ handler: TypedConsumeHandler<T>,
282
+ opts?: RocketConsumeOptions,
200
283
  ): Promise<string> {
201
- // Build consumer schema arguments for broker-side subset validation.
202
- const consumerArgs = this.buildConsumerSchemaArgs(queue);
284
+ NameSchema.parse(queue);
285
+ const consumerArgs = this.buildConsumerSchemaArgs(schema, queue);
203
286
 
204
287
  try {
205
- const reply = await this.ch.consume(
206
- queue,
207
- (msg) => {
208
- if (!msg) return;
209
- try {
210
- const body = this.serializer.deserialize(msg.content) as T;
211
- handler(body, msg);
212
- } catch (err) {
213
- console.error(`[rocketmq] deserialization error on queue '${queue}':`, err);
214
- }
215
- },
216
- {
217
- ...opts,
218
- arguments: {
219
- ...opts?.arguments,
220
- ...consumerArgs,
221
- },
222
- },
223
- );
288
+ const reply = await this.ch.consume(queue, this.createConsumeCallback(queue, handler), {
289
+ noAck: true,
290
+ ...opts,
291
+ arguments: { ...opts?.arguments, ...consumerArgs },
292
+ });
224
293
  return reply.consumerTag;
225
294
  } catch (err) {
226
- throw new ConsumeError(`Failed to consume from queue '${queue}'`, err);
295
+ const actualErr = this.lastChannelError ?? err;
296
+ rethrowBrokerOr(
297
+ actualErr,
298
+ new ConsumeError(`Failed to consume from queue '${queue}'`, actualErr),
299
+ );
227
300
  }
228
301
  }
229
302
 
230
- /**
231
- * Builds AMQP arguments for consumer schema compatibility checking.
232
- *
233
- * Returns `x-consumer-schema` + `x-consumer-schema-message` if the
234
- * queue has a registered schema, otherwise an empty object.
235
- */
236
- private buildConsumerSchemaArgs(queue: string): Record<string, string> {
303
+ private createConsumeCallback<T>(
304
+ queue: string,
305
+ handler: TypedConsumeHandler<T>,
306
+ ): (msg: ConsumeMessage | null) => void {
307
+ return async (msg: ConsumeMessage | null) => {
308
+ if (!msg) return;
309
+ try {
310
+ const body = this.serializer.deserialize(msg.content) as T;
311
+ await handler(body, msg);
312
+ } catch (err) {
313
+ console.error(`[rocketmq] deserialization error on queue '${queue}':`, err);
314
+ }
315
+ };
316
+ }
317
+
318
+ private buildConsumerSchemaArgs(
319
+ schema: SchemaInput | undefined,
320
+ queue: string,
321
+ ): Record<string, string> {
322
+ if (schema) {
323
+ return this.resolveConsumerArgs(schema, queue);
324
+ }
325
+ return this.fallbackConsumerArgs(queue);
326
+ }
327
+
328
+ /** Resolves consumer schema args from an explicit SchemaInput. */
329
+ private resolveConsumerArgs(schema: SchemaInput, queue: string): Record<string, string> {
330
+ try {
331
+ const resolved = resolveProto(schema, queue);
332
+ return {
333
+ 'x-consumer-schema': resolved.proto,
334
+ 'x-consumer-schema-message': resolved.messageName,
335
+ };
336
+ } catch (err) {
337
+ const name = isConstructorInput(schema)
338
+ ? (schema as Constructor).name
339
+ : (schema as { name: string }).name;
340
+ console.error(
341
+ `[rocketmq] failed to build consumer schema for ${name}:`,
342
+ (err as Error).message,
343
+ );
344
+ return {};
345
+ }
346
+ }
347
+
348
+ /** Falls back to registry lookup when no explicit schema is provided. */
349
+ private fallbackConsumerArgs(queue: string): Record<string, string> {
237
350
  const meta = this.registry.lookup(queue);
238
351
  if (!meta) return {};
239
352
 
240
353
  try {
241
- const proto = toProto(meta.ctor);
354
+ const resolved = resolveProto(meta.ctor, queue);
242
355
  return {
243
- 'x-consumer-schema': proto,
244
- 'x-consumer-schema-message': meta.name,
356
+ 'x-consumer-schema': resolved.proto,
357
+ 'x-consumer-schema-message': resolved.messageName,
245
358
  };
246
359
  } catch {
247
360
  // WHY: ctor may lack @Field() decorators (e.g. untyped consumers)
@@ -266,6 +379,7 @@ export class RocketMQ {
266
379
 
267
380
  /** Closes channel and connection. */
268
381
  async close(): Promise<void> {
382
+ this.ch.raw.removeListener('error', this.errorListener);
269
383
  await this.ch.close();
270
384
  await this.conn.close();
271
385
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Machine-readable error codes returned by the broker.
3
+ *
4
+ * These mirror the Rust `ErrorCode` enum 1:1. The client uses them
5
+ * to construct specific exception subclasses and provide programmatic
6
+ * access to the error category.
7
+ *
8
+ * Usage:
9
+ * if (parsed.code === BrokerErrorCode.SchemaTypeMismatch) { ... }
10
+ */
11
+ export enum BrokerErrorCode {
12
+ /** Consumer/publisher field type doesn't match queue schema. */
13
+ SchemaTypeMismatch = 'SchemaTypeMismatch',
14
+ /** Consumer has fields not present in queue schema. */
15
+ SchemaExtraFields = 'SchemaExtraFields',
16
+ /** JSON payload is missing required schema fields. */
17
+ SchemaMissingFields = 'SchemaMissingFields',
18
+ /** Re-declaration schema conflicts with existing queue schema. */
19
+ SchemaConflict = 'SchemaConflict',
20
+ /** Proto compilation failed (syntax error, etc.). */
21
+ SchemaCompileFailed = 'SchemaCompileFailed',
22
+ /** Unsupported schema type (not protobuf). */
23
+ SchemaUnsupportedType = 'SchemaUnsupportedType',
24
+ /** Schema validation on publish: wrong JSON value types. */
25
+ ValidationTypeMismatch = 'ValidationTypeMismatch',
26
+ /** Payload is not valid JSON. */
27
+ ValidationInvalidJson = 'ValidationInvalidJson',
28
+ /** Required AMQP argument missing (x-schema-type, x-schema-message). */
29
+ MissingArgument = 'MissingArgument',
30
+ }
@@ -0,0 +1,223 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ parseBrokerError,
4
+ extractReplyText,
5
+ formatBrokerError,
6
+ formatBrokerError,
7
+ protoToTsType,
8
+ wrapBrokerError,
9
+ rethrowBrokerOr,
10
+ } from './error-parser.js';
11
+ import { BrokerErrorCode } from './error-codes.js';
12
+ import { SchemaValidationError } from './errors.js';
13
+
14
+ describe('protoToTsType', () => {
15
+ it('maps numeric proto types to number', () => {
16
+ expect(protoToTsType('double')).toBe('number');
17
+ expect(protoToTsType('float')).toBe('number');
18
+ expect(protoToTsType('int32')).toBe('number');
19
+ expect(protoToTsType('int64')).toBe('number');
20
+ expect(protoToTsType('uint32')).toBe('number');
21
+ expect(protoToTsType('sint64')).toBe('number');
22
+ expect(protoToTsType('fixed32')).toBe('number');
23
+ expect(protoToTsType('sfixed64')).toBe('number');
24
+ });
25
+
26
+ it('maps bool to boolean', () => {
27
+ expect(protoToTsType('bool')).toBe('boolean');
28
+ });
29
+
30
+ it('maps string to string', () => {
31
+ expect(protoToTsType('string')).toBe('string');
32
+ });
33
+
34
+ it('maps bytes to Uint8Array', () => {
35
+ expect(protoToTsType('bytes')).toBe('Uint8Array');
36
+ });
37
+
38
+ it('returns unknown types as-is', () => {
39
+ expect(protoToTsType('customType')).toBe('customType');
40
+ });
41
+ });
42
+
43
+ describe('parseBrokerError', () => {
44
+ it('parses valid JSON with all fields', () => {
45
+ const json = JSON.stringify({
46
+ code: 'SchemaTypeMismatch',
47
+ queue: 'test-queue',
48
+ fields: [{ name: 'id', expected: 'double', got: 'string' }],
49
+ });
50
+
51
+ const result = parseBrokerError(json);
52
+
53
+ expect(result).not.toBeNull();
54
+ expect(result!.code).toBe(BrokerErrorCode.SchemaTypeMismatch);
55
+ expect(result!.queue).toBe('test-queue');
56
+ expect(result!.fields).toHaveLength(1);
57
+ expect(result!.fields[0].name).toBe('id');
58
+ expect(result!.fields[0].expected).toBe('double');
59
+ expect(result!.fields[0].got).toBe('string');
60
+ });
61
+
62
+ it('returns null for plain AMQP strings', () => {
63
+ expect(parseBrokerError('PRECONDITION_FAILED - no such queue')).toBeNull();
64
+ });
65
+
66
+ it('returns null for non-JSON starting with {', () => {
67
+ expect(parseBrokerError('{not valid json')).toBeNull();
68
+ });
69
+
70
+ it('returns null when code is missing', () => {
71
+ expect(parseBrokerError('{"queue":"q"}')).toBeNull();
72
+ });
73
+
74
+ it('returns null when queue is missing', () => {
75
+ expect(parseBrokerError('{"code":"SchemaTypeMismatch"}')).toBeNull();
76
+ });
77
+
78
+ it('handles missing fields array gracefully', () => {
79
+ const json = JSON.stringify({ code: 'SchemaConflict', queue: 'q' });
80
+ const result = parseBrokerError(json);
81
+
82
+ expect(result).not.toBeNull();
83
+ expect(result!.fields).toEqual([]);
84
+ });
85
+
86
+ it('parses truncated flag', () => {
87
+ const json = JSON.stringify({
88
+ code: 'SchemaTypeMismatch',
89
+ queue: 'q',
90
+ fields: [{ name: 'a', expected: 'int32', got: 'string' }],
91
+ truncated: true,
92
+ });
93
+
94
+ const result = parseBrokerError(json);
95
+ expect(result!.truncated).toBe(true);
96
+ });
97
+
98
+ it('returns null if parsed JSON is not an object', () => {
99
+ expect(parseBrokerError('true')).toBeNull();
100
+ expect(parseBrokerError('null')).toBeNull();
101
+ expect(parseBrokerError('"string"')).toBeNull();
102
+ expect(parseBrokerError('[]')).toBeNull();
103
+ });
104
+ });
105
+
106
+ describe('extractReplyText', () => {
107
+ it('extracts reply text from amqplib error', () => {
108
+ const err = new Error(
109
+ 'Channel closed by server: 406 (PRECONDITION-FAILED) with message "{"code":"SchemaTypeMismatch","queue":"q"}"',
110
+ );
111
+ const result = extractReplyText(err);
112
+ expect(result).toBe('{"code":"SchemaTypeMismatch","queue":"q"}');
113
+ });
114
+
115
+ it('returns null for non-Error values', () => {
116
+ expect(extractReplyText('string')).toBeNull();
117
+ expect(extractReplyText(42)).toBeNull();
118
+ expect(extractReplyText(null)).toBeNull();
119
+ });
120
+
121
+ it('returns null for errors without reply text pattern', () => {
122
+ expect(extractReplyText(new Error('some other error'))).toBeNull();
123
+ });
124
+ });
125
+
126
+ describe('formatBrokerError', () => {
127
+ it('formats error with field details using TS types', () => {
128
+ const msg = formatBrokerError({
129
+ code: BrokerErrorCode.SchemaTypeMismatch,
130
+ queue: 'orders',
131
+ fields: [
132
+ { name: 'id', expected: 'double', got: 'string' },
133
+ { name: 'count', expected: 'int32', got: 'bool' },
134
+ ],
135
+ truncated: false,
136
+ });
137
+
138
+ expect(msg).toContain('[SchemaTypeMismatch]');
139
+ expect(msg).toContain("Queue 'orders'");
140
+ expect(msg).toContain('id: expected number, got string');
141
+ expect(msg).toContain('count: expected number, got boolean');
142
+ });
143
+
144
+ it('formats error without fields', () => {
145
+ const msg = formatBrokerError({
146
+ code: BrokerErrorCode.SchemaConflict,
147
+ queue: 'q',
148
+ fields: [],
149
+ truncated: false,
150
+ });
151
+
152
+ expect(msg).toBe("[SchemaConflict] Queue 'q'");
153
+ });
154
+
155
+ it('adds truncation notice', () => {
156
+ const msg = formatBrokerError({
157
+ code: BrokerErrorCode.SchemaTypeMismatch,
158
+ queue: 'q',
159
+ fields: [{ name: 'a', expected: 'int32', got: 'string' }],
160
+ truncated: true,
161
+ });
162
+
163
+ expect(msg).toContain('(some fields omitted)');
164
+ });
165
+ });
166
+
167
+ describe('SchemaValidationError', () => {
168
+ it('is an instance of Error and SchemaValidationError', () => {
169
+ const err = new SchemaValidationError(
170
+ BrokerErrorCode.SchemaTypeMismatch,
171
+ 'test-queue',
172
+ [{ name: 'id', expected: 'number', got: 'string' }],
173
+ 'test message',
174
+ );
175
+
176
+ expect(err).toBeInstanceOf(Error);
177
+ expect(err).toBeInstanceOf(SchemaValidationError);
178
+ expect(err.code).toBe(BrokerErrorCode.SchemaTypeMismatch);
179
+ expect(err.queue).toBe('test-queue');
180
+ expect(err.fields).toHaveLength(1);
181
+ expect(err.name).toBe('SchemaValidationError');
182
+ });
183
+
184
+ it('preserves cause', () => {
185
+ const cause = new Error('original');
186
+ const err = new SchemaValidationError('code', 'q', [], 'msg', cause);
187
+ expect(err.cause).toBe(cause);
188
+ });
189
+ });
190
+
191
+ describe('wrapBrokerError', () => {
192
+ it('returns null if error is missing reply text', () => {
193
+ expect(wrapBrokerError(new Error('no reply text here'))).toBeNull();
194
+ });
195
+
196
+ it('returns null if reply text is invalid JSON', () => {
197
+ const err = new Error('with message "not json"');
198
+ expect(wrapBrokerError(err)).toBeNull();
199
+ });
200
+
201
+ it('returns a SchemaValidationError on valid broker error', () => {
202
+ const err = new Error(
203
+ 'with message "{"code":"SchemaTypeMismatch","queue":"q","fields":[{"name":"id","expected":"double","got":"string"}]}"',
204
+ );
205
+ const result = wrapBrokerError(err);
206
+ expect(result).toBeInstanceOf(SchemaValidationError);
207
+ expect(result!.code).toBe(BrokerErrorCode.SchemaTypeMismatch);
208
+ expect(result!.queue).toBe('q');
209
+ expect(result!.fields[0].expected).toBe('number');
210
+ });
211
+ });
212
+
213
+ describe('rethrowBrokerOr', () => {
214
+ it('throws SchemaValidationError if present', () => {
215
+ const err = new Error('with message "{"code":"SchemaTypeMismatch","queue":"q"}"');
216
+ expect(() => rethrowBrokerOr(err, new Error('fallback'))).toThrow(SchemaValidationError);
217
+ });
218
+
219
+ it('throws fallback error otherwise', () => {
220
+ const err = new Error('plain error');
221
+ expect(() => rethrowBrokerOr(err, new Error('fallback'))).toThrow('fallback');
222
+ });
223
+ });