@lindorm/iris 0.1.0 → 0.2.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.
Files changed (121) hide show
  1. package/README.md +1683 -0
  2. package/dist/classes/IrisSession.d.ts +28 -0
  3. package/dist/classes/IrisSession.d.ts.map +1 -0
  4. package/dist/classes/IrisSession.js +42 -0
  5. package/dist/classes/IrisSession.js.map +1 -0
  6. package/dist/classes/IrisSource.d.ts +6 -5
  7. package/dist/classes/IrisSource.d.ts.map +1 -1
  8. package/dist/classes/IrisSource.js +17 -33
  9. package/dist/classes/IrisSource.js.map +1 -1
  10. package/dist/classes/index.d.ts +1 -0
  11. package/dist/classes/index.d.ts.map +1 -1
  12. package/dist/classes/index.js +1 -0
  13. package/dist/classes/index.js.map +1 -1
  14. package/dist/cli.d.ts +3 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +35 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/interfaces/IrisDriver.d.ts +4 -2
  19. package/dist/interfaces/IrisDriver.d.ts.map +1 -1
  20. package/dist/interfaces/IrisMessagingProvider.d.ts +21 -0
  21. package/dist/interfaces/IrisMessagingProvider.d.ts.map +1 -0
  22. package/dist/interfaces/IrisMessagingProvider.js +3 -0
  23. package/dist/interfaces/IrisMessagingProvider.js.map +1 -0
  24. package/dist/interfaces/IrisSession.d.ts +4 -0
  25. package/dist/interfaces/IrisSession.d.ts.map +1 -0
  26. package/dist/{internal/types/iris-source-init.js → interfaces/IrisSession.js} +1 -1
  27. package/dist/interfaces/IrisSession.js.map +1 -0
  28. package/dist/interfaces/IrisSource.d.ts +8 -19
  29. package/dist/interfaces/IrisSource.d.ts.map +1 -1
  30. package/dist/interfaces/index.d.ts +2 -0
  31. package/dist/interfaces/index.d.ts.map +1 -1
  32. package/dist/interfaces/index.js +2 -0
  33. package/dist/interfaces/index.js.map +1 -1
  34. package/dist/internal/cli/commands/generate-message.d.ts +7 -0
  35. package/dist/internal/cli/commands/generate-message.d.ts.map +1 -0
  36. package/dist/internal/cli/commands/generate-message.js +77 -0
  37. package/dist/internal/cli/commands/generate-message.js.map +1 -0
  38. package/dist/internal/cli/commands/init.d.ts +8 -0
  39. package/dist/internal/cli/commands/init.d.ts.map +1 -0
  40. package/dist/internal/cli/commands/init.js +112 -0
  41. package/dist/internal/cli/commands/init.js.map +1 -0
  42. package/dist/internal/cli/commands/register-generate.d.ts +3 -0
  43. package/dist/internal/cli/commands/register-generate.d.ts.map +1 -0
  44. package/dist/internal/cli/commands/register-generate.js +20 -0
  45. package/dist/internal/cli/commands/register-generate.js.map +1 -0
  46. package/dist/internal/cli/commands/register-init.d.ts +3 -0
  47. package/dist/internal/cli/commands/register-init.d.ts.map +1 -0
  48. package/dist/internal/cli/commands/register-init.js +16 -0
  49. package/dist/internal/cli/commands/register-init.js.map +1 -0
  50. package/dist/internal/drivers/kafka/classes/KafkaDriver.d.ts +5 -3
  51. package/dist/internal/drivers/kafka/classes/KafkaDriver.d.ts.map +1 -1
  52. package/dist/internal/drivers/kafka/classes/KafkaDriver.js +12 -16
  53. package/dist/internal/drivers/kafka/classes/KafkaDriver.js.map +1 -1
  54. package/dist/internal/drivers/kafka/classes/KafkaMessageBus.d.ts.map +1 -1
  55. package/dist/internal/drivers/kafka/classes/KafkaMessageBus.js.map +1 -1
  56. package/dist/internal/drivers/kafka/classes/KafkaRpcServer.js.map +1 -1
  57. package/dist/internal/drivers/kafka/classes/KafkaWorkerQueue.d.ts.map +1 -1
  58. package/dist/internal/drivers/kafka/classes/KafkaWorkerQueue.js.map +1 -1
  59. package/dist/internal/drivers/kafka/utils/create-kafka-consumer.js +1 -1
  60. package/dist/internal/drivers/kafka/utils/create-kafka-consumer.js.map +1 -1
  61. package/dist/internal/drivers/kafka/utils/stop-kafka-consumer.d.ts +3 -1
  62. package/dist/internal/drivers/kafka/utils/stop-kafka-consumer.d.ts.map +1 -1
  63. package/dist/internal/drivers/kafka/utils/stop-kafka-consumer.js +24 -5
  64. package/dist/internal/drivers/kafka/utils/stop-kafka-consumer.js.map +1 -1
  65. package/dist/internal/drivers/memory/classes/MemoryDriver.d.ts +5 -3
  66. package/dist/internal/drivers/memory/classes/MemoryDriver.d.ts.map +1 -1
  67. package/dist/internal/drivers/memory/classes/MemoryDriver.js +11 -14
  68. package/dist/internal/drivers/memory/classes/MemoryDriver.js.map +1 -1
  69. package/dist/internal/drivers/nats/classes/NatsDriver.d.ts +5 -3
  70. package/dist/internal/drivers/nats/classes/NatsDriver.d.ts.map +1 -1
  71. package/dist/internal/drivers/nats/classes/NatsDriver.js +12 -15
  72. package/dist/internal/drivers/nats/classes/NatsDriver.js.map +1 -1
  73. package/dist/internal/drivers/rabbit/classes/RabbitDriver.d.ts +5 -3
  74. package/dist/internal/drivers/rabbit/classes/RabbitDriver.d.ts.map +1 -1
  75. package/dist/internal/drivers/rabbit/classes/RabbitDriver.js +11 -14
  76. package/dist/internal/drivers/rabbit/classes/RabbitDriver.js.map +1 -1
  77. package/dist/internal/drivers/redis/classes/RedisDriver.d.ts +5 -3
  78. package/dist/internal/drivers/redis/classes/RedisDriver.d.ts.map +1 -1
  79. package/dist/internal/drivers/redis/classes/RedisDriver.js +11 -14
  80. package/dist/internal/drivers/redis/classes/RedisDriver.js.map +1 -1
  81. package/dist/internal/drivers/redis/classes/RedisRpcServer.js.map +1 -1
  82. package/dist/internal/drivers/redis/utils/create-consumer-loop.js +1 -1
  83. package/dist/internal/drivers/redis/utils/create-consumer-loop.js.map +1 -1
  84. package/dist/internal/drivers/redis/utils/stop-consumer-loop.js +2 -2
  85. package/dist/internal/drivers/redis/utils/stop-consumer-loop.js.map +1 -1
  86. package/dist/internal/message/utils/encrypt.d.ts +1 -1
  87. package/dist/internal/message/utils/encrypt.d.ts.map +1 -1
  88. package/dist/internal/message/utils/encrypt.js +4 -3
  89. package/dist/internal/message/utils/encrypt.js.map +1 -1
  90. package/dist/internal/message/utils/prepare-inbound.js +1 -1
  91. package/dist/internal/message/utils/prepare-inbound.js.map +1 -1
  92. package/dist/internal/types/index.d.ts +0 -1
  93. package/dist/internal/types/index.d.ts.map +1 -1
  94. package/dist/internal/types/index.js +0 -1
  95. package/dist/internal/types/index.js.map +1 -1
  96. package/dist/mocks/create-mock-iris-session.d.ts +4 -0
  97. package/dist/mocks/create-mock-iris-session.d.ts.map +1 -0
  98. package/dist/mocks/create-mock-iris-session.js +20 -0
  99. package/dist/mocks/create-mock-iris-session.js.map +1 -0
  100. package/dist/mocks/create-mock-iris-source.d.ts +2 -1
  101. package/dist/mocks/create-mock-iris-source.d.ts.map +1 -1
  102. package/dist/mocks/create-mock-iris-source.js +5 -2
  103. package/dist/mocks/create-mock-iris-source.js.map +1 -1
  104. package/dist/mocks/index.d.ts +2 -1
  105. package/dist/mocks/index.d.ts.map +1 -1
  106. package/dist/mocks/index.js +5 -1
  107. package/dist/mocks/index.js.map +1 -1
  108. package/dist/types/events.d.ts +5 -0
  109. package/dist/types/events.d.ts.map +1 -0
  110. package/dist/types/events.js +3 -0
  111. package/dist/types/events.js.map +1 -0
  112. package/dist/types/index.d.ts +1 -0
  113. package/dist/types/index.d.ts.map +1 -1
  114. package/dist/types/index.js +1 -0
  115. package/dist/types/index.js.map +1 -1
  116. package/dist/types/source-options.d.ts +1 -1
  117. package/dist/types/source-options.d.ts.map +1 -1
  118. package/package.json +28 -16
  119. package/dist/internal/types/iris-source-init.d.ts +0 -29
  120. package/dist/internal/types/iris-source-init.d.ts.map +0 -1
  121. package/dist/internal/types/iris-source-init.js.map +0 -1
package/README.md CHANGED
@@ -1 +1,1684 @@
1
1
  # @lindorm/iris
2
+
3
+ Unified messaging library for Node.js with a single decorator-driven API across multiple brokers. Define messages once, deploy to any backend.
4
+
5
+ ## Supported Drivers
6
+
7
+ | Driver | Peer Dependency | Use Case |
8
+ | ------------ | --------------- | ----------------------------------- |
9
+ | **Memory** | _(none)_ | Testing, prototyping |
10
+ | **RabbitMQ** | `amqplib` | Task queues, complex routing |
11
+ | **Kafka** | `kafkajs` | High-throughput event streaming |
12
+ | **NATS** | `nats` | Low-latency, cloud-native systems |
13
+ | **Redis** | `ioredis` | Lightweight streams, existing infra |
14
+
15
+ Install only the peer dependency for the driver(s) you use:
16
+
17
+ ```bash
18
+ npm install @lindorm/iris
19
+
20
+ # Pick one or more:
21
+ npm install amqplib # RabbitMQ
22
+ npm install kafkajs # Kafka
23
+ npm install nats # NATS
24
+ npm install ioredis # Redis Streams
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ### 1. Define a Message
30
+
31
+ ```typescript
32
+ import {
33
+ Message,
34
+ Namespace,
35
+ Version,
36
+ Field,
37
+ IdentifierField,
38
+ TimestampField,
39
+ } from "@lindorm/iris";
40
+ import type { IMessage } from "@lindorm/iris";
41
+
42
+ @Message()
43
+ @Namespace("orders")
44
+ @Version(1)
45
+ class OrderPlaced {
46
+ @IdentifierField() id!: string;
47
+ @TimestampField() createdAt!: Date;
48
+ @Field("string") orderId!: string;
49
+ @Field("float") total!: number;
50
+ }
51
+ ```
52
+
53
+ ### 2. Create a Source and Connect
54
+
55
+ ```typescript
56
+ import { IrisSource } from "@lindorm/iris";
57
+
58
+ const source = new IrisSource({
59
+ driver: "rabbit",
60
+ url: "amqp://localhost",
61
+ logger: myLogger,
62
+ messages: [OrderPlaced],
63
+ });
64
+
65
+ await source.connect();
66
+ await source.setup();
67
+ ```
68
+
69
+ ### 3. Publish and Subscribe
70
+
71
+ ```typescript
72
+ const bus = source.messageBus(OrderPlaced);
73
+
74
+ // Subscribe
75
+ await bus.subscribe({
76
+ topic: "OrderPlaced",
77
+ queue: "order-service",
78
+ callback: async (msg, envelope) => {
79
+ console.log(`Order ${msg.orderId} placed for $${msg.total}`);
80
+ },
81
+ });
82
+
83
+ // Publish
84
+ const msg = bus.create({ orderId: "abc-123", total: 59.99 });
85
+ await bus.publish(msg);
86
+ ```
87
+
88
+ ### 4. Graceful Shutdown
89
+
90
+ ```typescript
91
+ await source.drain();
92
+ await source.disconnect();
93
+ ```
94
+
95
+ ## Table of Contents
96
+
97
+ - [Messaging Patterns](#messaging-patterns)
98
+ - [Publisher (Fire-and-Forget)](#publisher-fire-and-forget)
99
+ - [Message Bus (Pub/Sub + Queues)](#message-bus-pubsub--queues)
100
+ - [Worker Queue (Competing Consumers)](#worker-queue-competing-consumers)
101
+ - [RPC (Request/Response)](#rpc-requestresponse)
102
+ - [Stream Processor (Pipelines)](#stream-processor-pipelines)
103
+ - [Field Types](#field-types)
104
+ - [Decorators](#decorators)
105
+ - [Class-Level Decorators](#class-level-decorators)
106
+ - [`@Message`](#message)
107
+ - [`@AbstractMessage`](#abstractmessage)
108
+ - [`@Namespace`](#namespace)
109
+ - [`@Version`](#version)
110
+ - [`@Topic`](#topic)
111
+ - [`@Broadcast`](#broadcast)
112
+ - [`@Persistent`](#persistent)
113
+ - [`@Priority`](#priority)
114
+ - [`@Delay`](#delay)
115
+ - [`@Expiry`](#expiry)
116
+ - [`@Encrypted`](#encrypted)
117
+ - [`@Compressed`](#compressed)
118
+ - [`@Retry`](#retry)
119
+ - [`@DeadLetter`](#deadletter)
120
+ - [Field Decorators](#field-decorators)
121
+ - [`@Field`](#field)
122
+ - [`@IdentifierField`](#identifierfield)
123
+ - [`@CorrelationField`](#correlationfield)
124
+ - [`@TimestampField`](#timestampfield)
125
+ - [`@MandatoryField`](#mandatoryfield)
126
+ - [`@PersistentField`](#persistentfield)
127
+ - [Field Modifiers](#field-modifiers)
128
+ - [`@Generated`](#generated)
129
+ - [`@Header`](#header)
130
+ - [`@Enum`](#enum)
131
+ - [`@Min` / `@Max`](#min--max)
132
+ - [`@Schema`](#schema)
133
+ - [`@Transform`](#transform)
134
+ - [Lifecycle Hook Decorators](#lifecycle-hook-decorators)
135
+ - [`@OnCreate`](#oncreate)
136
+ - [`@OnHydrate`](#onhydrate)
137
+ - [`@OnValidate`](#onvalidate)
138
+ - [`@BeforePublish` / `@AfterPublish`](#beforepublish--afterpublish)
139
+ - [`@BeforeConsume` / `@AfterConsume`](#beforeconsume--afterconsume)
140
+ - [`@OnConsumeError`](#onconsumeerror)
141
+ - [Retry and Dead Letter](#retry-and-dead-letter)
142
+ - [Dynamic Topics](#dynamic-topics)
143
+ - [Encryption and Compression](#encryption-and-compression)
144
+ - [Message Subscribers](#message-subscribers)
145
+ - [Hook Execution Order](#hook-execution-order)
146
+ - [Consume Envelope](#consume-envelope)
147
+ - [Message Manipulation](#message-manipulation)
148
+ - [Publish Options](#publish-options)
149
+ - [Zod Validation](#zod-validation)
150
+ - [Cloning](#cloning)
151
+ - [Driver Configuration](#driver-configuration)
152
+ - [Memory](#memory)
153
+ - [RabbitMQ](#rabbitmq)
154
+ - [Kafka](#kafka)
155
+ - [NATS](#nats)
156
+ - [Redis Streams](#redis-streams)
157
+ - [Persistence (Delay and Dead Letter Stores)](#persistence-delay-and-dead-letter-stores)
158
+ - [Connection State](#connection-state)
159
+ - [Testing with Mocks](#testing-with-mocks)
160
+ - [Error Classes](#error-classes)
161
+
162
+ ## Messaging Patterns
163
+
164
+ ### Publisher (Fire-and-Forget)
165
+
166
+ Write-only. No subscriptions.
167
+
168
+ ```typescript
169
+ const pub = source.publisher(OrderPlaced);
170
+
171
+ const msg = pub.create({ orderId: "abc-123", total: 59.99 });
172
+ await pub.publish(msg);
173
+
174
+ // Batch publish
175
+ await pub.publish([msg1, msg2, msg3]);
176
+ ```
177
+
178
+ ### Message Bus (Pub/Sub + Queues)
179
+
180
+ Publish with topic-based subscriptions. Supports broadcast and competing-consumer queues.
181
+
182
+ ```typescript
183
+ const bus = source.messageBus(OrderPlaced);
184
+
185
+ // Broadcast: every subscriber receives every message
186
+ await bus.subscribe({
187
+ topic: "OrderPlaced",
188
+ callback: async (msg) => {
189
+ /* ... */
190
+ },
191
+ });
192
+
193
+ // Queue: messages are distributed round-robin among consumers
194
+ await bus.subscribe({
195
+ topic: "OrderPlaced",
196
+ queue: "order-processors",
197
+ callback: async (msg) => {
198
+ /* ... */
199
+ },
200
+ });
201
+
202
+ // Multiple subscriptions at once
203
+ await bus.subscribe([
204
+ { topic: "OrderPlaced", queue: "analytics", callback: handleAnalytics },
205
+ { topic: "OrderPlaced", queue: "notifications", callback: handleNotify },
206
+ ]);
207
+
208
+ // Unsubscribe
209
+ await bus.unsubscribe({ topic: "OrderPlaced", queue: "analytics" });
210
+ await bus.unsubscribeAll();
211
+ ```
212
+
213
+ ### Worker Queue (Competing Consumers)
214
+
215
+ Specialised for job distribution where each message is processed by exactly one consumer.
216
+
217
+ ```typescript
218
+ const queue = source.workerQueue(OrderPlaced);
219
+
220
+ // Register competing consumers
221
+ await queue.consume("process-orders", async (msg, envelope) => {
222
+ console.log(`Processing order ${msg.orderId} (attempt ${envelope.attempt})`);
223
+ });
224
+
225
+ // Publish work
226
+ await queue.publish(queue.create({ orderId: "abc-123", total: 59.99 }));
227
+
228
+ // Clean up
229
+ await queue.unconsume("process-orders");
230
+ await queue.unconsumeAll();
231
+ ```
232
+
233
+ ### RPC (Request/Response)
234
+
235
+ Synchronous request/response over the message broker.
236
+
237
+ ```typescript
238
+ @Message()
239
+ class GetPrice {
240
+ @Field("string") sku!: string;
241
+ }
242
+
243
+ @Message()
244
+ class PriceResponse {
245
+ @Field("float") price!: number;
246
+ @Field("string") currency!: string;
247
+ }
248
+
249
+ const client = source.rpcClient(GetPrice, PriceResponse);
250
+ const server = source.rpcServer(GetPrice, PriceResponse);
251
+
252
+ // Server: register handler
253
+ await server.serve(async (req) => {
254
+ const res = new PriceResponse();
255
+ res.price = await lookupPrice(req.sku);
256
+ res.currency = "USD";
257
+ return res;
258
+ });
259
+
260
+ // Client: send request
261
+ const req = new GetPrice();
262
+ req.sku = "WIDGET-42";
263
+
264
+ const res = await client.request(req);
265
+ console.log(`${res.price} ${res.currency}`); // 29.99 USD
266
+
267
+ // With timeout
268
+ const res2 = await client.request(req, { timeout: 5000 });
269
+
270
+ // Clean up
271
+ await client.close();
272
+ await server.unserveAll();
273
+ ```
274
+
275
+ ### Stream Processor (Pipelines)
276
+
277
+ Declarative stream processing with an immutable builder pattern.
278
+
279
+ ```typescript
280
+ @Message()
281
+ class RawEvent {
282
+ @Field("string") type!: string;
283
+ @Field("float") value!: number;
284
+ }
285
+
286
+ @Message()
287
+ class AggregatedEvent {
288
+ @Field("float") sum!: number;
289
+ @Field("integer") count!: number;
290
+ }
291
+
292
+ const pipeline = source
293
+ .stream()
294
+ .from(RawEvent)
295
+ .filter((msg) => msg.value > 0)
296
+ .map((msg) => {
297
+ const out = new AggregatedEvent();
298
+ out.sum = msg.value;
299
+ out.count = 1;
300
+ return out;
301
+ })
302
+ .to(AggregatedEvent);
303
+
304
+ await pipeline.start();
305
+ // pipeline.isRunning() === true
306
+
307
+ await pipeline.pause();
308
+ await pipeline.resume();
309
+ await pipeline.stop();
310
+ ```
311
+
312
+ ## Field Types
313
+
314
+ The `@Field()` decorator accepts the following type identifiers:
315
+
316
+ | Category | Types |
317
+ | -------------- | -------------------------------- |
318
+ | Boolean | `boolean` |
319
+ | Integer | `integer`, `bigint` |
320
+ | Floating Point | `float` |
321
+ | String | `string`, `email`, `url`, `uuid` |
322
+ | Enum | `enum` |
323
+ | Date/Time | `date` |
324
+ | Structured | `object`, `array` |
325
+
326
+ ```typescript
327
+ @Message()
328
+ class FullExample {
329
+ @IdentifierField() id!: string;
330
+ @CorrelationField() correlationId!: string;
331
+ @TimestampField() createdAt!: Date;
332
+
333
+ @Field("string") name!: string;
334
+ @Field("integer") count!: number;
335
+ @Field("float") price!: number;
336
+ @Field("boolean") active!: boolean;
337
+ @Field("date") expiresAt!: Date;
338
+ @Field("uuid") referenceId!: string;
339
+ @Field("email") contactEmail!: string;
340
+ @Field("url") callbackUrl!: string;
341
+ @Field("array") tags!: Array<string>;
342
+ @Field("object") metadata!: Record<string, unknown>;
343
+
344
+ @Field("string", { nullable: true }) description!: string | null;
345
+ @Field("string", { optional: true }) nickname?: string;
346
+ @Field("integer", { default: 0 }) retryCount!: number;
347
+ @Field("string", { default: () => "generated" }) code!: string;
348
+ }
349
+ ```
350
+
351
+ ## Decorators
352
+
353
+ All decorators use the TC39 (stage 3) decorator specification. Class decorators receive `ClassDecoratorContext`, field decorators receive `ClassFieldDecoratorContext`. Metadata flows through the `Symbol.metadata` prototype chain, so abstract base class decorators are inherited by concrete subclasses.
354
+
355
+ ### Class-Level Decorators
356
+
357
+ These decorators are applied to classes and configure message-wide behavior.
358
+
359
+ #### `@Message`
360
+
361
+ Marks a class as a concrete message type registered in the global message registry. Every message class must have exactly one of `@Message` or `@AbstractMessage`.
362
+
363
+ ```typescript
364
+ @Message()
365
+ class OrderPlaced {
366
+ /* ... */
367
+ }
368
+
369
+ @Message({ name: "order-placed" }) // custom message name
370
+ class OrderPlaced {
371
+ /* ... */
372
+ }
373
+ ```
374
+
375
+ **Options:** `{ name?: string }` — Override the message name. Defaults to the class name with any trailing version suffix (`_v1`, `_V2`) stripped. Must not conflict with other registered message names.
376
+
377
+ #### `@AbstractMessage`
378
+
379
+ Marks a class as an abstract message base. It is **not** registered in the global message registry. Fields, hooks, and metadata are inherited by `@Message()` subclasses via the `Symbol.metadata` prototype chain.
380
+
381
+ ```typescript
382
+ @AbstractMessage()
383
+ class BaseEvent {
384
+ @IdentifierField() id!: string;
385
+ @TimestampField() createdAt!: Date;
386
+ }
387
+
388
+ @Message()
389
+ @Namespace("orders")
390
+ class OrderPlaced extends BaseEvent {
391
+ @Field("string") orderId!: string;
392
+ }
393
+ ```
394
+
395
+ Cannot be combined with `@Message` on the same class.
396
+
397
+ #### `@Namespace`
398
+
399
+ Places the message in a named namespace for logical grouping and routing.
400
+
401
+ ```typescript
402
+ @Namespace("orders")
403
+ @Message()
404
+ class OrderPlaced {
405
+ /* ... */
406
+ }
407
+ ```
408
+
409
+ **Argument:** `string` — must be non-empty. Throws `IrisMetadataError` if empty or whitespace-only.
410
+
411
+ #### `@Version`
412
+
413
+ Sets the message schema version. Useful for evolving message formats while maintaining backward compatibility.
414
+
415
+ ```typescript
416
+ @Version(1)
417
+ @Message()
418
+ class OrderPlaced {
419
+ /* ... */
420
+ }
421
+ ```
422
+
423
+ **Argument:** `number` — must be a positive integer (>= 1). Throws `IrisMetadataError` otherwise.
424
+
425
+ #### `@Topic`
426
+
427
+ Provides a dynamic topic resolution callback. Instead of using the message class name as the topic, the callback computes the topic from the message content at publish time.
428
+
429
+ ```typescript
430
+ @Topic((msg: any) => `events.${msg.region}.${msg.type}`)
431
+ @Message()
432
+ class RegionalEvent {
433
+ @Field("string") region!: string;
434
+ @Field("string") type!: string;
435
+ }
436
+ ```
437
+
438
+ **Argument:** `(message: any) => string` — receives the message instance, returns the routing topic string.
439
+
440
+ #### `@Broadcast`
441
+
442
+ Marks a message for broadcast delivery. When published, the message is delivered to **all** subscribers rather than being distributed round-robin to one consumer per queue.
443
+
444
+ ```typescript
445
+ @Broadcast()
446
+ @Message()
447
+ class SystemNotification {
448
+ @Field("string") text!: string;
449
+ }
450
+ ```
451
+
452
+ No arguments.
453
+
454
+ #### `@Persistent`
455
+
456
+ Marks a message as persistent/durable. Persistent messages survive broker restarts (where supported by the driver).
457
+
458
+ ```typescript
459
+ @Persistent()
460
+ @Message()
461
+ class PaymentCharge {
462
+ @Field("string") chargeId!: string;
463
+ }
464
+ ```
465
+
466
+ No arguments.
467
+
468
+ #### `@Priority`
469
+
470
+ Sets the default priority for a message type. Higher priority messages are delivered before lower priority ones (where supported by the driver).
471
+
472
+ ```typescript
473
+ @Priority(8)
474
+ @Message()
475
+ class UrgentAlert {
476
+ @Field("string") text!: string;
477
+ }
478
+ ```
479
+
480
+ **Argument:** `number` — integer between 0 and 10 inclusive. Throws `IrisMetadataError` if out of range or not an integer.
481
+
482
+ #### `@Delay`
483
+
484
+ Sets a default delivery delay. The message is held for the specified duration before being delivered to consumers.
485
+
486
+ ```typescript
487
+ @Delay(5000) // 5 seconds
488
+ @Message()
489
+ class ScheduledReminder {
490
+ @Field("string") text!: string;
491
+ }
492
+ ```
493
+
494
+ **Argument:** `number` — non-negative integer in milliseconds. Throws `IrisMetadataError` if negative or not an integer.
495
+
496
+ Can be overridden per-publish via `PublishOptions.delay`.
497
+
498
+ #### `@Expiry`
499
+
500
+ Sets a default message TTL (time-to-live). Messages that are not consumed within this window are discarded.
501
+
502
+ ```typescript
503
+ @Expiry(60000) // 1 minute
504
+ @Message()
505
+ class TemporaryOffer {
506
+ @Field("float") discount!: number;
507
+ }
508
+ ```
509
+
510
+ **Argument:** `number` — non-negative integer in milliseconds. Throws `IrisMetadataError` if negative or not an integer.
511
+
512
+ Can be overridden per-publish via `PublishOptions.expiry`.
513
+
514
+ #### `@Encrypted`
515
+
516
+ Enables payload encryption via `@lindorm/amphora`. The entire message payload is encrypted before publishing and decrypted on consume. Requires an `IAmphora` instance configured on `IrisSource`.
517
+
518
+ ```typescript
519
+ @Encrypted() // encrypt with any available key
520
+ @Message()
521
+ class SensitivePayload {
522
+ @Field("string") ssn!: string;
523
+ }
524
+
525
+ @Encrypted({ purpose: "pii" }) // filter keys by purpose
526
+ @Message()
527
+ class MedicalRecord {
528
+ @Field("json") data!: Record<string, unknown>;
529
+ }
530
+ ```
531
+
532
+ **Argument:** `AmphoraPredicate` (optional, defaults to `{}`) — a predicate object to filter which encryption key to use from the key store. Supports fields like `algorithm`, `encryption`, `purpose`, `type`, `ownerId`, and standard predicate operators (`$eq`, `$in`, `$neq`, etc.).
533
+
534
+ #### `@Compressed`
535
+
536
+ Enables payload compression before publishing and decompression on consume.
537
+
538
+ ```typescript
539
+ @Compressed() // gzip (default)
540
+ @Compressed("brotli") // brotli compression
541
+ @Message()
542
+ class LargePayload {
543
+ @Field("object") data!: Record<string, unknown>;
544
+ }
545
+ ```
546
+
547
+ **Argument:** `"gzip" | "deflate" | "brotli"` (optional, defaults to `"gzip"`).
548
+
549
+ When combined with `@Encrypted`, compression is applied first, then encryption.
550
+
551
+ #### `@Retry`
552
+
553
+ Configures automatic retry behavior when a consume callback throws. Failed messages are retried with configurable backoff strategies.
554
+
555
+ ```typescript
556
+ @Retry() // defaults: 3 retries, constant 1s delay
557
+ @Message()
558
+ class ProcessOrder {
559
+ @Field("string") orderId!: string;
560
+ }
561
+
562
+ @Retry({
563
+ maxRetries: 5,
564
+ strategy: "exponential",
565
+ delay: 1000,
566
+ delayMax: 30000,
567
+ multiplier: 2,
568
+ jitter: true,
569
+ })
570
+ @Message()
571
+ class PaymentCharge {
572
+ @Field("string") chargeId!: string;
573
+ }
574
+ ```
575
+
576
+ **Options:**
577
+
578
+ | Field | Type | Default | Description |
579
+ | ------------ | --------------------------------------------- | ------------ | ----------------------------------------- |
580
+ | `maxRetries` | `number` | `3` | Maximum number of retry attempts |
581
+ | `strategy` | `"constant"` \| `"linear"` \| `"exponential"` | `"constant"` | Backoff strategy |
582
+ | `delay` | `number` | `1000` | Initial delay in milliseconds |
583
+ | `delayMax` | `number` | `30000` | Maximum delay cap in milliseconds |
584
+ | `multiplier` | `number` | `2` | Multiplier for exponential backoff |
585
+ | `jitter` | `boolean` | `false` | Add randomness to prevent thundering herd |
586
+
587
+ **Retry strategies:**
588
+
589
+ | Strategy | Delay pattern (base=1000, multiplier=2) |
590
+ | --------------- | ------------------------------------------- |
591
+ | `"constant"` | 1000, 1000, 1000, ... |
592
+ | `"linear"` | 1000, 2000, 3000, ... |
593
+ | `"exponential"` | 1000, 2000, 4000, 8000, ... (capped at max) |
594
+
595
+ #### `@DeadLetter`
596
+
597
+ Routes messages that have exhausted all retry attempts to the dead letter store. Requires `@Retry` and a dead letter store configured via `IrisSource.persistence.deadLetter`.
598
+
599
+ ```typescript
600
+ @Retry({ maxRetries: 3 })
601
+ @DeadLetter()
602
+ @Message()
603
+ class PaymentCharge {
604
+ @Field("string") chargeId!: string;
605
+ }
606
+ ```
607
+
608
+ No arguments.
609
+
610
+ ---
611
+
612
+ ### Field Decorators
613
+
614
+ These decorators are applied to class properties and declare message fields. Each field decorator creates a complete field definition with its type, default value, and nullability.
615
+
616
+ #### `@Field`
617
+
618
+ The foundational field decorator. Declares a message field with an explicit type.
619
+
620
+ ```typescript
621
+ @Field("string")
622
+ name!: string;
623
+
624
+ @Field("integer")
625
+ count!: number;
626
+
627
+ @Field("float")
628
+ price!: number;
629
+
630
+ @Field("boolean")
631
+ active!: boolean;
632
+
633
+ @Field("date")
634
+ expiresAt!: Date;
635
+
636
+ @Field("uuid")
637
+ referenceId!: string;
638
+
639
+ @Field("email")
640
+ contactEmail!: string;
641
+
642
+ @Field("url")
643
+ callbackUrl!: string;
644
+
645
+ @Field("array")
646
+ tags!: Array<string>;
647
+
648
+ @Field("object")
649
+ metadata!: Record<string, unknown>;
650
+ ```
651
+
652
+ **Arguments:** `(type: MetaFieldType, options?: FieldDecoratorOptions)`.
653
+
654
+ **Options:**
655
+
656
+ | Field | Type | Default | Description |
657
+ | ----------- | -------------------------------- | ------- | ----------------------------------------- |
658
+ | `nullable` | `boolean` | `false` | Allow `null` values |
659
+ | `optional` | `boolean` | `false` | Field may be omitted |
660
+ | `default` | `value \| (() => value) \| null` | `null` | Default value applied on create |
661
+ | `transform` | `MetaTransform \| null` | `null` | Inline serialisation/deserialisation pair |
662
+
663
+ ```typescript
664
+ @Field("string", { nullable: true })
665
+ description!: string | null;
666
+
667
+ @Field("string", { optional: true })
668
+ nickname?: string;
669
+
670
+ @Field("integer", { default: 0 })
671
+ retryCount!: number;
672
+
673
+ @Field("string", { default: () => "generated" })
674
+ code!: string;
675
+
676
+ @Field("float", {
677
+ transform: {
678
+ to: (value: number) => Math.round(value * 100),
679
+ from: (raw: number) => raw / 100,
680
+ },
681
+ })
682
+ price!: number;
683
+ ```
684
+
685
+ #### `@IdentifierField`
686
+
687
+ Shorthand for a UUID primary identifier field. Auto-generates a UUID v4 on message creation.
688
+
689
+ ```typescript
690
+ @IdentifierField()
691
+ id!: string;
692
+ ```
693
+
694
+ Equivalent to `@Field("uuid", { default: () => randomUUID() })`. Non-nullable, non-optional.
695
+
696
+ No arguments.
697
+
698
+ #### `@CorrelationField`
699
+
700
+ Shorthand for a UUID correlation tracking field. Auto-generates a UUID v4 on message creation. Used to trace related messages across publish/consume chains.
701
+
702
+ ```typescript
703
+ @CorrelationField()
704
+ correlationId!: string;
705
+ ```
706
+
707
+ Equivalent to `@Field("uuid", { default: () => randomUUID() })`. Non-nullable, non-optional.
708
+
709
+ No arguments.
710
+
711
+ #### `@TimestampField`
712
+
713
+ Shorthand for a timestamp field. Auto-generates the current `Date` on message creation.
714
+
715
+ ```typescript
716
+ @TimestampField()
717
+ createdAt!: Date;
718
+ ```
719
+
720
+ Equivalent to `@Field("date", { default: () => new Date() })`. Non-nullable, non-optional.
721
+
722
+ No arguments.
723
+
724
+ #### `@MandatoryField`
725
+
726
+ Shorthand for a boolean flag that defaults to `false`. Commonly used for acknowledgement or processing flags.
727
+
728
+ ```typescript
729
+ @MandatoryField()
730
+ requiresApproval!: boolean;
731
+ ```
732
+
733
+ Equivalent to `@Field("boolean", { default: false })`. Non-nullable, non-optional.
734
+
735
+ No arguments.
736
+
737
+ #### `@PersistentField`
738
+
739
+ Shorthand for a boolean persistence flag that defaults to `false`. Commonly used to mark whether a message should be durably stored.
740
+
741
+ ```typescript
742
+ @PersistentField()
743
+ shouldPersist!: boolean;
744
+ ```
745
+
746
+ Equivalent to `@Field("boolean", { default: false })`. Non-nullable, non-optional.
747
+
748
+ No arguments.
749
+
750
+ ---
751
+
752
+ ### Field Modifiers
753
+
754
+ These decorators modify the behavior of a field declared with `@Field` or one of the shorthand field decorators. Stack them on the same property. Modifier decorators must appear alongside a field decorator on the same property.
755
+
756
+ #### `@Generated`
757
+
758
+ Marks a field for automatic value generation on message creation.
759
+
760
+ ```typescript
761
+ @Generated("uuid") // UUID v4
762
+ @Field("uuid")
763
+ traceId!: string;
764
+
765
+ @Generated("date") // current timestamp
766
+ @Field("date")
767
+ processedAt!: Date;
768
+
769
+ @Generated("string") // random string (default length)
770
+ @Field("string")
771
+ token!: string;
772
+
773
+ @Generated("string", { length: 12 }) // random string with custom length
774
+ @Field("string")
775
+ shortCode!: string;
776
+
777
+ @Generated("integer", { min: 1, max: 1000 })
778
+ @Field("integer")
779
+ sequenceNumber!: number;
780
+
781
+ @Generated("float", { min: 0.0, max: 1.0 })
782
+ @Field("float")
783
+ weight!: number;
784
+ ```
785
+
786
+ **Arguments:** `(strategy: MetaGeneratedStrategy, options?: GeneratedDecoratorOptions)`.
787
+
788
+ **Strategies:**
789
+
790
+ | Strategy | Description |
791
+ | ----------- | ---------------------------------------- |
792
+ | `"uuid"` | Generate UUID v4 |
793
+ | `"date"` | Current timestamp |
794
+ | `"string"` | Random string with configurable `length` |
795
+ | `"integer"` | Random integer in `[min, max]` range |
796
+ | `"float"` | Random float in `[min, max]` range |
797
+
798
+ **Options:** `{ length?: number, min?: number, max?: number }` — all optional, all default to `null`.
799
+
800
+ #### `@Header`
801
+
802
+ Promotes a field value to a message header. Headers are transported as key-value metadata alongside the payload, accessible without deserialising the full message body.
803
+
804
+ ```typescript
805
+ @Header() // header name = property name ("source")
806
+ @Field("string")
807
+ source!: string;
808
+
809
+ @Header("x-trace-id") // explicit header name
810
+ @Field("uuid")
811
+ traceId!: string;
812
+ ```
813
+
814
+ **Argument:** `string?` — custom header name. Defaults to the property name. Throws `IrisMetadataError` if the resolved name is empty or whitespace-only.
815
+
816
+ #### `@Enum`
817
+
818
+ Restricts a field to a fixed set of allowed values. Pass a TypeScript enum or a plain `Record<string, string | number>`. Enforced during Zod validation.
819
+
820
+ ```typescript
821
+ enum OrderStatus {
822
+ Pending = "pending",
823
+ Shipped = "shipped",
824
+ Delivered = "delivered",
825
+ }
826
+
827
+ @Enum(OrderStatus)
828
+ @Field("enum")
829
+ status!: OrderStatus;
830
+ ```
831
+
832
+ **Argument:** `Record<string, string | number>` — the enum object or value map.
833
+
834
+ #### `@Min` / `@Max`
835
+
836
+ Set minimum/maximum bounds for numeric fields or minimum/maximum length for string fields. Enforced during Zod validation.
837
+
838
+ ```typescript
839
+ @Min(0)
840
+ @Max(100)
841
+ @Field("integer")
842
+ score!: number;
843
+
844
+ @Min(1)
845
+ @Max(255)
846
+ @Field("string")
847
+ name!: string;
848
+ ```
849
+
850
+ **Argument:** `number`.
851
+
852
+ #### `@Schema`
853
+
854
+ Attaches a Zod schema for fine-grained field validation. The schema is evaluated during message validation.
855
+
856
+ ```typescript
857
+ import { z } from "zod";
858
+
859
+ @Schema(z.string().email())
860
+ @Field("email")
861
+ email!: string;
862
+
863
+ @Schema(z.number().int().min(13).max(150))
864
+ @Field("integer")
865
+ age!: number;
866
+
867
+ @Schema(z.string().regex(/^[A-Z]{2,3}$/))
868
+ @Field("string")
869
+ countryCode!: string;
870
+ ```
871
+
872
+ **Argument:** `z.ZodType` — any Zod schema.
873
+
874
+ #### `@Transform`
875
+
876
+ Applies a bidirectional transform to a field value. `to` runs during serialisation (message -> transport), `from` runs during deserialisation (transport -> message).
877
+
878
+ ```typescript
879
+ @Transform({
880
+ to: (value: string[]) => value.join(","),
881
+ from: (raw: string) => raw.split(","),
882
+ })
883
+ @Field("string")
884
+ tags!: string[];
885
+
886
+ @Transform<Date, number>({
887
+ to: (date) => date.getTime(),
888
+ from: (ms) => new Date(ms),
889
+ })
890
+ @Field("bigint")
891
+ timestamp!: Date;
892
+ ```
893
+
894
+ **Options:** `{ to: (value: TFrom) => TTo, from: (raw: TTo) => TFrom }`.
895
+
896
+ This is a standalone decorator that uses a separate staging path. For inline transforms, use the `transform` option on `@Field` instead.
897
+
898
+ ---
899
+
900
+ ### Lifecycle Hook Decorators
901
+
902
+ Lifecycle hooks are **class decorators** that register callbacks at specific points in the message lifecycle. All hooks receive `(message, context?)` as arguments, where `context` is the source's context value.
903
+
904
+ Hooks may be async (`void | Promise<void>`) unless otherwise noted.
905
+
906
+ #### `@OnCreate`
907
+
908
+ Fires when a message instance is created via `create()`. Useful for setting computed defaults or derived fields.
909
+
910
+ ```typescript
911
+ @OnCreate((msg) => {
912
+ msg.slug = msg.name.toLowerCase().replace(/\s+/g, "-");
913
+ })
914
+ @Message()
915
+ class OrderPlaced {
916
+ @Field("string") name!: string;
917
+ @Field("string") slug!: string;
918
+ }
919
+ ```
920
+
921
+ **Argument:** `(message: M, context?: C) => void | Promise<void>`.
922
+
923
+ #### `@OnHydrate`
924
+
925
+ Fires when a message is rehydrated from raw transport data, after all fields are populated but before the message is returned to the consume callback.
926
+
927
+ ```typescript
928
+ @OnHydrate((msg) => {
929
+ msg.displayName = `${msg.firstName} ${msg.lastName}`;
930
+ })
931
+ @Message()
932
+ class UserEvent {
933
+ @Field("string") firstName!: string;
934
+ @Field("string") lastName!: string;
935
+ @Field("string") displayName!: string;
936
+ }
937
+ ```
938
+
939
+ **Argument:** `(message: M, context?: C) => void | Promise<void>`.
940
+
941
+ #### `@OnValidate`
942
+
943
+ Fires during message validation, after the built-in Zod schema check. Throw to reject the message.
944
+
945
+ ```typescript
946
+ @OnValidate((msg) => {
947
+ if (msg.startDate >= msg.endDate) {
948
+ throw new Error("startDate must be before endDate");
949
+ }
950
+ })
951
+ @Message()
952
+ class BookingRequest {
953
+ @Field("date") startDate!: Date;
954
+ @Field("date") endDate!: Date;
955
+ }
956
+ ```
957
+
958
+ **Argument:** `(message: M, context?: C) => void | Promise<void>`.
959
+
960
+ #### `@BeforePublish` / `@AfterPublish`
961
+
962
+ Fire around publish operations. `@BeforePublish` runs before the message is handed to the transport. `@AfterPublish` runs after the transport confirms delivery.
963
+
964
+ ```typescript
965
+ @BeforePublish(async (msg) => {
966
+ await validateExternalId(msg.externalId);
967
+ })
968
+ @AfterPublish(async (msg) => {
969
+ metrics.increment("messages.published");
970
+ })
971
+ @Message()
972
+ class OrderPlaced {
973
+ @Field("string") externalId!: string;
974
+ }
975
+ ```
976
+
977
+ **Argument:** `(message: M, context?: C) => void | Promise<void>`.
978
+
979
+ #### `@BeforeConsume` / `@AfterConsume`
980
+
981
+ Fire around consume callback execution. `@BeforeConsume` runs after deserialisation but before the consume callback. `@AfterConsume` runs after the callback completes successfully.
982
+
983
+ ```typescript
984
+ @BeforeConsume(async (msg, ctx) => {
985
+ audit.log("consuming", msg);
986
+ })
987
+ @AfterConsume(async (msg) => {
988
+ metrics.increment("messages.consumed");
989
+ })
990
+ @Message()
991
+ class OrderPlaced {
992
+ /* ... */
993
+ }
994
+ ```
995
+
996
+ **Argument:** `(message: M, context?: C) => void | Promise<void>`.
997
+
998
+ #### `@OnConsumeError`
999
+
1000
+ Fires when a consume callback throws an error. Receives the error as the **first** argument, followed by the message and context.
1001
+
1002
+ ```typescript
1003
+ @OnConsumeError(async (error, msg) => {
1004
+ errorTracker.capture(error, { messageId: msg.id });
1005
+ })
1006
+ @Message()
1007
+ class PaymentCharge {
1008
+ @IdentifierField() id!: string;
1009
+ @Field("string") chargeId!: string;
1010
+ }
1011
+ ```
1012
+
1013
+ **Argument:** `(error: Error, message: M, context?: C) => void | Promise<void>`.
1014
+
1015
+ Note the different signature: `error` is the first parameter, unlike all other hooks where the message comes first.
1016
+
1017
+ ---
1018
+
1019
+ ### Hook Execution Order
1020
+
1021
+ | Phase | Hooks (in order) |
1022
+ | ---------- | ------------------------------------------------------------------------------------------------------- |
1023
+ | Creation | `@OnCreate` |
1024
+ | Validation | `@OnValidate` |
1025
+ | Publish | `@BeforePublish` -> subscriber.beforePublish -> transport -> `@AfterPublish` -> subscriber.afterPublish |
1026
+ | Consume | `@BeforeConsume` -> subscriber.beforeConsume -> callback -> `@AfterConsume` -> subscriber.afterConsume |
1027
+ | Hydration | `@OnHydrate` |
1028
+ | Error | `@OnConsumeError` -> subscriber.onConsumeError (replaces AfterConsume steps) |
1029
+
1030
+ Full publish + consume lifecycle:
1031
+
1032
+ ```
1033
+ 1. @BeforePublish hook
1034
+ 2. subscriber.beforePublish
1035
+ 3. (transport publishes)
1036
+ 4. (transport delivers to consumer)
1037
+ 5. @BeforeConsume hook
1038
+ 6. subscriber.beforeConsume
1039
+ 7. callback executes
1040
+ 8. @AfterConsume hook
1041
+ 9. subscriber.afterConsume
1042
+ 10. @AfterPublish hook
1043
+ 11. subscriber.afterPublish
1044
+ ```
1045
+
1046
+ On error at step 7, steps 8-11 are replaced by `@OnConsumeError` and `subscriber.onConsumeError`.
1047
+
1048
+ ## Retry and Dead Letter
1049
+
1050
+ Configure automatic retry with backoff strategies and dead letter routing for permanently failed messages.
1051
+
1052
+ ```typescript
1053
+ @Retry({
1054
+ maxRetries: 5,
1055
+ strategy: "exponential", // "constant" | "linear" | "exponential"
1056
+ delay: 1000, // initial delay in ms
1057
+ delayMax: 30000, // maximum delay cap
1058
+ multiplier: 2, // exponential multiplier
1059
+ jitter: true, // add randomness to prevent thundering herd
1060
+ })
1061
+ @DeadLetter()
1062
+ @Message()
1063
+ class PaymentCharge {
1064
+ @Field("string") chargeId!: string;
1065
+ @Field("float") amount!: number;
1066
+ }
1067
+ ```
1068
+
1069
+ ## Dynamic Topics
1070
+
1071
+ Route messages to different topics based on their content:
1072
+
1073
+ ```typescript
1074
+ @Topic((msg: any) => `events.${msg.region}.${msg.type}`)
1075
+ @Message()
1076
+ class RegionalEvent {
1077
+ @Field("string") region!: string;
1078
+ @Field("string") type!: string;
1079
+ @Field("object") data!: Record<string, unknown>;
1080
+ }
1081
+
1082
+ const bus = source.messageBus(RegionalEvent);
1083
+ const msg = bus.create({ region: "eu-west", type: "signup", data: {} });
1084
+ await bus.publish(msg); // Published to "events.eu-west.signup"
1085
+ ```
1086
+
1087
+ ## Encryption and Compression
1088
+
1089
+ ```typescript
1090
+ import { Encrypted, Compressed } from "@lindorm/iris";
1091
+
1092
+ @Encrypted() // Encrypt payload via amphora
1093
+ @Compressed("brotli") // Then compress ("gzip" | "deflate" | "brotli")
1094
+ @Message()
1095
+ class SensitivePayload {
1096
+ @Field("string") ssn!: string;
1097
+ @Field("string") name!: string;
1098
+ }
1099
+
1100
+ // Source must be configured with an amphora instance
1101
+ const source = new IrisSource({
1102
+ driver: "rabbit",
1103
+ url: "amqp://localhost",
1104
+ logger: myLogger,
1105
+ amphora: myAmphora,
1106
+ messages: [SensitivePayload],
1107
+ });
1108
+ ```
1109
+
1110
+ ## Message Subscribers
1111
+
1112
+ Observe message lifecycle events across all messages in a source:
1113
+
1114
+ ```typescript
1115
+ import type { IMessageSubscriber } from "@lindorm/iris";
1116
+
1117
+ const auditSubscriber: IMessageSubscriber = {
1118
+ beforePublish: async (msg) => {
1119
+ audit.log("publishing", msg);
1120
+ },
1121
+ afterConsume: async (msg) => {
1122
+ audit.log("consumed", msg);
1123
+ },
1124
+ onConsumeError: async (error, msg) => {
1125
+ audit.log("consume-failed", { error: error.message, msg });
1126
+ },
1127
+ };
1128
+
1129
+ source.addSubscriber(auditSubscriber);
1130
+
1131
+ // Remove later
1132
+ source.removeSubscriber(auditSubscriber);
1133
+ ```
1134
+
1135
+ ## Consume Envelope
1136
+
1137
+ Every subscribe/consume callback receives the message and an envelope with routing metadata:
1138
+
1139
+ ```typescript
1140
+ import type { ConsumeEnvelope } from "@lindorm/iris";
1141
+
1142
+ await bus.subscribe({
1143
+ topic: "OrderPlaced",
1144
+ callback: async (msg: OrderPlaced, envelope: ConsumeEnvelope) => {
1145
+ console.log(envelope.topic); // "OrderPlaced"
1146
+ console.log(envelope.messageName); // "OrderPlaced"
1147
+ console.log(envelope.namespace); // "orders" | null
1148
+ console.log(envelope.version); // 1
1149
+ console.log(envelope.headers); // Record<string, string>
1150
+ console.log(envelope.attempt); // 1 (increments on retry)
1151
+ console.log(envelope.correlationId); // string | null
1152
+ console.log(envelope.timestamp); // Unix timestamp
1153
+ },
1154
+ });
1155
+ ```
1156
+
1157
+ ## Message Manipulation
1158
+
1159
+ Every publisher, message bus, and worker queue provides utilities for working with message instances:
1160
+
1161
+ ```typescript
1162
+ const bus = source.messageBus(OrderPlaced);
1163
+
1164
+ // Create: new instance with auto-generated fields and defaults
1165
+ const msg = bus.create({ orderId: "abc-123", total: 59.99 });
1166
+
1167
+ // Hydrate: reconstruct from raw data (no auto-generation)
1168
+ const hydrated = bus.hydrate({ orderId: "abc-123", total: 59.99, id: "existing-uuid" });
1169
+
1170
+ // Copy: deep clone with a fresh identifier
1171
+ const copied = bus.copy(msg);
1172
+ // copied.orderId === msg.orderId, but copied.id !== msg.id
1173
+
1174
+ // Validate: throws IrisValidationError if invalid
1175
+ bus.validate(msg);
1176
+ ```
1177
+
1178
+ ## Publish Options
1179
+
1180
+ Override message-level defaults per publish call:
1181
+
1182
+ ```typescript
1183
+ await bus.publish(msg, {
1184
+ delay: 5000, // delay delivery by 5 seconds
1185
+ priority: 8, // override @Priority
1186
+ expiry: 60000, // override @Expiry (TTL in ms)
1187
+ key: "partition-key", // routing/partition key
1188
+ headers: { "x-source": "api" }, // additional headers
1189
+ });
1190
+ ```
1191
+
1192
+ ## Zod Validation
1193
+
1194
+ Use `@Schema()` with Zod for fine-grained field validation:
1195
+
1196
+ ```typescript
1197
+ import { z } from "zod";
1198
+ import { Schema, Field, Message } from "@lindorm/iris";
1199
+
1200
+ @Message()
1201
+ class UserCreated {
1202
+ @Schema(z.string().email())
1203
+ @Field("email")
1204
+ email!: string;
1205
+
1206
+ @Schema(z.number().int().min(13).max(150))
1207
+ @Field("integer")
1208
+ age!: number;
1209
+
1210
+ @Schema(z.string().regex(/^[A-Z]{2,3}$/))
1211
+ @Field("string")
1212
+ countryCode!: string;
1213
+ }
1214
+ ```
1215
+
1216
+ ## Cloning
1217
+
1218
+ Create independent source instances that share the underlying driver connection:
1219
+
1220
+ ```typescript
1221
+ const source = new IrisSource({
1222
+ driver: "rabbit",
1223
+ url: "amqp://localhost",
1224
+ logger: mainLogger,
1225
+ messages: [OrderPlaced],
1226
+ });
1227
+
1228
+ await source.connect();
1229
+ await source.setup();
1230
+
1231
+ // Clone shares the connection but has its own logger and subscriber registry
1232
+ const scoped = source.clone({ logger: requestLogger, context: { requestId: "abc" } });
1233
+
1234
+ const pub = scoped.publisher(OrderPlaced);
1235
+ await pub.publish(pub.create({ orderId: "abc-123", total: 59.99 }));
1236
+ ```
1237
+
1238
+ ## Driver Configuration
1239
+
1240
+ ### Memory
1241
+
1242
+ ```typescript
1243
+ const source = new IrisSource({
1244
+ driver: "memory",
1245
+ logger,
1246
+ messages: [OrderPlaced],
1247
+ });
1248
+ ```
1249
+
1250
+ ### RabbitMQ
1251
+
1252
+ ```typescript
1253
+ const source = new IrisSource({
1254
+ driver: "rabbit",
1255
+ url: "amqp://localhost",
1256
+ logger,
1257
+ messages: [OrderPlaced],
1258
+ exchange: "my-exchange", // optional
1259
+ prefetch: 10, // optional
1260
+ connection: {
1261
+ // optional
1262
+ heartbeat: 60,
1263
+ socketOptions: {
1264
+ timeout: 30000,
1265
+ keepAlive: true,
1266
+ },
1267
+ },
1268
+ });
1269
+ ```
1270
+
1271
+ ### Kafka
1272
+
1273
+ ```typescript
1274
+ const source = new IrisSource({
1275
+ driver: "kafka",
1276
+ brokers: ["localhost:9092"],
1277
+ logger,
1278
+ messages: [OrderPlaced],
1279
+ prefix: "my-app", // optional topic prefix
1280
+ prefetch: 100, // optional
1281
+ acks: -1, // optional: -1 (all), 0 (none), 1 (leader)
1282
+ sessionTimeoutMs: 30000, // optional
1283
+ connection: {
1284
+ // optional
1285
+ clientId: "my-service",
1286
+ ssl: true,
1287
+ sasl: {
1288
+ mechanism: "scram-sha-256",
1289
+ username: "user",
1290
+ password: "pass",
1291
+ },
1292
+ connectionTimeout: 10000,
1293
+ requestTimeout: 30000,
1294
+ retry: {
1295
+ retries: 5,
1296
+ initialRetryTime: 300,
1297
+ },
1298
+ },
1299
+ });
1300
+ ```
1301
+
1302
+ ### NATS
1303
+
1304
+ ```typescript
1305
+ const source = new IrisSource({
1306
+ driver: "nats",
1307
+ servers: "nats://localhost:4222", // string or Array<string>
1308
+ logger,
1309
+ messages: [OrderPlaced],
1310
+ prefix: "my-app", // optional
1311
+ prefetch: 50, // optional
1312
+ connection: {
1313
+ // optional
1314
+ user: "nats-user",
1315
+ pass: "nats-pass",
1316
+ tls: true,
1317
+ maxReconnectAttempts: 10,
1318
+ reconnectTimeWait: 2000,
1319
+ timeout: 10000,
1320
+ pingInterval: 30000,
1321
+ name: "my-service",
1322
+ },
1323
+ });
1324
+ ```
1325
+
1326
+ ### Redis Streams
1327
+
1328
+ ```typescript
1329
+ const source = new IrisSource({
1330
+ driver: "redis",
1331
+ url: "redis://localhost:6379",
1332
+ logger,
1333
+ messages: [OrderPlaced],
1334
+ prefix: "my-app", // optional
1335
+ prefetch: 50, // optional
1336
+ blockMs: 5000, // optional: XREAD block time
1337
+ maxStreamLength: 10000, // optional: MAXLEN cap per stream
1338
+ connection: {
1339
+ // optional
1340
+ host: "redis.internal",
1341
+ port: 6379,
1342
+ password: "secret",
1343
+ db: 0,
1344
+ tls: {},
1345
+ connectTimeout: 10000,
1346
+ commandTimeout: 5000,
1347
+ keepAlive: 30000,
1348
+ connectionName: "iris-worker",
1349
+ },
1350
+ });
1351
+ ```
1352
+
1353
+ ## Persistence (Delay and Dead Letter Stores)
1354
+
1355
+ Configure where delayed messages and dead letter entries are stored:
1356
+
1357
+ ```typescript
1358
+ const source = new IrisSource({
1359
+ driver: "rabbit",
1360
+ url: "amqp://localhost",
1361
+ logger,
1362
+ messages: [OrderPlaced],
1363
+ persistence: {
1364
+ // Delay store: holds messages until their scheduled delivery time
1365
+ delay: { type: "memory" },
1366
+ // or: { type: "redis", url: "redis://localhost:6379" },
1367
+ // or: { type: "custom", store: myDelayStore },
1368
+
1369
+ // Dead letter store: holds messages that exhausted all retries
1370
+ deadLetter: { type: "memory" },
1371
+ // or: { type: "redis", url: "redis://localhost:6379" },
1372
+ // or: { type: "custom", store: myDeadLetterStore },
1373
+ },
1374
+ });
1375
+ ```
1376
+
1377
+ ### Custom Stores
1378
+
1379
+ Implement `IDelayStore` and/or `IDeadLetterStore` for custom persistence:
1380
+
1381
+ ```typescript
1382
+ import type {
1383
+ IDelayStore,
1384
+ IDeadLetterStore,
1385
+ DelayedEntry,
1386
+ DeadLetterEntry,
1387
+ } from "@lindorm/iris";
1388
+
1389
+ class MyDelayStore implements IDelayStore {
1390
+ async schedule(entry: DelayedEntry): Promise<void> {
1391
+ /* ... */
1392
+ }
1393
+ async poll(now: number): Promise<Array<DelayedEntry>> {
1394
+ /* ... */
1395
+ }
1396
+ async cancel(id: string): Promise<boolean> {
1397
+ /* ... */
1398
+ }
1399
+ async size(): Promise<number> {
1400
+ /* ... */
1401
+ }
1402
+ async clear(): Promise<void> {
1403
+ /* ... */
1404
+ }
1405
+ async close(): Promise<void> {
1406
+ /* ... */
1407
+ }
1408
+ }
1409
+
1410
+ class MyDeadLetterStore implements IDeadLetterStore {
1411
+ async add(entry: DeadLetterEntry): Promise<void> {
1412
+ /* ... */
1413
+ }
1414
+ async list(options?: {
1415
+ topic?: string;
1416
+ limit?: number;
1417
+ offset?: number;
1418
+ }): Promise<Array<DeadLetterEntry>> {
1419
+ /* ... */
1420
+ }
1421
+ async get(id: string): Promise<DeadLetterEntry | null> {
1422
+ /* ... */
1423
+ }
1424
+ async remove(id: string): Promise<boolean> {
1425
+ /* ... */
1426
+ }
1427
+ async purge(options?: { topic?: string }): Promise<number> {
1428
+ /* ... */
1429
+ }
1430
+ async count(options?: { topic?: string }): Promise<number> {
1431
+ /* ... */
1432
+ }
1433
+ async close(): Promise<void> {
1434
+ /* ... */
1435
+ }
1436
+ }
1437
+ ```
1438
+
1439
+ ## Connection State
1440
+
1441
+ ```typescript
1442
+ const state = source.getConnectionState();
1443
+ // "disconnected" | "connecting" | "connected" | "reconnecting" | "draining"
1444
+
1445
+ source.onConnectionStateChange((state) => {
1446
+ console.log(`Connection state: ${state}`);
1447
+ });
1448
+
1449
+ // Health check
1450
+ const healthy = await source.ping();
1451
+ ```
1452
+
1453
+ ## Testing with Mocks
1454
+
1455
+ All mocks are available via the `@lindorm/iris/mocks` subpath:
1456
+
1457
+ ```typescript
1458
+ import {
1459
+ createMockIrisSource,
1460
+ createMockPublisher,
1461
+ createMockMessageBus,
1462
+ createMockWorkerQueue,
1463
+ createMockRpcClient,
1464
+ } from "@lindorm/iris/mocks";
1465
+ ```
1466
+
1467
+ ### Mock Source
1468
+
1469
+ ```typescript
1470
+ const source = createMockIrisSource();
1471
+
1472
+ // All methods are jest.fn() mocks
1473
+ expect(source.connect).not.toHaveBeenCalled();
1474
+
1475
+ await source.connect();
1476
+ expect(source.connect).toHaveBeenCalledTimes(1);
1477
+
1478
+ // Factory methods return mocks by default
1479
+ const bus = source.messageBus(OrderPlaced);
1480
+ const pub = source.publisher(OrderPlaced);
1481
+ const queue = source.workerQueue(OrderPlaced);
1482
+ const rpc = source.rpcClient(GetPrice, PriceResponse);
1483
+ ```
1484
+
1485
+ ### Mock Publisher
1486
+
1487
+ ```typescript
1488
+ const pub = createMockPublisher<OrderPlaced>();
1489
+
1490
+ const msg = pub.create({ orderId: "abc", total: 10 });
1491
+ await pub.publish(msg);
1492
+
1493
+ // Inspect published messages
1494
+ expect(pub.published).toHaveLength(1);
1495
+
1496
+ // Reset
1497
+ pub.clearPublished();
1498
+ expect(pub.published).toHaveLength(0);
1499
+ ```
1500
+
1501
+ ### Mock Message Bus
1502
+
1503
+ ```typescript
1504
+ const bus = createMockMessageBus<OrderPlaced>();
1505
+
1506
+ await bus.publish(bus.create({ orderId: "abc", total: 10 }));
1507
+
1508
+ expect(bus.published).toHaveLength(1);
1509
+ expect(bus.subscribe).not.toHaveBeenCalled();
1510
+
1511
+ bus.clearPublished();
1512
+ ```
1513
+
1514
+ ### Mock Worker Queue
1515
+
1516
+ ```typescript
1517
+ const queue = createMockWorkerQueue<OrderPlaced>();
1518
+
1519
+ await queue.publish(queue.create({ orderId: "abc", total: 10 }));
1520
+
1521
+ expect(queue.published).toHaveLength(1);
1522
+ expect(queue.consume).not.toHaveBeenCalled();
1523
+
1524
+ queue.clearPublished();
1525
+ ```
1526
+
1527
+ ### Mock RPC Client
1528
+
1529
+ ```typescript
1530
+ // Provide a response factory
1531
+ const client = createMockRpcClient<GetPrice, PriceResponse>((req) => {
1532
+ const res = new PriceResponse();
1533
+ res.price = 42.0;
1534
+ res.currency = "USD";
1535
+ return res;
1536
+ });
1537
+
1538
+ const req = new GetPrice();
1539
+ req.sku = "WIDGET-42";
1540
+
1541
+ const res = await client.request(req);
1542
+ expect(res.price).toBe(42.0);
1543
+ expect(client.requests).toHaveLength(1);
1544
+
1545
+ client.clearRequests();
1546
+ ```
1547
+
1548
+ ## Error Classes
1549
+
1550
+ All errors extend `IrisError`, which extends `LindormError`:
1551
+
1552
+ | Error Class | When |
1553
+ | ------------------------ | ----------------------------------------- |
1554
+ | `IrisError` | Base class for all iris errors |
1555
+ | `IrisDriverError` | Driver connection or operation failure |
1556
+ | `IrisMetadataError` | Invalid decorator configuration |
1557
+ | `IrisNotSupportedError` | Unsupported feature for the active driver |
1558
+ | `IrisPublishError` | Message publishing failure |
1559
+ | `IrisScannerError` | Message class scanning failure |
1560
+ | `IrisSerializationError` | Serialisation or deserialisation failure |
1561
+ | `IrisSourceError` | Source setup or configuration error |
1562
+ | `IrisTimeoutError` | Operation exceeded timeout |
1563
+ | `IrisTransportError` | Transport layer failure |
1564
+ | `IrisValidationError` | Message validation failure |
1565
+
1566
+ ```typescript
1567
+ import { IrisTimeoutError, IrisValidationError } from "@lindorm/iris";
1568
+
1569
+ try {
1570
+ await client.request(req, { timeout: 1000 });
1571
+ } catch (error) {
1572
+ if (error instanceof IrisTimeoutError) {
1573
+ // handle timeout
1574
+ }
1575
+ }
1576
+ ```
1577
+
1578
+ ## Full Example
1579
+
1580
+ ```typescript
1581
+ import {
1582
+ IrisSource,
1583
+ Message,
1584
+ Namespace,
1585
+ Version,
1586
+ Field,
1587
+ IdentifierField,
1588
+ TimestampField,
1589
+ Retry,
1590
+ DeadLetter,
1591
+ } from "@lindorm/iris";
1592
+ import type { IMessage, IMessageSubscriber } from "@lindorm/iris";
1593
+
1594
+ // --- Define messages ---
1595
+
1596
+ @Message()
1597
+ @Namespace("payments")
1598
+ @Version(1)
1599
+ @Retry({ maxRetries: 3, strategy: "exponential", delay: 1000 })
1600
+ @DeadLetter()
1601
+ class ChargeRequested {
1602
+ @IdentifierField() id!: string;
1603
+ @TimestampField() createdAt!: Date;
1604
+ @Field("string") paymentId!: string;
1605
+ @Field("float") amount!: number;
1606
+ @Field("string") currency!: string;
1607
+ }
1608
+
1609
+ @Message()
1610
+ @Namespace("payments")
1611
+ @Version(1)
1612
+ class ChargeCompleted {
1613
+ @IdentifierField() id!: string;
1614
+ @TimestampField() completedAt!: Date;
1615
+ @Field("string") paymentId!: string;
1616
+ @Field("boolean") success!: boolean;
1617
+ }
1618
+
1619
+ // --- Set up source ---
1620
+
1621
+ const source = new IrisSource({
1622
+ driver: "kafka",
1623
+ brokers: ["kafka-1:9092", "kafka-2:9092"],
1624
+ logger: appLogger,
1625
+ messages: [ChargeRequested, ChargeCompleted],
1626
+ persistence: {
1627
+ deadLetter: { type: "redis", url: "redis://localhost:6379" },
1628
+ },
1629
+ });
1630
+
1631
+ await source.connect();
1632
+ await source.setup();
1633
+
1634
+ // --- Observe lifecycle ---
1635
+
1636
+ const metricsSubscriber: IMessageSubscriber = {
1637
+ afterPublish: async (msg) => metrics.increment("messages.published"),
1638
+ afterConsume: async (msg) => metrics.increment("messages.consumed"),
1639
+ onConsumeError: async (err) => metrics.increment("messages.errors"),
1640
+ };
1641
+
1642
+ source.addSubscriber(metricsSubscriber);
1643
+
1644
+ // --- Worker: process charges ---
1645
+
1646
+ const queue = source.workerQueue(ChargeRequested);
1647
+ const completedPub = source.publisher(ChargeCompleted);
1648
+
1649
+ await queue.consume("payment-workers", async (msg, envelope) => {
1650
+ const result = await paymentGateway.charge(msg.paymentId, msg.amount, msg.currency);
1651
+
1652
+ const completed = completedPub.create({
1653
+ paymentId: msg.paymentId,
1654
+ success: result.ok,
1655
+ });
1656
+
1657
+ await completedPub.publish(completed);
1658
+ });
1659
+
1660
+ // --- Notify on completion ---
1661
+
1662
+ const completedBus = source.messageBus(ChargeCompleted);
1663
+
1664
+ await completedBus.subscribe({
1665
+ topic: "ChargeCompleted",
1666
+ queue: "notification-service",
1667
+ callback: async (msg) => {
1668
+ if (msg.success) {
1669
+ await emailService.sendReceipt(msg.paymentId);
1670
+ }
1671
+ },
1672
+ });
1673
+
1674
+ // --- Shutdown ---
1675
+
1676
+ process.on("SIGTERM", async () => {
1677
+ await source.drain();
1678
+ await source.disconnect();
1679
+ });
1680
+ ```
1681
+
1682
+ ## License
1683
+
1684
+ AGPL-3.0-or-later