@lindorm/iris 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +989 -0
  2. package/package.json +19 -12
package/README.md CHANGED
@@ -1 +1,990 @@
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
+ ## Messaging Patterns
96
+
97
+ ### Publisher (Fire-and-Forget)
98
+
99
+ Write-only. No subscriptions.
100
+
101
+ ```typescript
102
+ const pub = source.publisher(OrderPlaced);
103
+
104
+ const msg = pub.create({ orderId: "abc-123", total: 59.99 });
105
+ await pub.publish(msg);
106
+
107
+ // Batch publish
108
+ await pub.publish([msg1, msg2, msg3]);
109
+ ```
110
+
111
+ ### Message Bus (Pub/Sub + Queues)
112
+
113
+ Publish with topic-based subscriptions. Supports broadcast and competing-consumer queues.
114
+
115
+ ```typescript
116
+ const bus = source.messageBus(OrderPlaced);
117
+
118
+ // Broadcast: every subscriber receives every message
119
+ await bus.subscribe({
120
+ topic: "OrderPlaced",
121
+ callback: async (msg) => {
122
+ /* ... */
123
+ },
124
+ });
125
+
126
+ // Queue: messages are distributed round-robin among consumers
127
+ await bus.subscribe({
128
+ topic: "OrderPlaced",
129
+ queue: "order-processors",
130
+ callback: async (msg) => {
131
+ /* ... */
132
+ },
133
+ });
134
+
135
+ // Multiple subscriptions at once
136
+ await bus.subscribe([
137
+ { topic: "OrderPlaced", queue: "analytics", callback: handleAnalytics },
138
+ { topic: "OrderPlaced", queue: "notifications", callback: handleNotify },
139
+ ]);
140
+
141
+ // Unsubscribe
142
+ await bus.unsubscribe({ topic: "OrderPlaced", queue: "analytics" });
143
+ await bus.unsubscribeAll();
144
+ ```
145
+
146
+ ### Worker Queue (Competing Consumers)
147
+
148
+ Specialised for job distribution where each message is processed by exactly one consumer.
149
+
150
+ ```typescript
151
+ const queue = source.workerQueue(OrderPlaced);
152
+
153
+ // Register competing consumers
154
+ await queue.consume("process-orders", async (msg, envelope) => {
155
+ console.log(`Processing order ${msg.orderId} (attempt ${envelope.attempt})`);
156
+ });
157
+
158
+ // Publish work
159
+ await queue.publish(queue.create({ orderId: "abc-123", total: 59.99 }));
160
+
161
+ // Clean up
162
+ await queue.unconsume("process-orders");
163
+ await queue.unconsumeAll();
164
+ ```
165
+
166
+ ### RPC (Request/Response)
167
+
168
+ Synchronous request/response over the message broker.
169
+
170
+ ```typescript
171
+ @Message()
172
+ class GetPrice {
173
+ @Field("string") sku!: string;
174
+ }
175
+
176
+ @Message()
177
+ class PriceResponse {
178
+ @Field("float") price!: number;
179
+ @Field("string") currency!: string;
180
+ }
181
+
182
+ const client = source.rpcClient(GetPrice, PriceResponse);
183
+ const server = source.rpcServer(GetPrice, PriceResponse);
184
+
185
+ // Server: register handler
186
+ await server.serve(async (req) => {
187
+ const res = new PriceResponse();
188
+ res.price = await lookupPrice(req.sku);
189
+ res.currency = "USD";
190
+ return res;
191
+ });
192
+
193
+ // Client: send request
194
+ const req = new GetPrice();
195
+ req.sku = "WIDGET-42";
196
+
197
+ const res = await client.request(req);
198
+ console.log(`${res.price} ${res.currency}`); // 29.99 USD
199
+
200
+ // With timeout
201
+ const res2 = await client.request(req, { timeout: 5000 });
202
+
203
+ // Clean up
204
+ await client.close();
205
+ await server.unserveAll();
206
+ ```
207
+
208
+ ### Stream Processor (Pipelines)
209
+
210
+ Declarative stream processing with an immutable builder pattern.
211
+
212
+ ```typescript
213
+ @Message()
214
+ class RawEvent {
215
+ @Field("string") type!: string;
216
+ @Field("float") value!: number;
217
+ }
218
+
219
+ @Message()
220
+ class AggregatedEvent {
221
+ @Field("float") sum!: number;
222
+ @Field("integer") count!: number;
223
+ }
224
+
225
+ const pipeline = source
226
+ .stream()
227
+ .from(RawEvent)
228
+ .filter((msg) => msg.value > 0)
229
+ .map((msg) => {
230
+ const out = new AggregatedEvent();
231
+ out.sum = msg.value;
232
+ out.count = 1;
233
+ return out;
234
+ })
235
+ .to(AggregatedEvent);
236
+
237
+ await pipeline.start();
238
+ // pipeline.isRunning() === true
239
+
240
+ await pipeline.pause();
241
+ await pipeline.resume();
242
+ await pipeline.stop();
243
+ ```
244
+
245
+ ## Message Decorators
246
+
247
+ ### Class-Level
248
+
249
+ | Decorator | Description |
250
+ | -------------------- | ------------------------------------------------------ |
251
+ | `@Message(opts?)` | Mark class as a message type |
252
+ | `@AbstractMessage()` | Mark as abstract (non-concrete base) |
253
+ | `@Namespace(ns)` | Set message namespace |
254
+ | `@Version(n)` | Set message version (positive integer) |
255
+ | `@Topic(fn)` | Dynamic topic resolution callback |
256
+ | `@Broadcast()` | Deliver to all subscribers (not just one per queue) |
257
+ | `@Persistent()` | Mark message as persistent/durable |
258
+ | `@Priority(n)` | Set priority (integer 0-10) |
259
+ | `@Delay(ms)` | Default delivery delay in milliseconds |
260
+ | `@Expiry(ms)` | Message expiration in milliseconds |
261
+ | `@Encrypted(pred?)` | Enable payload encryption via `@lindorm/amphora` |
262
+ | `@Compressed(alg?)` | Enable compression (`"gzip"`, `"deflate"`, `"brotli"`) |
263
+ | `@Retry(opts?)` | Configure retry behaviour on consume failure |
264
+ | `@DeadLetter()` | Route failed messages to dead letter store |
265
+
266
+ ### Field-Level
267
+
268
+ | Decorator | Description |
269
+ | ---------------------- | ---------------------------------------------------------------------------- |
270
+ | `@Field(type, opts?)` | Declare field with type and options |
271
+ | `@IdentifierField()` | Auto-generated UUID field |
272
+ | `@CorrelationField()` | Auto-generated UUID for correlation tracking |
273
+ | `@TimestampField()` | Auto-generated Date field |
274
+ | `@MandatoryField()` | Boolean field, defaults to `false` |
275
+ | `@PersistentField()` | Boolean persistence flag, defaults to `false` |
276
+ | `@Generated(strategy)` | Auto-generate value (`"uuid"`, `"date"`, `"string"`, `"integer"`, `"float"`) |
277
+ | `@Header(name?)` | Promote field to message header |
278
+ | `@Enum(values)` | Restrict to enum values |
279
+ | `@Min(n)` | Minimum value constraint |
280
+ | `@Max(n)` | Maximum value constraint |
281
+ | `@Schema(zodType)` | Zod schema validation |
282
+ | `@Transform(opts)` | Custom serialisation/deserialisation transform |
283
+
284
+ ### Lifecycle Hooks
285
+
286
+ | Decorator | Description |
287
+ | --------------------- | ----------------------------------------------- |
288
+ | `@OnCreate(fn)` | Called when message instance is created |
289
+ | `@OnHydrate(fn)` | Called when message is rehydrated from raw data |
290
+ | `@OnValidate(fn)` | Called when message is validated |
291
+ | `@BeforePublish(fn)` | Called before publishing |
292
+ | `@AfterPublish(fn)` | Called after publishing |
293
+ | `@BeforeConsume(fn)` | Called before consume callback |
294
+ | `@AfterConsume(fn)` | Called after successful consume |
295
+ | `@OnConsumeError(fn)` | Called when consume callback throws |
296
+
297
+ ## Field Types
298
+
299
+ The `@Field()` decorator accepts the following type identifiers:
300
+
301
+ `"array"` | `"bigint"` | `"boolean"` | `"date"` | `"email"` | `"enum"` | `"float"` | `"integer"` | `"object"` | `"string"` | `"url"` | `"uuid"`
302
+
303
+ ```typescript
304
+ @Message()
305
+ class FullExample {
306
+ @IdentifierField() id!: string;
307
+ @CorrelationField() correlationId!: string;
308
+ @TimestampField() createdAt!: Date;
309
+
310
+ @Field("string") name!: string;
311
+ @Field("integer") count!: number;
312
+ @Field("float") price!: number;
313
+ @Field("boolean") active!: boolean;
314
+ @Field("date") expiresAt!: Date;
315
+ @Field("uuid") referenceId!: string;
316
+ @Field("email") contactEmail!: string;
317
+ @Field("url") callbackUrl!: string;
318
+ @Field("array") tags!: Array<string>;
319
+ @Field("object") metadata!: Record<string, unknown>;
320
+
321
+ @Field("string", { nullable: true }) description!: string | null;
322
+ @Field("string", { optional: true }) nickname?: string;
323
+ @Field("integer", { default: 0 }) retryCount!: number;
324
+ @Field("string", { default: () => "generated" }) code!: string;
325
+ }
326
+ ```
327
+
328
+ ## Retry and Dead Letter
329
+
330
+ Configure automatic retry with backoff strategies and dead letter routing for permanently failed messages.
331
+
332
+ ```typescript
333
+ @Retry({
334
+ maxRetries: 5,
335
+ strategy: "exponential", // "constant" | "linear" | "exponential"
336
+ delay: 1000, // initial delay in ms
337
+ delayMax: 30000, // maximum delay cap
338
+ multiplier: 2, // exponential multiplier
339
+ jitter: true, // add randomness to prevent thundering herd
340
+ })
341
+ @DeadLetter()
342
+ @Message()
343
+ class PaymentCharge {
344
+ @Field("string") chargeId!: string;
345
+ @Field("float") amount!: number;
346
+ }
347
+ ```
348
+
349
+ **Retry strategies:**
350
+
351
+ | Strategy | Delay pattern (base=1000, multiplier=2) |
352
+ | --------------- | ------------------------------------------- |
353
+ | `"constant"` | 1000, 1000, 1000, ... |
354
+ | `"linear"` | 1000, 2000, 3000, ... |
355
+ | `"exponential"` | 1000, 2000, 4000, 8000, ... (capped at max) |
356
+
357
+ ## Dynamic Topics
358
+
359
+ Route messages to different topics based on their content:
360
+
361
+ ```typescript
362
+ @Topic((msg: any) => `events.${msg.region}.${msg.type}`)
363
+ @Message()
364
+ class RegionalEvent {
365
+ @Field("string") region!: string;
366
+ @Field("string") type!: string;
367
+ @Field("object") data!: Record<string, unknown>;
368
+ }
369
+
370
+ const bus = source.messageBus(RegionalEvent);
371
+ const msg = bus.create({ region: "eu-west", type: "signup", data: {} });
372
+ await bus.publish(msg); // Published to "events.eu-west.signup"
373
+ ```
374
+
375
+ ## Encryption and Compression
376
+
377
+ ```typescript
378
+ import { Encrypted, Compressed } from "@lindorm/iris";
379
+
380
+ @Encrypted() // Encrypt payload via amphora
381
+ @Compressed("brotli") // Then compress ("gzip" | "deflate" | "brotli")
382
+ @Message()
383
+ class SensitivePayload {
384
+ @Field("string") ssn!: string;
385
+ @Field("string") name!: string;
386
+ }
387
+
388
+ // Source must be configured with an amphora instance
389
+ const source = new IrisSource({
390
+ driver: "rabbit",
391
+ url: "amqp://localhost",
392
+ logger: myLogger,
393
+ amphora: myAmphora,
394
+ messages: [SensitivePayload],
395
+ });
396
+ ```
397
+
398
+ ## Message Subscribers
399
+
400
+ Observe message lifecycle events across all messages in a source:
401
+
402
+ ```typescript
403
+ import type { IMessageSubscriber } from "@lindorm/iris";
404
+
405
+ const auditSubscriber: IMessageSubscriber = {
406
+ beforePublish: async (msg) => {
407
+ audit.log("publishing", msg);
408
+ },
409
+ afterConsume: async (msg) => {
410
+ audit.log("consumed", msg);
411
+ },
412
+ onConsumeError: async (error, msg) => {
413
+ audit.log("consume-failed", { error: error.message, msg });
414
+ },
415
+ };
416
+
417
+ source.addSubscriber(auditSubscriber);
418
+
419
+ // Remove later
420
+ source.removeSubscriber(auditSubscriber);
421
+ ```
422
+
423
+ **Hook execution order on publish + consume:**
424
+
425
+ ```
426
+ 1. @BeforePublish hook
427
+ 2. subscriber.beforePublish
428
+ 3. (transport publishes)
429
+ 4. (transport delivers to consumer)
430
+ 5. @BeforeConsume hook
431
+ 6. subscriber.beforeConsume
432
+ 7. callback executes
433
+ 8. @AfterConsume hook
434
+ 9. subscriber.afterConsume
435
+ 10. @AfterPublish hook
436
+ 11. subscriber.afterPublish
437
+ ```
438
+
439
+ On error at step 7, steps 8-11 are replaced by `@OnConsumeError` and `subscriber.onConsumeError`.
440
+
441
+ ## Consume Envelope
442
+
443
+ Every subscribe/consume callback receives the message and an envelope with routing metadata:
444
+
445
+ ```typescript
446
+ import type { ConsumeEnvelope } from "@lindorm/iris";
447
+
448
+ await bus.subscribe({
449
+ topic: "OrderPlaced",
450
+ callback: async (msg: OrderPlaced, envelope: ConsumeEnvelope) => {
451
+ console.log(envelope.topic); // "OrderPlaced"
452
+ console.log(envelope.messageName); // "OrderPlaced"
453
+ console.log(envelope.namespace); // "orders" | null
454
+ console.log(envelope.version); // 1
455
+ console.log(envelope.headers); // Record<string, string>
456
+ console.log(envelope.attempt); // 1 (increments on retry)
457
+ console.log(envelope.correlationId); // string | null
458
+ console.log(envelope.timestamp); // Unix timestamp
459
+ },
460
+ });
461
+ ```
462
+
463
+ ## Cloning
464
+
465
+ Create independent source instances that share the underlying driver connection:
466
+
467
+ ```typescript
468
+ const source = new IrisSource({
469
+ driver: "rabbit",
470
+ url: "amqp://localhost",
471
+ logger: mainLogger,
472
+ messages: [OrderPlaced],
473
+ });
474
+
475
+ await source.connect();
476
+ await source.setup();
477
+
478
+ // Clone shares the connection but has its own logger and subscriber registry
479
+ const scoped = source.clone({ logger: requestLogger, context: { requestId: "abc" } });
480
+
481
+ const pub = scoped.publisher(OrderPlaced);
482
+ await pub.publish(pub.create({ orderId: "abc-123", total: 59.99 }));
483
+ ```
484
+
485
+ ## Driver Configuration
486
+
487
+ ### Memory
488
+
489
+ ```typescript
490
+ const source = new IrisSource({
491
+ driver: "memory",
492
+ logger,
493
+ messages: [OrderPlaced],
494
+ });
495
+ ```
496
+
497
+ ### RabbitMQ
498
+
499
+ ```typescript
500
+ const source = new IrisSource({
501
+ driver: "rabbit",
502
+ url: "amqp://localhost",
503
+ logger,
504
+ messages: [OrderPlaced],
505
+ exchange: "my-exchange", // optional
506
+ prefetch: 10, // optional
507
+ connection: {
508
+ // optional
509
+ heartbeat: 60,
510
+ socketOptions: {
511
+ timeout: 30000,
512
+ keepAlive: true,
513
+ },
514
+ },
515
+ });
516
+ ```
517
+
518
+ ### Kafka
519
+
520
+ ```typescript
521
+ const source = new IrisSource({
522
+ driver: "kafka",
523
+ brokers: ["localhost:9092"],
524
+ logger,
525
+ messages: [OrderPlaced],
526
+ prefix: "my-app", // optional topic prefix
527
+ prefetch: 100, // optional
528
+ acks: -1, // optional: -1 (all), 0 (none), 1 (leader)
529
+ sessionTimeoutMs: 30000, // optional
530
+ connection: {
531
+ // optional
532
+ clientId: "my-service",
533
+ ssl: true,
534
+ sasl: {
535
+ mechanism: "scram-sha-256",
536
+ username: "user",
537
+ password: "pass",
538
+ },
539
+ connectionTimeout: 10000,
540
+ requestTimeout: 30000,
541
+ retry: {
542
+ retries: 5,
543
+ initialRetryTime: 300,
544
+ },
545
+ },
546
+ });
547
+ ```
548
+
549
+ ### NATS
550
+
551
+ ```typescript
552
+ const source = new IrisSource({
553
+ driver: "nats",
554
+ servers: "nats://localhost:4222", // string or Array<string>
555
+ logger,
556
+ messages: [OrderPlaced],
557
+ prefix: "my-app", // optional
558
+ prefetch: 50, // optional
559
+ connection: {
560
+ // optional
561
+ user: "nats-user",
562
+ pass: "nats-pass",
563
+ tls: true,
564
+ maxReconnectAttempts: 10,
565
+ reconnectTimeWait: 2000,
566
+ timeout: 10000,
567
+ pingInterval: 30000,
568
+ name: "my-service",
569
+ },
570
+ });
571
+ ```
572
+
573
+ ### Redis Streams
574
+
575
+ ```typescript
576
+ const source = new IrisSource({
577
+ driver: "redis",
578
+ url: "redis://localhost:6379",
579
+ logger,
580
+ messages: [OrderPlaced],
581
+ prefix: "my-app", // optional
582
+ prefetch: 50, // optional
583
+ blockMs: 5000, // optional: XREAD block time
584
+ maxStreamLength: 10000, // optional: MAXLEN cap per stream
585
+ connection: {
586
+ // optional
587
+ host: "redis.internal",
588
+ port: 6379,
589
+ password: "secret",
590
+ db: 0,
591
+ tls: {},
592
+ connectTimeout: 10000,
593
+ commandTimeout: 5000,
594
+ keepAlive: 30000,
595
+ connectionName: "iris-worker",
596
+ },
597
+ });
598
+ ```
599
+
600
+ ## Persistence (Delay and Dead Letter Stores)
601
+
602
+ Configure where delayed messages and dead letter entries are stored:
603
+
604
+ ```typescript
605
+ const source = new IrisSource({
606
+ driver: "rabbit",
607
+ url: "amqp://localhost",
608
+ logger,
609
+ messages: [OrderPlaced],
610
+ persistence: {
611
+ // Delay store: holds messages until their scheduled delivery time
612
+ delay: { type: "memory" },
613
+ // or: { type: "redis", url: "redis://localhost:6379" },
614
+ // or: { type: "custom", store: myDelayStore },
615
+
616
+ // Dead letter store: holds messages that exhausted all retries
617
+ deadLetter: { type: "memory" },
618
+ // or: { type: "redis", url: "redis://localhost:6379" },
619
+ // or: { type: "custom", store: myDeadLetterStore },
620
+ },
621
+ });
622
+ ```
623
+
624
+ ### Custom Stores
625
+
626
+ Implement `IDelayStore` and/or `IDeadLetterStore` for custom persistence:
627
+
628
+ ```typescript
629
+ import type {
630
+ IDelayStore,
631
+ IDeadLetterStore,
632
+ DelayedEntry,
633
+ DeadLetterEntry,
634
+ } from "@lindorm/iris";
635
+
636
+ class MyDelayStore implements IDelayStore {
637
+ async schedule(entry: DelayedEntry): Promise<void> {
638
+ /* ... */
639
+ }
640
+ async poll(now: number): Promise<Array<DelayedEntry>> {
641
+ /* ... */
642
+ }
643
+ async cancel(id: string): Promise<boolean> {
644
+ /* ... */
645
+ }
646
+ async size(): Promise<number> {
647
+ /* ... */
648
+ }
649
+ async clear(): Promise<void> {
650
+ /* ... */
651
+ }
652
+ async close(): Promise<void> {
653
+ /* ... */
654
+ }
655
+ }
656
+
657
+ class MyDeadLetterStore implements IDeadLetterStore {
658
+ async add(entry: DeadLetterEntry): Promise<void> {
659
+ /* ... */
660
+ }
661
+ async list(options?: {
662
+ topic?: string;
663
+ limit?: number;
664
+ offset?: number;
665
+ }): Promise<Array<DeadLetterEntry>> {
666
+ /* ... */
667
+ }
668
+ async get(id: string): Promise<DeadLetterEntry | null> {
669
+ /* ... */
670
+ }
671
+ async remove(id: string): Promise<boolean> {
672
+ /* ... */
673
+ }
674
+ async purge(options?: { topic?: string }): Promise<number> {
675
+ /* ... */
676
+ }
677
+ async count(options?: { topic?: string }): Promise<number> {
678
+ /* ... */
679
+ }
680
+ async close(): Promise<void> {
681
+ /* ... */
682
+ }
683
+ }
684
+ ```
685
+
686
+ ## Connection State
687
+
688
+ ```typescript
689
+ const state = source.getConnectionState();
690
+ // "disconnected" | "connecting" | "connected" | "reconnecting" | "draining"
691
+
692
+ source.onConnectionStateChange((state) => {
693
+ console.log(`Connection state: ${state}`);
694
+ });
695
+
696
+ // Health check
697
+ const healthy = await source.ping();
698
+ ```
699
+
700
+ ## Message Manipulation
701
+
702
+ Every publisher, message bus, and worker queue provides utilities for working with message instances:
703
+
704
+ ```typescript
705
+ const bus = source.messageBus(OrderPlaced);
706
+
707
+ // Create: new instance with auto-generated fields and defaults
708
+ const msg = bus.create({ orderId: "abc-123", total: 59.99 });
709
+
710
+ // Hydrate: reconstruct from raw data (no auto-generation)
711
+ const hydrated = bus.hydrate({ orderId: "abc-123", total: 59.99, id: "existing-uuid" });
712
+
713
+ // Copy: deep clone with a fresh identifier
714
+ const copied = bus.copy(msg);
715
+ // copied.orderId === msg.orderId, but copied.id !== msg.id
716
+
717
+ // Validate: throws IrisValidationError if invalid
718
+ bus.validate(msg);
719
+ ```
720
+
721
+ ## Publish Options
722
+
723
+ Override message-level defaults per publish call:
724
+
725
+ ```typescript
726
+ await bus.publish(msg, {
727
+ delay: 5000, // delay delivery by 5 seconds
728
+ priority: 8, // override @Priority
729
+ expiry: 60000, // override @Expiry (TTL in ms)
730
+ key: "partition-key", // routing/partition key
731
+ headers: { "x-source": "api" }, // additional headers
732
+ });
733
+ ```
734
+
735
+ ## Zod Validation
736
+
737
+ Use `@Schema()` with Zod for fine-grained field validation:
738
+
739
+ ```typescript
740
+ import { z } from "zod";
741
+ import { Schema, Field, Message } from "@lindorm/iris";
742
+
743
+ @Message()
744
+ class UserCreated {
745
+ @Schema(z.string().email())
746
+ @Field("email")
747
+ email!: string;
748
+
749
+ @Schema(z.number().int().min(13).max(150))
750
+ @Field("integer")
751
+ age!: number;
752
+
753
+ @Schema(z.string().regex(/^[A-Z]{2,3}$/))
754
+ @Field("string")
755
+ countryCode!: string;
756
+ }
757
+ ```
758
+
759
+ ## Testing with Mocks
760
+
761
+ All mocks are available via the `@lindorm/iris/mocks` subpath:
762
+
763
+ ```typescript
764
+ import {
765
+ createMockIrisSource,
766
+ createMockPublisher,
767
+ createMockMessageBus,
768
+ createMockWorkerQueue,
769
+ createMockRpcClient,
770
+ } from "@lindorm/iris/mocks";
771
+ ```
772
+
773
+ ### Mock Source
774
+
775
+ ```typescript
776
+ const source = createMockIrisSource();
777
+
778
+ // All methods are jest.fn() mocks
779
+ expect(source.connect).not.toHaveBeenCalled();
780
+
781
+ await source.connect();
782
+ expect(source.connect).toHaveBeenCalledTimes(1);
783
+
784
+ // Factory methods return mocks by default
785
+ const bus = source.messageBus(OrderPlaced);
786
+ const pub = source.publisher(OrderPlaced);
787
+ const queue = source.workerQueue(OrderPlaced);
788
+ const rpc = source.rpcClient(GetPrice, PriceResponse);
789
+ ```
790
+
791
+ ### Mock Publisher
792
+
793
+ ```typescript
794
+ const pub = createMockPublisher<OrderPlaced>();
795
+
796
+ const msg = pub.create({ orderId: "abc", total: 10 });
797
+ await pub.publish(msg);
798
+
799
+ // Inspect published messages
800
+ expect(pub.published).toHaveLength(1);
801
+
802
+ // Reset
803
+ pub.clearPublished();
804
+ expect(pub.published).toHaveLength(0);
805
+ ```
806
+
807
+ ### Mock Message Bus
808
+
809
+ ```typescript
810
+ const bus = createMockMessageBus<OrderPlaced>();
811
+
812
+ await bus.publish(bus.create({ orderId: "abc", total: 10 }));
813
+
814
+ expect(bus.published).toHaveLength(1);
815
+ expect(bus.subscribe).not.toHaveBeenCalled();
816
+
817
+ bus.clearPublished();
818
+ ```
819
+
820
+ ### Mock Worker Queue
821
+
822
+ ```typescript
823
+ const queue = createMockWorkerQueue<OrderPlaced>();
824
+
825
+ await queue.publish(queue.create({ orderId: "abc", total: 10 }));
826
+
827
+ expect(queue.published).toHaveLength(1);
828
+ expect(queue.consume).not.toHaveBeenCalled();
829
+
830
+ queue.clearPublished();
831
+ ```
832
+
833
+ ### Mock RPC Client
834
+
835
+ ```typescript
836
+ // Provide a response factory
837
+ const client = createMockRpcClient<GetPrice, PriceResponse>((req) => {
838
+ const res = new PriceResponse();
839
+ res.price = 42.0;
840
+ res.currency = "USD";
841
+ return res;
842
+ });
843
+
844
+ const req = new GetPrice();
845
+ req.sku = "WIDGET-42";
846
+
847
+ const res = await client.request(req);
848
+ expect(res.price).toBe(42.0);
849
+ expect(client.requests).toHaveLength(1);
850
+
851
+ client.clearRequests();
852
+ ```
853
+
854
+ ## Error Classes
855
+
856
+ All errors extend `IrisError`, which extends `LindormError`:
857
+
858
+ | Error Class | When |
859
+ | ------------------------ | ----------------------------------------- |
860
+ | `IrisError` | Base class for all iris errors |
861
+ | `IrisDriverError` | Driver connection or operation failure |
862
+ | `IrisMetadataError` | Invalid decorator configuration |
863
+ | `IrisNotSupportedError` | Unsupported feature for the active driver |
864
+ | `IrisPublishError` | Message publishing failure |
865
+ | `IrisScannerError` | Message class scanning failure |
866
+ | `IrisSerializationError` | Serialisation or deserialisation failure |
867
+ | `IrisSourceError` | Source setup or configuration error |
868
+ | `IrisTimeoutError` | Operation exceeded timeout |
869
+ | `IrisTransportError` | Transport layer failure |
870
+ | `IrisValidationError` | Message validation failure |
871
+
872
+ ```typescript
873
+ import { IrisTimeoutError, IrisValidationError } from "@lindorm/iris";
874
+
875
+ try {
876
+ await client.request(req, { timeout: 1000 });
877
+ } catch (error) {
878
+ if (error instanceof IrisTimeoutError) {
879
+ // handle timeout
880
+ }
881
+ }
882
+ ```
883
+
884
+ ## Full Example
885
+
886
+ ```typescript
887
+ import {
888
+ IrisSource,
889
+ Message,
890
+ Namespace,
891
+ Version,
892
+ Field,
893
+ IdentifierField,
894
+ TimestampField,
895
+ Retry,
896
+ DeadLetter,
897
+ } from "@lindorm/iris";
898
+ import type { IMessage, IMessageSubscriber } from "@lindorm/iris";
899
+
900
+ // --- Define messages ---
901
+
902
+ @Message()
903
+ @Namespace("payments")
904
+ @Version(1)
905
+ @Retry({ maxRetries: 3, strategy: "exponential", delay: 1000 })
906
+ @DeadLetter()
907
+ class ChargeRequested {
908
+ @IdentifierField() id!: string;
909
+ @TimestampField() createdAt!: Date;
910
+ @Field("string") paymentId!: string;
911
+ @Field("float") amount!: number;
912
+ @Field("string") currency!: string;
913
+ }
914
+
915
+ @Message()
916
+ @Namespace("payments")
917
+ @Version(1)
918
+ class ChargeCompleted {
919
+ @IdentifierField() id!: string;
920
+ @TimestampField() completedAt!: Date;
921
+ @Field("string") paymentId!: string;
922
+ @Field("boolean") success!: boolean;
923
+ }
924
+
925
+ // --- Set up source ---
926
+
927
+ const source = new IrisSource({
928
+ driver: "kafka",
929
+ brokers: ["kafka-1:9092", "kafka-2:9092"],
930
+ logger: appLogger,
931
+ messages: [ChargeRequested, ChargeCompleted],
932
+ persistence: {
933
+ deadLetter: { type: "redis", url: "redis://localhost:6379" },
934
+ },
935
+ });
936
+
937
+ await source.connect();
938
+ await source.setup();
939
+
940
+ // --- Observe lifecycle ---
941
+
942
+ const metricsSubscriber: IMessageSubscriber = {
943
+ afterPublish: async (msg) => metrics.increment("messages.published"),
944
+ afterConsume: async (msg) => metrics.increment("messages.consumed"),
945
+ onConsumeError: async (err) => metrics.increment("messages.errors"),
946
+ };
947
+
948
+ source.addSubscriber(metricsSubscriber);
949
+
950
+ // --- Worker: process charges ---
951
+
952
+ const queue = source.workerQueue(ChargeRequested);
953
+ const completedPub = source.publisher(ChargeCompleted);
954
+
955
+ await queue.consume("payment-workers", async (msg, envelope) => {
956
+ const result = await paymentGateway.charge(msg.paymentId, msg.amount, msg.currency);
957
+
958
+ const completed = completedPub.create({
959
+ paymentId: msg.paymentId,
960
+ success: result.ok,
961
+ });
962
+
963
+ await completedPub.publish(completed);
964
+ });
965
+
966
+ // --- Notify on completion ---
967
+
968
+ const completedBus = source.messageBus(ChargeCompleted);
969
+
970
+ await completedBus.subscribe({
971
+ topic: "ChargeCompleted",
972
+ queue: "notification-service",
973
+ callback: async (msg) => {
974
+ if (msg.success) {
975
+ await emailService.sendReceipt(msg.paymentId);
976
+ }
977
+ },
978
+ });
979
+
980
+ // --- Shutdown ---
981
+
982
+ process.on("SIGTERM", async () => {
983
+ await source.drain();
984
+ await source.disconnect();
985
+ });
986
+ ```
987
+
988
+ ## License
989
+
990
+ AGPL-3.0-or-later
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lindorm/iris",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "author": "Jonn Nilsson",
6
6
  "repository": {
@@ -26,6 +26,13 @@
26
26
  "default": "./dist/mocks/index.js"
27
27
  }
28
28
  },
29
+ "typesVersions": {
30
+ "*": {
31
+ "mocks": [
32
+ "dist/mocks/index.d.ts"
33
+ ]
34
+ }
35
+ },
29
36
  "imports": {
30
37
  "#internal/*": "./dist/internal/*.js"
31
38
  },
@@ -44,20 +51,20 @@
44
51
  "verify": "npm run typecheck; npm run build; npm test"
45
52
  },
46
53
  "dependencies": {
47
- "@lindorm/aes": "^0.6.1",
48
- "@lindorm/amphora": "^0.3.3",
49
- "@lindorm/errors": "^0.1.14",
50
- "@lindorm/is": "^0.1.13",
51
- "@lindorm/json-kit": "^0.5.5",
52
- "@lindorm/logger": "^0.5.0",
54
+ "@lindorm/aes": "^0.6.3",
55
+ "@lindorm/amphora": "^0.3.4",
56
+ "@lindorm/errors": "^0.1.16",
57
+ "@lindorm/is": "^0.1.14",
58
+ "@lindorm/json-kit": "^0.5.6",
59
+ "@lindorm/logger": "^0.5.2",
53
60
  "@lindorm/random": "^0.2.2",
54
- "@lindorm/retry": "^0.2.0",
55
- "@lindorm/scanner": "^0.4.0",
61
+ "@lindorm/retry": "^0.2.1",
62
+ "@lindorm/scanner": "^0.4.2",
56
63
  "zod": "^4.3.6"
57
64
  },
58
65
  "devDependencies": {
59
- "@lindorm/kryptos": "^0.5.1",
60
- "@lindorm/types": "^0.4.0",
66
+ "@lindorm/kryptos": "^0.5.3",
67
+ "@lindorm/types": "^0.4.1",
61
68
  "@types/amqplib": "^0.10.8",
62
69
  "amqplib": "^0.10.9",
63
70
  "ioredis": "^5.10.0",
@@ -84,5 +91,5 @@
84
91
  "optional": true
85
92
  }
86
93
  },
87
- "gitHead": "cb6565b22488dd1a314e86026ab170d0c98c7f00"
94
+ "gitHead": "a771f3669e540fb78fecf0ffc0e58e0f417f086c"
88
95
  }