@rocketmq/core 0.1.2 → 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/.turbo/turbo-build.log +10 -8
- package/CHANGELOG.md +14 -0
- package/README.md +67 -6
- package/dist/index.cjs +4829 -163
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +255 -33
- package/dist/index.d.ts +255 -33
- package/dist/index.js +4825 -165
- package/dist/index.js.map +1 -0
- package/package.json +6 -5
- package/src/client.test.ts +256 -13
- package/src/client.ts +195 -81
- package/src/error-codes.ts +30 -0
- package/src/error-parser.test.ts +223 -0
- package/src/error-parser.ts +189 -0
- package/src/errors.test.ts +14 -11
- package/src/errors.ts +40 -3
- package/src/index.ts +31 -1
- package/src/queue-handle.test.ts +14 -140
- package/src/queue-handle.ts +8 -66
- package/src/schema-resolver.test.ts +112 -0
- package/src/schema-resolver.ts +99 -0
- package/tsup.config.ts +1 -0
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 {
|
|
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.
|
|
33
|
-
url
|
|
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
|
|
40
|
-
const
|
|
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:
|
|
75
|
-
opts?:
|
|
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
|
|
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?:
|
|
91
|
-
opts?:
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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.
|
|
194
|
-
* await mq.consume("orders",
|
|
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
|
|
278
|
+
async consume<T>(
|
|
197
279
|
queue: string,
|
|
198
|
-
|
|
199
|
-
|
|
280
|
+
schema: SchemaInput<T>,
|
|
281
|
+
handler: TypedConsumeHandler<T>,
|
|
282
|
+
opts?: RocketConsumeOptions,
|
|
200
283
|
): Promise<string> {
|
|
201
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
354
|
+
const resolved = resolveProto(meta.ctor, queue);
|
|
242
355
|
return {
|
|
243
|
-
'x-consumer-schema': proto,
|
|
244
|
-
'x-consumer-schema-message':
|
|
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
|
+
});
|