@vytches/ddd-messaging 0.29.3 → 0.31.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LLMGUIDE.md CHANGED
@@ -14,71 +14,88 @@ True to the library's philosophy: **no database adapter ships with the package**
14
14
 
15
15
  ```typescript
16
16
  import {
17
- OutboxService,
18
17
  OutboxProcessor,
19
18
  OutboxMessageFactory,
20
19
  EventBusOutboxHandler,
21
- MessageStatus,
22
20
  MessagePriority,
23
21
  type IOutboxRepository,
22
+ type IOutboxMessageHandler,
24
23
  } from '@vytches/ddd-messaging';
25
24
 
26
- // 1. Implement the repository for your storage (example sketch)
27
- class PgOutboxRepository implements IOutboxRepository {
28
- async save(message) {
25
+ // 1. Implement the repository for your storage
26
+ class PgOutboxRepository extends IOutboxRepository {
27
+ async saveMessage(message) {
29
28
  /* INSERT INTO outbox ... */
30
29
  }
31
- async findPending(limit) {
32
- /* SELECT ... WHERE status = 'pending' */
30
+ async saveBatch(messages) {
31
+ /* INSERT INTO outbox ... */
32
+ }
33
+ async getUnprocessedMessages(limit, priorityOrder, messageTypes) {
34
+ // IMPORTANT: order by priorityOrder array index, NOT alphabetically
35
+ // See "Priority ordering in repository implementations" below
36
+ }
37
+ async getById(id) {
38
+ /* SELECT ... */
33
39
  }
34
- async markDispatched(id) {
40
+ async updateStatus(id, status, error?) {
35
41
  /* UPDATE ... */
36
42
  }
37
- async markFailed(id, error) {
43
+ async updateStatusBatch(ids, status) {
38
44
  /* UPDATE ... */
39
45
  }
40
- // ...
46
+ async incrementAttempt(id) {
47
+ /* UPDATE ... RETURNING attempts */
48
+ }
49
+ async deleteByStatusAndAge(olderThan, status) {
50
+ /* DELETE ... */
51
+ }
52
+ async scheduleMessage(message, processAfter) {
53
+ /* INSERT ... */
54
+ }
41
55
  }
42
56
 
43
- // 2. Wire up the outbox service
57
+ // 2. Wire up the processor
44
58
  const repo = new PgOutboxRepository(db);
45
- const factory = new OutboxMessageFactory();
46
59
  const handler = new EventBusOutboxHandler(integrationBus);
47
- const processor = new OutboxProcessor(repo, handler, {
48
- pollIntervalMs: 1_000,
60
+
61
+ const processor = new OutboxProcessor(repo, {
49
62
  batchSize: 50,
63
+ processingInterval: 1_000,
64
+ retryBackoff: { initial: 1_000, multiplier: 2, maxDelay: 300_000 },
50
65
  });
66
+ processor.registerHandler('order.placed', handler);
67
+ processor.start();
51
68
 
52
- const outbox = new OutboxService(repo, factory, processor);
53
-
54
- // 3. In a transaction with your aggregate save
69
+ // 3. Enqueue in the same transaction as your aggregate save
70
+ const factory = new OutboxMessageFactory();
55
71
  await db.transaction(async tx => {
56
72
  await orderRepo.save(order, tx);
57
- await outbox.enqueue(
58
- integrationEvent,
59
- { priority: MessagePriority.High },
60
- tx
73
+ await repo.saveMessage(
74
+ factory.create(integrationEvent, {
75
+ priority: MessagePriority.HIGH,
76
+ })
61
77
  );
62
78
  });
63
79
  ```
64
80
 
65
81
  ## Key API
66
82
 
67
- | Export | Kind | Description |
68
- | ------------------------ | --------- | ---------------------------------------------------------------- |
69
- | `IOutboxMessage` | interface | The shape of a stored outbox row |
70
- | `IOutboxRepository` | interface | **You implement this**: persistence for outbox messages |
71
- | `IOutboxMessageHandler` | interface | How to dispatch a stored message (default: publish to event bus) |
72
- | `OutboxMessageOptions` | type | `{ priority, dedupKey, headers }` per-message overrides |
73
- | `OutboxMiddleware` | type | Hook for cross-cutting concerns (tracing, encryption) |
74
- | `OutboxProcessorOptions` | type | `{ pollIntervalMs, batchSize, maxRetries, ... }` |
75
- | `OutboxServiceOptions` | type | Top-level config bag |
76
- | `MessageStatus` | enum | `Pending`, `Dispatched`, `Failed`, `DeadLettered` |
77
- | `MessagePriority` | enum | `Low`, `Normal`, `High`, `Critical` |
78
- | `OutboxMessageFactory` | class | Build `IOutboxMessage` from an integration event |
79
- | `OutboxProcessor` | class | Polls repo, dispatches via handler, updates status |
80
- | `EventBusOutboxHandler` | class | Default handler — publishes via an `IIntegrationEventBus` |
81
- | `OutboxService` | class | Facade combining factory + processor for the application |
83
+ | Export | Kind | Description |
84
+ | ------------------------ | --------- | ------------------------------------------------------------------------------- |
85
+ | `IOutboxMessage` | interface | The shape of a stored outbox row |
86
+ | `IOutboxRepository` | interface | **You implement this**: persistence for outbox messages |
87
+ | `IOutboxMessageHandler` | interface | How to dispatch a stored message (default: publish to event bus) |
88
+ | `OutboxMessageOptions` | type | `{ priority, dedupKey, headers }` per-message overrides |
89
+ | `OutboxMiddleware` | type | Hook for cross-cutting concerns (tracing, encryption) |
90
+ | `OutboxProcessorOptions` | type | `{ pollIntervalMs, batchSize, maxRetries, ... }` |
91
+ | `OutboxServiceOptions` | type | Top-level config bag |
92
+ | `MessageStatus` | enum | `Pending`, `Dispatched`, `Failed`, `DeadLettered` |
93
+ | `MessagePriority` | enum | `Low`, `Normal`, `High`, `Critical` |
94
+ | `OutboxMessageFactory` | class | Build `IOutboxMessage` from an integration event |
95
+ | `OutboxProcessor` | class | Polls repo, dispatches via handler, updates status |
96
+ | `EventBusOutboxHandler` | class | Default handler — publishes via an `IIntegrationEventBus` |
97
+ | `OutboxService` | class | Facade combining factory + processor for the application |
98
+ | `comparePriority` | function | Sorts `MessagePriority` values by a given order array (safe for partial arrays) |
82
99
 
83
100
  ## Patterns
84
101
 
@@ -142,6 +159,90 @@ The library does **not** ship broker-specific adapters — that would force
142
159
  adopters to bring in BullMQ/Kafka/etc. as transitive dependencies. Write the
143
160
  ~10-line adapter yourself; the library guarantees the interface contract.
144
161
 
162
+ ### Fan-out: one default handler for N event types
163
+
164
+ When you want a single handler to process all (or most) outbox message types —
165
+ for example, routing every integration event to a BullMQ per-context queue — use
166
+ `registerDefaultHandler` instead of registering the same handler 50 times.
167
+
168
+ ```typescript
169
+ class FanOutHandler implements IOutboxMessageHandler {
170
+ constructor(private readonly queues: Map<string, Queue>) {}
171
+
172
+ async handle(message: IOutboxMessage): Promise<void> {
173
+ // RECOMMENDED: validate against a known-types set so new event types
174
+ // added to the domain are caught early rather than silently dispatched.
175
+ if (!KNOWN_EVENT_TYPES.has(message.messageType)) {
176
+ throw new Error(`Unknown event type: ${message.messageType}`);
177
+ }
178
+ const queue =
179
+ this.queues.get(message.messageType) ?? this.queues.get('default');
180
+ await queue?.add(message.messageType, message.payload, {
181
+ jobId: message.id,
182
+ });
183
+ }
184
+ }
185
+
186
+ const processor = new OutboxProcessor(repo, { batchSize: 200 });
187
+ processor.registerDefaultHandler(new FanOutHandler(queues));
188
+ processor.start();
189
+ ```
190
+
191
+ **Security warning:** `registerDefaultHandler` removes the implicit type
192
+ allowlist that exists when only `registerHandler` is used. Before VP-008, an
193
+ unknown `messageType` would always throw — an implicit safety net. After
194
+ registering a default handler, ALL future message types (including ones added
195
+ later by mistake or injected via a compromised storage layer) will be dispatched
196
+ automatically. Always validate `message.messageType` against an explicit
197
+ known-types set inside the default handler.
198
+
199
+ **Interaction with `messageTypes` option:** if
200
+ `OutboxProcessorOptions.messageTypes` is set, the default handler will only see
201
+ messages whose type passes that filter. To catch every type, leave
202
+ `messageTypes` unset.
203
+
204
+ **PII and new event types:** when a new domain event type carrying PII is added,
205
+ review access controls on all downstream queues — the default handler will
206
+ dispatch it to all configured destinations without any per-type gating.
207
+
208
+ ### Priority ordering in repository implementations
209
+
210
+ `IOutboxRepository.getUnprocessedMessages` receives a `priorityOrder` array such
211
+ as `[CRITICAL, HIGH, NORMAL, LOW]`. Repository implementations **MUST** order by
212
+ the array index, not by raw column value. String comparison on the `priority`
213
+ column (`ORDER BY priority DESC`) silently inverts the intent:
214
+ `'normal' > 'low' > 'high' > 'critical'` alphabetically.
215
+
216
+ Correct SQL using a `CASE` expression:
217
+
218
+ ```sql
219
+ ORDER BY
220
+ CASE priority
221
+ WHEN 'critical' THEN 0
222
+ WHEN 'high' THEN 1
223
+ WHEN 'normal' THEN 2
224
+ WHEN 'low' THEN 3
225
+ ELSE 4 -- unknown priorities sort last
226
+ END
227
+ ```
228
+
229
+ Or use the exported `comparePriority` helper in in-process sorting:
230
+
231
+ ```typescript
232
+ import { comparePriority } from '@vytches/ddd-messaging';
233
+
234
+ // Sort an in-memory array highest-priority-first:
235
+ messages.sort((a, b) =>
236
+ comparePriority(
237
+ a.priority ?? MessagePriority.NORMAL,
238
+ b.priority ?? MessagePriority.NORMAL
239
+ )
240
+ );
241
+ ```
242
+
243
+ `comparePriority` is safe with partial or custom order arrays — values absent
244
+ from the array sort last rather than accidentally first.
245
+
145
246
  ## Anti-Patterns
146
247
 
147
248
  - **Do not enqueue outbox messages outside the aggregate save transaction** — if
package/README.md CHANGED
@@ -17,74 +17,101 @@ pnpm add @vytches/ddd-messaging
17
17
 
18
18
  ## What's included
19
19
 
20
- | Export | Kind | Description |
21
- | ------------------------ | -------------- | --------------------------------------------------------------- |
22
- | `MessageStatus` | enum | `PENDING \| PROCESSING \| PROCESSED \| FAILED` |
23
- | `MessagePriority` | enum | `LOW \| NORMAL \| HIGH \| CRITICAL` |
24
- | `OutboxMessageFactory` | class | Creates `IOutboxMessage` instances |
25
- | `OutboxProcessor` | class | Polls the repository and dispatches messages |
26
- | `EventBusOutboxHandler` | class | `IOutboxMessageHandler` that publishes to an `IEventBus` |
27
- | `OutboxService` | class | High-level facade: store, schedule, and coordinate processing |
28
- | `IOutboxRepository` | abstract class | Base repository contract — extend this for your storage backend |
29
- | `IOutboxMessage` | interface | Message shape with id, payload, status, priority, timestamps |
30
- | `IOutboxMessageHandler` | interface | Single-method handler: `handle(message): Promise<void>` |
31
- | `OutboxMiddleware` | type | Middleware signature for the processor pipeline |
32
- | `OutboxProcessorOptions` | interface | Processor configuration (interval, batch size, retries…) |
33
- | `OutboxServiceOptions` | interface | Service configuration |
34
- | `OutboxMessageOptions` | interface | Per-message options (priority, processAfter…) |
35
- | `RetryBackoffConfig` | interface | Exponential backoff configuration for the processor |
20
+ | Export | Kind | Description |
21
+ | ------------------------ | -------------- | ------------------------------------------------------------------------ |
22
+ | `MessageStatus` | enum | `PENDING \| PROCESSING \| PROCESSED \| FAILED` |
23
+ | `MessagePriority` | enum | `low \| normal \| high \| critical` |
24
+ | `OutboxMessageFactory` | class | Creates `IOutboxMessage` instances |
25
+ | `OutboxProcessor` | class | Polls the repository and dispatches messages via registered handlers |
26
+ | `EventBusOutboxHandler` | class | `IOutboxMessageHandler` that publishes to an `IEventBus` |
27
+ | `OutboxService` | class | High-level facade: store, schedule, and coordinate processing |
28
+ | `IOutboxRepository` | abstract class | Base repository contract — extend this for your storage backend |
29
+ | `IOutboxMessage` | interface | Message shape with id, payload, status, priority, timestamps |
30
+ | `IOutboxMessageHandler` | interface | Single-method handler: `handle(message): Promise<void>` |
31
+ | `OutboxMiddleware` | type | Middleware signature for the processor pipeline |
32
+ | `OutboxProcessorOptions` | interface | Processor configuration (interval, batch size, retries, backoff, hooks…) |
33
+ | `OutboxProcessorHooks` | interface | Observability callbacks: batch complete, message failed, permanent fail |
34
+ | `OutboxServiceOptions` | interface | Service configuration |
35
+ | `OutboxMessageOptions` | interface | Per-message options (priority, processAfter…) |
36
+ | `RetryBackoffConfig` | interface | Exponential backoff configuration for the processor |
37
+ | `comparePriority` | function | Compares two `MessagePriority` values by a given order array |
36
38
 
37
39
  ## Quick start
38
40
 
39
41
  ```typescript
40
42
  import {
41
- OutboxService,
42
43
  OutboxProcessor,
43
44
  OutboxMessageFactory,
44
45
  EventBusOutboxHandler,
45
46
  MessagePriority,
47
+ type IOutboxRepository,
46
48
  } from '@vytches/ddd-messaging';
47
- import { UnifiedEventBus } from '@vytches/ddd-events';
48
49
 
49
- // Implement IOutboxRepository for your storage (e.g. PostgreSQL)
50
+ // 1. Implement IOutboxRepository for your storage (e.g. PostgreSQL)
50
51
  class PostgresOutboxRepository extends IOutboxRepository {
51
- async save(message) {
52
- /* ... */
52
+ async saveMessage(message) {
53
+ /* INSERT INTO outbox ... */
53
54
  }
54
- async findPending(limit) {
55
- /* ... */
55
+ async saveBatch(messages) {
56
+ /* INSERT INTO outbox (batch) ... */
56
57
  }
57
- async markProcessed(id) {
58
- /* ... */
58
+ async getUnprocessedMessages(limit, priorityOrder, messageTypes) {
59
+ // ORDER by priorityOrder array index — do NOT use ORDER BY priority string column
60
+ /* SELECT ... WHERE status = 'PENDING' ORDER BY CASE priority ... */
59
61
  }
60
- async markFailed(id, error) {
61
- /* ... */
62
+ async getById(id) {
63
+ /* SELECT ... */
64
+ }
65
+ async updateStatus(id, status, error?) {
66
+ /* UPDATE ... */
67
+ }
68
+ async updateStatusBatch(ids, status) {
69
+ /* UPDATE ... */
70
+ }
71
+ async incrementAttempt(id) {
72
+ /* UPDATE ... RETURNING attempts */
73
+ }
74
+ async deleteByStatusAndAge(olderThan, status) {
75
+ /* DELETE ... */
76
+ }
77
+ async scheduleMessage(message, processAfter) {
78
+ /* INSERT ... */
62
79
  }
63
80
  }
64
81
 
65
- const repository = new PostgresOutboxRepository();
66
- const eventBus = new UnifiedEventBus();
67
-
68
- // Service stores messages, processor dispatches them
69
- const service = new OutboxService(repository);
70
- const handler = new EventBusOutboxHandler(eventBus);
71
- const processor = new OutboxProcessor(repository, handler, {
72
- processingInterval: 5_000,
82
+ // 2. Wire up the processor
83
+ const repo = new PostgresOutboxRepository(db);
84
+ const processor = new OutboxProcessor(repo, {
73
85
  batchSize: 50,
86
+ processingInterval: 5_000,
74
87
  maxRetries: 3,
88
+ retryBackoff: { initial: 1_000, multiplier: 2, maxDelay: 300_000 },
75
89
  });
76
90
 
77
- // Store a message (call this inside your aggregate save transaction)
78
- const factory = new OutboxMessageFactory();
79
- const message = factory.create({
80
- messageType: 'OrderCreated',
81
- payload: { orderId: '123' },
82
- priority: MessagePriority.HIGH,
91
+ // Register a type-specific handler
92
+ processor.registerHandler('OrderCreated', new EventBusOutboxHandler(eventBus));
93
+
94
+ // Or a catch-all default handler for uniform fan-out
95
+ processor.registerDefaultHandler({
96
+ async handle(message) {
97
+ await myQueue.add(message.messageType, message.payload);
98
+ },
83
99
  });
84
- await service.store(message);
85
100
 
86
- // Start polling
87
- await processor.start();
101
+ processor.start();
102
+
103
+ // 3. Enqueue in the same transaction as your aggregate save
104
+ const factory = new OutboxMessageFactory();
105
+ await db.transaction(async tx => {
106
+ await orderRepo.save(order, tx);
107
+ await repo.saveMessage(
108
+ factory.create({
109
+ messageType: 'OrderCreated',
110
+ payload: { orderId: order.id },
111
+ priority: MessagePriority.HIGH,
112
+ })
113
+ );
114
+ });
88
115
  ```
89
116
 
90
117
  ## Custom message handler
@@ -102,41 +129,29 @@ class KafkaMessageHandler implements IOutboxMessageHandler {
102
129
  });
103
130
  }
104
131
  }
132
+
133
+ processor.registerHandler('OrderCreated', new KafkaMessageHandler());
105
134
  ```
106
135
 
107
- ## Implement your repository
136
+ ## Default (catch-all) handler
108
137
 
109
- Extend `IOutboxRepository` and provide persistence:
138
+ For uniform dispatch — e.g. routing all event types to a single queue — use
139
+ `registerDefaultHandler` instead of registering per-type:
110
140
 
111
141
  ```typescript
112
- import {
113
- IOutboxRepository,
114
- IOutboxMessage,
115
- MessageStatus,
116
- } from '@vytches/ddd-messaging';
117
-
118
- class MyOutboxRepository extends IOutboxRepository {
119
- async save(message: IOutboxMessage): Promise<void> {
120
- await db.outbox.insert(message);
121
- }
122
-
123
- async findPendingMessages(limit: number): Promise<IOutboxMessage[]> {
124
- return db.outbox.findWhere({ status: MessageStatus.PENDING }, limit);
125
- }
126
-
127
- async markAsProcessed(id: string): Promise<void> {
128
- await db.outbox.update(id, { status: MessageStatus.PROCESSED });
129
- }
130
-
131
- async markAsFailed(id: string, error: string): Promise<void> {
132
- await db.outbox.update(id, {
133
- status: MessageStatus.FAILED,
134
- lastError: error,
142
+ processor.registerDefaultHandler({
143
+ async handle(message) {
144
+ await fanOutQueue.add(message.messageType, message.payload, {
145
+ jobId: message.id,
135
146
  });
136
- }
137
- }
147
+ },
148
+ });
138
149
  ```
139
150
 
151
+ > **Note:** Registering a default handler removes the implicit per-type
152
+ > allowlist. Validate `message.messageType` inside the handler if you need an
153
+ > explicit allowlist.
154
+
140
155
  ## Package boundaries
141
156
 
142
157
  `@vytches/ddd-messaging` depends on:
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u=require("@vytches/ddd-logging"),d=require("@vytches/ddd-utils");var n=(i=>(i.PENDING="PENDING",i.PROCESSING="PROCESSING",i.PROCESSED="PROCESSED",i.FAILED="FAILED",i))(n||{}),g=(i=>(i.LOW="low",i.NORMAL="normal",i.HIGH="high",i.CRITICAL="critical",i))(g||{});class p{scheduleRetry(e,t){return Promise.resolve()}resetStaleProcessing(e){return Promise.resolve(0)}}class l{static createMessage(e,t,s){const r={id:d.LibUtils.getUUID(),messageType:e,payload:t,metadata:s?.metadata||{},status:n.PENDING,attempts:0,createdAt:new Date,priority:s?.priority||g.NORMAL};return s?.processAfter&&(r.processAfter=s.processAfter),r}static createDelayedMessage(e,t,s,r){const a=new Date(Date.now()+s);return l.createMessage(e,t,{...r,processAfter:a})}static createHighPriorityMessage(e,t,s){return l.createMessage(e,t,{...s,priority:g.HIGH})}static createFromIntegrationEvent(e,t){const s={metadata:{...e.metadata||{},...t?.metadata||{}}};return t?.priority&&(s.priority=t.priority),t?.processAfter&&(s.processAfter=t.processAfter),l.createMessage(`integration_event:${e.eventName}`,e.payload,s)}}class y{constructor(e,t={}){this.handlers=new Map,this.middlewares=[],this.isRunning=!1,this.logger=u.Logger.create("OutboxProcessor");const s=t.batchSize??10;if(s>1e4)throw new RangeError(`OutboxProcessor: batchSize must be ≤ 10,000, got ${s}`);if(t.retryBackoff){const{initial:o,multiplier:h,maxDelay:c}=t.retryBackoff;if(o<=0||h<=0||c<=0)throw new RangeError(`OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${h}, maxDelay=${c})`)}const r=t.messageTimeout??3e4,a=t.crashRecoveryThresholdMs??Math.max(3e5,r);if(a<r)throw new RangeError(`OutboxProcessor: crashRecoveryThresholdMs (${a}) must be ≥ messageTimeout (${r}) to avoid resetting in-flight messages`);this.repository=e,this.options={batchSize:s,maxRetries:t.maxRetries??3,processingInterval:t.processingInterval??5e3,priorityOrder:t.priorityOrder??[g.CRITICAL,g.HIGH,g.NORMAL,g.LOW],messageTimeout:r,enableLogging:t.enableLogging??!1,retryBackoff:t.retryBackoff,messageTypes:t.messageTypes,crashRecoveryIntervalMs:t.crashRecoveryIntervalMs,crashRecoveryThresholdMs:a,adaptiveRepoll:t.adaptiveRepoll??!1,adaptiveRepollMaxConsecutive:t.adaptiveRepollMaxConsecutive??5,adaptiveRepollPauseMs:t.adaptiveRepollPauseMs??50,startupJitterMs:t.startupJitterMs??0,hooks:t.hooks}}safelyInvokeHook(e,t){if(this.options.hooks)try{t()}catch(s){const r=s instanceof Error?s.message:String(s);this.logger.error(`Outbox hook ${e} threw and was ignored: ${r}`)}}registerHandler(e,t){this.handlers.set(e,t),this.logger.debug(`Registered handler for message type: ${e}`)}use(e){this.middlewares.push(e),this.logger.debug("Registered middleware")}start(){if(this.isRunning){this.logger.warn("Processor is already running");return}this.isRunning=!0,this.logger.info("Starting outbox processor");const e=this.options.startupJitterMs>0?Math.floor(Math.random()*this.options.startupJitterMs):0;this.scheduleProcessing(0,this.options.processingInterval+e),this.scheduleCrashRecovery()}stop(){if(!this.isRunning){this.logger.warn("Processor is not running");return}this.isRunning=!1,this.processingTimer&&(clearTimeout(this.processingTimer),this.processingTimer=void 0),this.crashRecoveryTimer&&(clearTimeout(this.crashRecoveryTimer),this.crashRecoveryTimer=void 0),this.logger.info("Stopped outbox processor")}async processBatch(){const e=this.options.batchSize;if(!this.isRunning)return{processed:0,batchSize:e};const[t,s]=await d.safeRun(()=>this.repository.getUnprocessedMessages(e,this.options.priorityOrder,this.options.messageTypes));if(t){const c=t instanceof Error?t.message:String(t);return this.logger.error(`Error retrieving messages: ${c}`),{processed:0,batchSize:e}}const r=s;if(!r||!Array.isArray(r)||r.length===0)return this.logger.debug("No pending messages to process"),{processed:0,batchSize:e};this.logger.info(`Processing ${r.length} messages`);const a=Date.now(),h=(await Promise.allSettled(r.map(c=>this.processMessage(c)))).filter(c=>c.status==="rejected"||c.status==="fulfilled"&&c.value===!1).length;return this.logger.info(`Completed processing batch of ${r.length} messages`),this.safelyInvokeHook("onBatchComplete",()=>this.options.hooks?.onBatchComplete?.({processed:r.length,failed:h,batchSize:e,durationMs:Date.now()-a})),{processed:r.length,batchSize:e}}async processMessage(e){const[t]=await d.safeRun(async()=>{await this.repository.updateStatus(e.id,n.PROCESSING),await this.buildPipeline(e)(e),await this.repository.updateStatus(e.id,n.PROCESSED),this.logger.debug(`Successfully processed message ${e.id}`)});return t?(await this.handleMessageError(e,t),!1):!0}buildPipeline(e){const t=this.handlers.get(e.messageType);if(!t)throw new Error(`No handler registered for message type: ${e.messageType}`);const s=async r=>{await Promise.race([t.handle(r),new Promise((a,o)=>setTimeout(()=>o(new Error("Message processing timeout")),this.options.messageTimeout))])};return this.middlewares.reduceRight((r,a)=>a(r),s)}async handleMessageError(e,t){try{const s=await this.repository.incrementAttempt(e.id);if(this.logger.warn(`Message ${e.id} failed, attempt ${s}: ${t?.message}`),this.safelyInvokeHook("onMessageFailed",()=>this.options.hooks?.onMessageFailed?.(e,t,s)),s>=this.options.maxRetries)await this.repository.updateStatus(e.id,n.FAILED,t),this.logger.error(`Message ${e.id} marked as failed after ${s} attempts`),this.safelyInvokeHook("onPermanentFailure",()=>this.options.hooks?.onPermanentFailure?.(e,t));else if(this.options.retryBackoff){const{initial:r,multiplier:a,maxDelay:o}=this.options.retryBackoff,h=Math.min(r*Math.pow(a,s-1),o),c=new Date(Date.now()+h);await this.repository.updateStatus(e.id,n.PENDING),await this.repository.scheduleRetry(e.id,c),this.logger.info(`Message ${e.id} scheduled for retry in ${h}ms (attempt ${s})`)}else await this.repository.updateStatus(e.id,n.PENDING),this.logger.info(`Message ${e.id} scheduled for retry (attempt ${s+1})`)}catch(s){const r=s instanceof Error?s.message:String(s);this.logger.error(`Error updating message status: ${r}`)}}scheduleProcessing(e=0,t=this.options.processingInterval){this.isRunning&&(this.processingTimer=setTimeout(async()=>{const s=await this.processBatch();if(this.options.adaptiveRepoll&&s.processed>0&&s.processed>=s.batchSize){const a=e+1,o=a>this.options.adaptiveRepollMaxConsecutive?this.options.adaptiveRepollPauseMs:0;this.scheduleProcessing(a,o)}else this.scheduleProcessing(0,this.options.processingInterval)},t))}scheduleCrashRecovery(){!this.isRunning||this.options.crashRecoveryIntervalMs===void 0||(this.crashRecoveryTimer=setTimeout(async()=>{await this.runCrashRecovery(),this.scheduleCrashRecovery()},this.options.crashRecoveryIntervalMs))}async runCrashRecovery(){const e=new Date(Date.now()-this.options.crashRecoveryThresholdMs),[t,s]=await d.safeRun(()=>this.repository.resetStaleProcessing(e));if(t){const r=t instanceof Error?t.message:String(t);this.logger.error(`Crash recovery failed: ${r}`);return}s&&s>0&&this.logger.warn(`Crash recovery reset ${s} stale PROCESSING message(s) to PENDING`)}log(e){this.options.enableLogging&&this.logger.info(e)}async getStats(){return{isRunning:this.isRunning,batchSize:this.options.batchSize,maxRetries:this.options.maxRetries,processingInterval:this.options.processingInterval,registeredHandlers:Array.from(this.handlers.keys()),middlewareCount:this.middlewares.length}}}class f{constructor(e){this.eventBus=e}async handle(e){const s={eventName:e.messageType.startsWith("integration_event:")?e.messageType.replace("integration_event:",""):e.messageType,eventId:e.id,occurredOn:e.createdAt,version:1,...e.payload,...e.metadata};await this.eventBus.publish(s)}}class m{constructor(e,t={}){this.logger=u.Logger.create("OutboxService"),this.repository=e,this.options={defaultPriority:t.defaultPriority??g.NORMAL,defaultMetadata:t.defaultMetadata??{},autoCleanup:t.autoCleanup??!1,cleanupAfterDays:t.cleanupAfterDays??7,enableLogging:t.enableLogging??!1}}async saveMessage(e,t,s){const r=l.createMessage(e,t,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...s?.metadata},...s}),a=await this.repository.saveMessage(r);return this.logger.debug(`Saved message ${a} of type ${e}`),a}async saveMessages(e){const t=e.map(({messageType:r,payload:a,options:o})=>l.createMessage(r,a,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...o?.metadata},...o})),s=await this.repository.saveBatch(t);return this.logger.debug(`Saved batch of ${e.length} messages`),s}async scheduleMessage(e,t,s,r){const a=l.createDelayedMessage(e,t,s,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...r?.metadata},...r}),o=await this.repository.scheduleMessage(a,a.processAfter);return this.logger.debug(`Scheduled message ${o} of type ${e} for processing in ${s}ms`),o}async saveHighPriorityMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:g.HIGH})}async saveCriticalMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:g.CRITICAL})}async saveDomainEvent(e,t){const s=l.createFromIntegrationEvent({eventName:e.eventName,payload:e,metadata:{eventId:e?.metadata?.eventId,occurredOn:e?.metadata?.occurredOn,version:e?.metadata?.version}},{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...t?.metadata},...t}),r=await this.repository.saveMessage(s);return this.logger.debug(`Saved domain event ${e.eventName} as message ${r}`),r}async getMessage(e){return this.repository.getById(e)}async getPendingMessages(e=100){return this.repository.getUnprocessedMessages(e)}async retryMessage(e){await this.repository.updateStatus(e,n.PENDING),this.logger.info(`Reset message ${e} to pending for retry`)}async retryMessages(e){await this.repository.updateStatusBatch(e,n.PENDING),this.logger.info(`Reset ${e.length} messages to pending for retry`)}async cleanup(e){const t=e??this.options.cleanupAfterDays,s=new Date;s.setDate(s.getDate()-t);const r=await this.repository.deleteByStatusAndAge(s,n.PROCESSED);return this.logger.info(`Cleaned up ${r} processed messages older than ${t} days`),r}async getStats(){const e=this.repository;return e.getMessageCountByStatus?{totalPending:e.getMessageCountByStatus(n.PENDING),totalProcessing:e.getMessageCountByStatus(n.PROCESSING),totalProcessed:e.getMessageCountByStatus(n.PROCESSED),totalFailed:e.getMessageCountByStatus(n.FAILED)}:{totalPending:(await this.repository.getUnprocessedMessages(1e3)).length,totalProcessing:0,totalProcessed:0,totalFailed:0}}log(e){this.options.enableLogging&&this.logger.info(e)}}exports.EventBusOutboxHandler=f;exports.IOutboxRepository=p;exports.MessagePriority=g;exports.MessageStatus=n;exports.OutboxMessageFactory=l;exports.OutboxProcessor=y;exports.OutboxService=m;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const p=require("@vytches/ddd-utils"),c=require("@vytches/ddd-contracts/internal");var n=(i=>(i.PENDING="PENDING",i.PROCESSING="PROCESSING",i.PROCESSED="PROCESSED",i.FAILED="FAILED",i))(n||{}),l=(i=>(i.LOW="low",i.NORMAL="normal",i.HIGH="high",i.CRITICAL="critical",i))(l||{});const m=["critical","high","normal","low"];function f(i,e,t=m){const s=t.includes(i)?t.indexOf(i):t.length,a=t.includes(e)?t.indexOf(e):t.length;return s-a}class v{async claimBatch(e,t,s){const a=await this.getUnprocessedMessages(e,t,s);return a.length===0?a:(await this.updateStatusBatch(a.map(r=>r.id),n.PROCESSING),a.map(r=>({...r,status:n.PROCESSING})))}scheduleRetry(e,t){return Promise.resolve()}resetStaleProcessing(e){return Promise.resolve(0)}}class h{static createMessage(e,t,s){const a={id:p.LibUtils.getUUID(),messageType:e,payload:t,metadata:s?.metadata||{},status:n.PENDING,attempts:0,createdAt:new Date,priority:s?.priority||l.NORMAL};return s?.processAfter&&(a.processAfter=s.processAfter),a}static createDelayedMessage(e,t,s,a){const r=new Date(Date.now()+s);return h.createMessage(e,t,{...a,processAfter:r})}static createHighPriorityMessage(e,t,s){return h.createMessage(e,t,{...s,priority:l.HIGH})}static createFromIntegrationEvent(e,t){const s={metadata:{...e.metadata||{},...t?.metadata||{}}};return t?.priority&&(s.priority=t.priority),t?.processAfter&&(s.processAfter=t.processAfter),h.createMessage(`integration_event:${e.eventName}`,e.payload,s)}}class M{constructor(e,t={}){this.handlers=new Map,this.middlewares=[],this.isRunning=!1,this.warnedMissingClaimBatch=!1;const s=t.batchSize??10;if(s>1e4)throw new RangeError(`OutboxProcessor: batchSize must be ≤ 10,000, got ${s}`);if(t.retryBackoff){const{initial:o,multiplier:u,maxDelay:d}=t.retryBackoff;if(o<=0||u<=0||d<=0)throw new RangeError(`OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${u}, maxDelay=${d})`)}const a=t.messageTimeout??3e4,r=t.crashRecoveryThresholdMs??Math.max(3e5,a);if(r<a)throw new RangeError(`OutboxProcessor: crashRecoveryThresholdMs (${r}) must be ≥ messageTimeout (${a}) to avoid resetting in-flight messages`);this.repository=e,this.options={batchSize:s,maxRetries:t.maxRetries??3,processingInterval:t.processingInterval??5e3,priorityOrder:t.priorityOrder??[l.CRITICAL,l.HIGH,l.NORMAL,l.LOW],messageTimeout:a,enableLogging:t.enableLogging??!1,retryBackoff:t.retryBackoff,messageTypes:t.messageTypes,crashRecoveryIntervalMs:t.crashRecoveryIntervalMs,crashRecoveryThresholdMs:r,adaptiveRepoll:t.adaptiveRepoll??!1,adaptiveRepollMaxConsecutive:t.adaptiveRepollMaxConsecutive??5,adaptiveRepollPauseMs:t.adaptiveRepollPauseMs??50,startupJitterMs:t.startupJitterMs??0,hooks:t.hooks,useClaimBatch:t.useClaimBatch??!1}}safelyInvokeHook(e,t){if(this.options.hooks)try{t()}catch(s){c.internalLogger.error(`OutboxProcessor: hook ${e} threw and was ignored`,s instanceof Error?s:void 0,{hookName:e})}}registerHandler(e,t){this.handlers.set(e,t)}registerDefaultHandler(e){this.defaultHandler&&c.internalLogger.warn("registerDefaultHandler: replacing existing default handler — previous handler discarded"),this.defaultHandler=e}use(e){this.middlewares.push(e)}start(){if(this.isRunning){c.internalLogger.warn("OutboxProcessor: processor is already running");return}this.isRunning=!0;const e=this.options.startupJitterMs>0?Math.floor(Math.random()*this.options.startupJitterMs):0;this.scheduleProcessing(0,this.options.processingInterval+e),this.scheduleCrashRecovery()}stop(){if(!this.isRunning){c.internalLogger.warn("OutboxProcessor: processor is not running");return}this.isRunning=!1,this.processingTimer&&(clearTimeout(this.processingTimer),this.processingTimer=void 0),this.crashRecoveryTimer&&(clearTimeout(this.crashRecoveryTimer),this.crashRecoveryTimer=void 0)}async processBatch(){const e=this.options.batchSize;if(!this.isRunning)return{processed:0,batchSize:e};const t=this.repository.claimBatch,s=this.options.useClaimBatch===!0&&typeof t=="function";this.options.useClaimBatch&&!s&&!this.warnedMissingClaimBatch&&(this.warnedMissingClaimBatch=!0,c.internalLogger.warn("OutboxProcessor: useClaimBatch is enabled but the repository does not implement claimBatch() — falling back to getUnprocessedMessages (no atomic-claim protection)"));const[a,r]=await p.safeRun(()=>s&&t?t.call(this.repository,e,this.options.priorityOrder,this.options.messageTypes):this.repository.getUnprocessedMessages(e,this.options.priorityOrder,this.options.messageTypes));if(a)return c.internalLogger.error("OutboxProcessor: error retrieving messages",a instanceof Error?a:void 0),{processed:0,batchSize:e};const o=r;if(!o||!Array.isArray(o)||o.length===0)return{processed:0,batchSize:e};const u=Date.now(),y=(await Promise.allSettled(o.map(g=>this.processMessage(g,s)))).filter(g=>g.status==="rejected"||g.status==="fulfilled"&&g.value===!1).length;return this.safelyInvokeHook("onBatchComplete",()=>this.options.hooks?.onBatchComplete?.({processed:o.length,failed:y,batchSize:e,durationMs:Date.now()-u})),{processed:o.length,batchSize:e}}async processMessage(e,t=!1){const[s]=await p.safeRun(async()=>{t||await this.repository.updateStatus(e.id,n.PROCESSING),await this.buildPipeline(e)(e),await this.repository.updateStatus(e.id,n.PROCESSED)});return s?(await this.handleMessageError(e,s),!1):!0}buildPipeline(e){const t=this.handlers.get(e.messageType)??this.defaultHandler;if(!t)throw new Error(`No handler registered for message type: ${e.messageType}`);const s=async a=>{let r;try{await Promise.race([t.handle(a),new Promise((o,u)=>{r=setTimeout(()=>u(new Error("Message processing timeout")),this.options.messageTimeout)})])}finally{r!==void 0&&clearTimeout(r)}};return this.middlewares.reduceRight((a,r)=>r(a),s)}async handleMessageError(e,t){try{const s=await this.repository.incrementAttempt(e.id);if(c.internalLogger.warn(`OutboxProcessor: message ${e.id} failed, attempt ${s}: ${p.LibUtils.sanitizeLogMessage(t?.message??String(t))}`),this.safelyInvokeHook("onMessageFailed",()=>this.options.hooks?.onMessageFailed?.(e,t,s)),s>=this.options.maxRetries)await this.repository.updateStatus(e.id,n.FAILED,t),c.internalLogger.error("OutboxProcessor: message permanently failed",t,{messageId:e.id,attempts:s}),this.safelyInvokeHook("onPermanentFailure",()=>this.options.hooks?.onPermanentFailure?.(e,t));else if(this.options.retryBackoff){const{initial:a,multiplier:r,maxDelay:o}=this.options.retryBackoff,u=Math.min(a*Math.pow(r,s-1),o),d=new Date(Date.now()+u);await this.repository.updateStatus(e.id,n.PENDING),await this.repository.scheduleRetry(e.id,d)}else await this.repository.updateStatus(e.id,n.PENDING)}catch(s){c.internalLogger.error("OutboxProcessor: error updating message status",s instanceof Error?s:void 0,{messageId:e.id})}}scheduleProcessing(e=0,t=this.options.processingInterval){this.isRunning&&(this.processingTimer=setTimeout(async()=>{const s=await this.processBatch();if(this.options.adaptiveRepoll&&s.processed>0&&s.processed>=s.batchSize){const r=e+1,o=r>this.options.adaptiveRepollMaxConsecutive?this.options.adaptiveRepollPauseMs:0;this.scheduleProcessing(r,o)}else this.scheduleProcessing(0,this.options.processingInterval)},t))}scheduleCrashRecovery(){!this.isRunning||this.options.crashRecoveryIntervalMs===void 0||(this.crashRecoveryTimer=setTimeout(async()=>{await this.runCrashRecovery(),this.scheduleCrashRecovery()},this.options.crashRecoveryIntervalMs))}async runCrashRecovery(){const e=new Date(Date.now()-this.options.crashRecoveryThresholdMs),[t,s]=await p.safeRun(()=>this.repository.resetStaleProcessing(e));if(t){c.internalLogger.error("OutboxProcessor: crash recovery failed",t instanceof Error?t:void 0);return}s&&s>0&&c.internalLogger.warn(`OutboxProcessor: crash recovery reset ${s} stale PROCESSING message(s) to PENDING`)}async getStats(){return{isRunning:this.isRunning,batchSize:this.options.batchSize,maxRetries:this.options.maxRetries,processingInterval:this.options.processingInterval,registeredHandlers:Array.from(this.handlers.keys()),middlewareCount:this.middlewares.length,hasDefaultHandler:this.defaultHandler!==void 0}}}class R{constructor(e){this.eventBus=e}async handle(e){const s={eventName:e.messageType.startsWith("integration_event:")?e.messageType.replace("integration_event:",""):e.messageType,eventId:e.id,occurredOn:e.createdAt,version:1,...e.payload,...e.metadata};await this.eventBus.publish(s)}}class P{constructor(e,t={}){this.repository=e,this.options={defaultPriority:t.defaultPriority??l.NORMAL,defaultMetadata:t.defaultMetadata??{},autoCleanup:t.autoCleanup??!1,cleanupAfterDays:t.cleanupAfterDays??7,enableLogging:t.enableLogging??!1}}async saveMessage(e,t,s){const a=h.createMessage(e,t,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...s?.metadata},...s});return await this.repository.saveMessage(a)}async saveMessages(e){const t=e.map(({messageType:a,payload:r,options:o})=>h.createMessage(a,r,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...o?.metadata},...o}));return await this.repository.saveBatch(t)}async scheduleMessage(e,t,s,a){const r=h.createDelayedMessage(e,t,s,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...a?.metadata},...a});return await this.repository.scheduleMessage(r,r.processAfter)}async saveHighPriorityMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:l.HIGH})}async saveCriticalMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:l.CRITICAL})}async saveDomainEvent(e,t){const s=h.createFromIntegrationEvent({eventName:e.eventName,payload:e,metadata:{eventId:e?.metadata?.eventId,occurredOn:e?.metadata?.occurredOn,version:e?.metadata?.version}},{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...t?.metadata},...t});return await this.repository.saveMessage(s)}async getMessage(e){return this.repository.getById(e)}async getPendingMessages(e=100){return this.repository.getUnprocessedMessages(e)}async retryMessage(e){await this.repository.updateStatus(e,n.PENDING)}async retryMessages(e){await this.repository.updateStatusBatch(e,n.PENDING)}async cleanup(e){const t=e??this.options.cleanupAfterDays,s=new Date;return s.setDate(s.getDate()-t),await this.repository.deleteByStatusAndAge(s,n.PROCESSED)}async getStats(){const e=this.repository;return e.getMessageCountByStatus?{totalPending:e.getMessageCountByStatus(n.PENDING),totalProcessing:e.getMessageCountByStatus(n.PROCESSING),totalProcessed:e.getMessageCountByStatus(n.PROCESSED),totalFailed:e.getMessageCountByStatus(n.FAILED)}:{totalPending:(await this.repository.getUnprocessedMessages(1e3)).length,totalProcessing:0,totalProcessed:0,totalFailed:0}}}exports.EventBusOutboxHandler=R;exports.IOutboxRepository=v;exports.MessagePriority=l;exports.MessageStatus=n;exports.OutboxMessageFactory=h;exports.OutboxProcessor=M;exports.OutboxService=P;exports.comparePriority=f;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export type { IOutboxMessage, OutboxMessageOptions, IOutboxMessageHandler, OutboxMiddleware, OutboxProcessorOptions, OutboxProcessorHooks, RetryBackoffConfig, OutboxServiceOptions, } from './outbox';
2
2
  export { MessageStatus, MessagePriority } from './outbox';
3
+ export { comparePriority } from './outbox';
3
4
  export { IOutboxRepository, OutboxMessageFactory, OutboxProcessor, EventBusOutboxHandler, OutboxService, } from './outbox';