@vytches/ddd-messaging 0.29.2 → 0.30.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 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||{}),l=(i=>(i.LOW="low",i.NORMAL="normal",i.HIGH="high",i.CRITICAL="critical",i))(l||{});const p=["critical","high","normal","low"];function y(i,e,t=p){const s=t.includes(i)?t.indexOf(i):t.length,r=t.includes(e)?t.indexOf(e):t.length;return s-r}class f{scheduleRetry(e,t){return Promise.resolve()}resetStaleProcessing(e){return Promise.resolve(0)}}class g{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||l.NORMAL};return s?.processAfter&&(r.processAfter=s.processAfter),r}static createDelayedMessage(e,t,s,r){const a=new Date(Date.now()+s);return g.createMessage(e,t,{...r,processAfter:a})}static createHighPriorityMessage(e,t,s){return g.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),g.createMessage(`integration_event:${e.eventName}`,e.payload,s)}}class m{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??[l.CRITICAL,l.HIGH,l.NORMAL,l.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}`)}registerDefaultHandler(e){this.defaultHandler=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)??this.defaultHandler;if(!t)throw new Error(`No handler registered for message type: ${e.messageType}`);this.handlers.has(e.messageType)||this.logger.debug(`Default handler used 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,hasDefaultHandler:this.defaultHandler!==void 0}}}class v{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??l.NORMAL,defaultMetadata:t.defaultMetadata??{},autoCleanup:t.autoCleanup??!1,cleanupAfterDays:t.cleanupAfterDays??7,enableLogging:t.enableLogging??!1}}async saveMessage(e,t,s){const r=g.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})=>g.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=g.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:l.HIGH})}async saveCriticalMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:l.CRITICAL})}async saveDomainEvent(e,t){const s=g.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=v;exports.IOutboxRepository=f;exports.MessagePriority=l;exports.MessageStatus=n;exports.OutboxMessageFactory=g;exports.OutboxProcessor=m;exports.OutboxService=M;exports.comparePriority=y;
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';
package/dist/index.js CHANGED
@@ -1,7 +1,18 @@
1
1
  import { Logger as u } from "@vytches/ddd-logging";
2
2
  import { LibUtils as p, safeRun as d } from "@vytches/ddd-utils";
3
- var n = /* @__PURE__ */ ((i) => (i.PENDING = "PENDING", i.PROCESSING = "PROCESSING", i.PROCESSED = "PROCESSED", i.FAILED = "FAILED", i))(n || {}), g = /* @__PURE__ */ ((i) => (i.LOW = "low", i.NORMAL = "normal", i.HIGH = "high", i.CRITICAL = "critical", i))(g || {});
4
- class m {
3
+ var n = /* @__PURE__ */ ((i) => (i.PENDING = "PENDING", i.PROCESSING = "PROCESSING", i.PROCESSED = "PROCESSED", i.FAILED = "FAILED", i))(n || {}), l = /* @__PURE__ */ ((i) => (i.LOW = "low", i.NORMAL = "normal", i.HIGH = "high", i.CRITICAL = "critical", i))(l || {});
4
+ const y = [
5
+ "critical",
6
+ "high",
7
+ "normal",
8
+ "low"
9
+ /* LOW */
10
+ ];
11
+ function v(i, e, t = y) {
12
+ const s = t.includes(i) ? t.indexOf(i) : t.length, r = t.includes(e) ? t.indexOf(e) : t.length;
13
+ return s - r;
14
+ }
15
+ class M {
5
16
  /**
6
17
  * Schedules a retry for a failed message at a specific future time.
7
18
  *
@@ -60,7 +71,7 @@ class h {
60
71
  status: n.PENDING,
61
72
  attempts: 0,
62
73
  createdAt: /* @__PURE__ */ new Date(),
63
- priority: s?.priority || g.NORMAL
74
+ priority: s?.priority || l.NORMAL
64
75
  };
65
76
  return s?.processAfter && (r.processAfter = s.processAfter), r;
66
77
  }
@@ -89,7 +100,7 @@ class h {
89
100
  static createHighPriorityMessage(e, t, s) {
90
101
  return h.createMessage(e, t, {
91
102
  ...s,
92
- priority: g.HIGH
103
+ priority: l.HIGH
93
104
  });
94
105
  }
95
106
  /**
@@ -112,17 +123,17 @@ class h {
112
123
  );
113
124
  }
114
125
  }
115
- class v {
126
+ class R {
116
127
  constructor(e, t = {}) {
117
128
  this.handlers = /* @__PURE__ */ new Map(), this.middlewares = [], this.isRunning = !1, this.logger = u.create("OutboxProcessor");
118
129
  const s = t.batchSize ?? 10;
119
130
  if (s > 1e4)
120
131
  throw new RangeError(`OutboxProcessor: batchSize must be ≤ 10,000, got ${s}`);
121
132
  if (t.retryBackoff) {
122
- const { initial: o, multiplier: l, maxDelay: c } = t.retryBackoff;
123
- if (o <= 0 || l <= 0 || c <= 0)
133
+ const { initial: o, multiplier: g, maxDelay: c } = t.retryBackoff;
134
+ if (o <= 0 || g <= 0 || c <= 0)
124
135
  throw new RangeError(
125
- `OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${l}, maxDelay=${c})`
136
+ `OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${g}, maxDelay=${c})`
126
137
  );
127
138
  }
128
139
  const r = t.messageTimeout ?? 3e4, a = t.crashRecoveryThresholdMs ?? Math.max(3e5, r);
@@ -135,10 +146,10 @@ class v {
135
146
  maxRetries: t.maxRetries ?? 3,
136
147
  processingInterval: t.processingInterval ?? 5e3,
137
148
  priorityOrder: t.priorityOrder ?? [
138
- g.CRITICAL,
139
- g.HIGH,
140
- g.NORMAL,
141
- g.LOW
149
+ l.CRITICAL,
150
+ l.HIGH,
151
+ l.NORMAL,
152
+ l.LOW
142
153
  ],
143
154
  messageTimeout: r,
144
155
  enableLogging: t.enableLogging ?? !1,
@@ -172,6 +183,25 @@ class v {
172
183
  registerHandler(e, t) {
173
184
  this.handlers.set(e, t), this.logger.debug(`Registered handler for message type: ${e}`);
174
185
  }
186
+ /**
187
+ * Registers a catch-all handler invoked when no type-specific handler matches.
188
+ *
189
+ * NOTE: Registering a default handler removes the implicit type allowlist —
190
+ * all future message types, including ones added by mistake or injected via
191
+ * compromised storage, will be dispatched to this handler. Validate
192
+ * `message.messageType` inside the handler against a known-types set if your
193
+ * domain requires an explicit allowlist.
194
+ *
195
+ * NOTE: When `messageTypes` filter option is set, the default handler handles
196
+ * only types that pass through that filter. To handle all types, leave
197
+ * `messageTypes` unset.
198
+ *
199
+ * Calling this method a second time silently replaces the previous default
200
+ * handler (idempotent).
201
+ */
202
+ registerDefaultHandler(e) {
203
+ this.defaultHandler = e;
204
+ }
175
205
  /**
176
206
  * Registers middleware for message processing pipeline
177
207
  */
@@ -248,7 +278,7 @@ class v {
248
278
  if (!r || !Array.isArray(r) || r.length === 0)
249
279
  return this.logger.debug("No pending messages to process"), { processed: 0, batchSize: e };
250
280
  this.logger.info(`Processing ${r.length} messages`);
251
- const a = Date.now(), l = (await Promise.allSettled(
281
+ const a = Date.now(), g = (await Promise.allSettled(
252
282
  r.map((c) => this.processMessage(c))
253
283
  )).filter(
254
284
  (c) => c.status === "rejected" || c.status === "fulfilled" && c.value === !1
@@ -257,7 +287,7 @@ class v {
257
287
  "onBatchComplete",
258
288
  () => this.options.hooks?.onBatchComplete?.({
259
289
  processed: r.length,
260
- failed: l,
290
+ failed: g,
261
291
  batchSize: e,
262
292
  durationMs: Date.now() - a
263
293
  })
@@ -277,9 +307,10 @@ class v {
277
307
  * Builds the middleware pipeline for message processing
278
308
  */
279
309
  buildPipeline(e) {
280
- const t = this.handlers.get(e.messageType);
310
+ const t = this.handlers.get(e.messageType) ?? this.defaultHandler;
281
311
  if (!t)
282
312
  throw new Error(`No handler registered for message type: ${e.messageType}`);
313
+ this.handlers.has(e.messageType) || this.logger.debug(`Default handler used for message type: ${e.messageType}`);
283
314
  const s = async (r) => {
284
315
  await Promise.race([
285
316
  t.handle(r),
@@ -308,9 +339,9 @@ class v {
308
339
  () => this.options.hooks?.onPermanentFailure?.(e, t)
309
340
  );
310
341
  else if (this.options.retryBackoff) {
311
- const { initial: r, multiplier: a, maxDelay: o } = this.options.retryBackoff, l = Math.min(r * Math.pow(a, s - 1), o), c = new Date(Date.now() + l);
342
+ const { initial: r, multiplier: a, maxDelay: o } = this.options.retryBackoff, g = Math.min(r * Math.pow(a, s - 1), o), c = new Date(Date.now() + g);
312
343
  await this.repository.updateStatus(e.id, n.PENDING), await this.repository.scheduleRetry(e.id, c), this.logger.info(
313
- `Message ${e.id} scheduled for retry in ${l}ms (attempt ${s})`
344
+ `Message ${e.id} scheduled for retry in ${g}ms (attempt ${s})`
314
345
  );
315
346
  } else
316
347
  await this.repository.updateStatus(e.id, n.PENDING), this.logger.info(`Message ${e.id} scheduled for retry (attempt ${s + 1})`);
@@ -380,11 +411,12 @@ class v {
380
411
  maxRetries: this.options.maxRetries,
381
412
  processingInterval: this.options.processingInterval,
382
413
  registeredHandlers: Array.from(this.handlers.keys()),
383
- middlewareCount: this.middlewares.length
414
+ middlewareCount: this.middlewares.length,
415
+ hasDefaultHandler: this.defaultHandler !== void 0
384
416
  };
385
417
  }
386
418
  }
387
- class M {
419
+ class w {
388
420
  constructor(e) {
389
421
  this.eventBus = e;
390
422
  }
@@ -400,10 +432,10 @@ class M {
400
432
  await this.eventBus.publish(s);
401
433
  }
402
434
  }
403
- class R {
435
+ class P {
404
436
  constructor(e, t = {}) {
405
437
  this.logger = u.create("OutboxService"), this.repository = e, this.options = {
406
- defaultPriority: t.defaultPriority ?? g.NORMAL,
438
+ defaultPriority: t.defaultPriority ?? l.NORMAL,
407
439
  defaultMetadata: t.defaultMetadata ?? {},
408
440
  autoCleanup: t.autoCleanup ?? !1,
409
441
  cleanupAfterDays: t.cleanupAfterDays ?? 7,
@@ -453,7 +485,7 @@ class R {
453
485
  async saveHighPriorityMessage(e, t, s) {
454
486
  return this.saveMessage(e, t, {
455
487
  ...s,
456
- priority: g.HIGH
488
+ priority: l.HIGH
457
489
  });
458
490
  }
459
491
  /**
@@ -462,7 +494,7 @@ class R {
462
494
  async saveCriticalMessage(e, t, s) {
463
495
  return this.saveMessage(e, t, {
464
496
  ...s,
465
- priority: g.CRITICAL
497
+ priority: l.CRITICAL
466
498
  });
467
499
  }
468
500
  /**
@@ -551,11 +583,12 @@ class R {
551
583
  }
552
584
  }
553
585
  export {
554
- M as EventBusOutboxHandler,
555
- m as IOutboxRepository,
556
- g as MessagePriority,
586
+ w as EventBusOutboxHandler,
587
+ M as IOutboxRepository,
588
+ l as MessagePriority,
557
589
  n as MessageStatus,
558
590
  h as OutboxMessageFactory,
559
- v as OutboxProcessor,
560
- R as OutboxService
591
+ R as OutboxProcessor,
592
+ P as OutboxService,
593
+ v as comparePriority
561
594
  };
@@ -48,3 +48,22 @@ export interface IOutboxMessageHandler<T = unknown> {
48
48
  handle(message: IOutboxMessage<T>): Promise<void>;
49
49
  }
50
50
  export type OutboxMiddleware = (next: (message: IOutboxMessage) => Promise<void>) => (message: IOutboxMessage) => Promise<void>;
51
+ /**
52
+ * Compares two MessagePriority values for sorting purposes.
53
+ *
54
+ * Returns a negative number if `a` should be processed before `b`,
55
+ * positive if `b` before `a`, 0 if equal.
56
+ *
57
+ * @param order - Processing order array (first element = highest priority).
58
+ * Defaults to [CRITICAL, HIGH, NORMAL, LOW].
59
+ * Values absent from the array sort LAST (treated as lowest priority).
60
+ * Passing a partial array is safe — missing values are not promoted.
61
+ *
62
+ * @example
63
+ * // Sort messages by priority (highest first):
64
+ * messages.sort((a, b) => comparePriority(a.priority, b.priority));
65
+ *
66
+ * // Custom order:
67
+ * messages.sort((a, b) => comparePriority(a.priority, b.priority, ['high', 'critical', 'normal', 'low']));
68
+ */
69
+ export declare function comparePriority(a: MessagePriority, b: MessagePriority, order?: MessagePriority[]): number;
@@ -85,6 +85,7 @@ export declare class OutboxProcessor {
85
85
  private readonly repository;
86
86
  private readonly options;
87
87
  private readonly handlers;
88
+ private defaultHandler?;
88
89
  private readonly middlewares;
89
90
  private isRunning;
90
91
  private processingTimer?;
@@ -100,6 +101,23 @@ export declare class OutboxProcessor {
100
101
  * Registers a message handler for a specific message type
101
102
  */
102
103
  registerHandler(messageType: string, handler: IOutboxMessageHandler): void;
104
+ /**
105
+ * Registers a catch-all handler invoked when no type-specific handler matches.
106
+ *
107
+ * NOTE: Registering a default handler removes the implicit type allowlist —
108
+ * all future message types, including ones added by mistake or injected via
109
+ * compromised storage, will be dispatched to this handler. Validate
110
+ * `message.messageType` inside the handler against a known-types set if your
111
+ * domain requires an explicit allowlist.
112
+ *
113
+ * NOTE: When `messageTypes` filter option is set, the default handler handles
114
+ * only types that pass through that filter. To handle all types, leave
115
+ * `messageTypes` unset.
116
+ *
117
+ * Calling this method a second time silently replaces the previous default
118
+ * handler (idempotent).
119
+ */
120
+ registerDefaultHandler(handler: IOutboxMessageHandler): void;
103
121
  /**
104
122
  * Registers middleware for message processing pipeline
105
123
  */
@@ -196,6 +214,7 @@ export declare class OutboxProcessor {
196
214
  processingInterval: number;
197
215
  registeredHandlers: string[];
198
216
  middlewareCount: number;
217
+ hasDefaultHandler: boolean;
199
218
  }>;
200
219
  }
201
220
  export declare class EventBusOutboxHandler implements IOutboxMessageHandler {
@@ -13,14 +13,19 @@ export declare abstract class IOutboxRepository {
13
13
  */
14
14
  abstract saveBatch<T = unknown>(messages: IOutboxMessage<T>[]): Promise<string[]>;
15
15
  /**
16
- * Gets unprocessed messages from the outbox
17
- * @param limit Maximum number of messages to retrieve
18
- * @param priorityOrder Order of priority for processing (default: [CRITICAL, HIGH, NORMAL, LOW])
19
- * @param messageTypes Optional allow-list of message types to fetch. When
20
- * provided, only messages whose `messageType` is in this list are returned
21
- * (e.g. `WHERE message_type IN (...)`). Omitting it fetches all types —
22
- * this parameter is optional and backward-compatible.
23
- * @returns Unprocessed messages
16
+ * Returns unprocessed messages up to the given limit.
17
+ *
18
+ * @param limit - Maximum number of messages to return.
19
+ * @param priorityOrder - Ordered array of MessagePriority values defining
20
+ * processing order (first element = highest priority, processed first).
21
+ * Implementations MUST sort results by the position of each message's
22
+ * priority in this array — do NOT use ORDER BY priority directly on the
23
+ * string column. Alphabetical sort inverts the intent: 'critical' sorts
24
+ * after 'high' alphabetically, causing CRITICAL messages to be processed
25
+ * last. Use comparePriority() or array index-based sorting instead.
26
+ * @param messageTypes - Optional allow-list of message types to return.
27
+ * When provided, only messages whose messageType is in this array are
28
+ * returned. When absent, all message types are returned.
24
29
  */
25
30
  abstract getUnprocessedMessages(limit?: number, priorityOrder?: MessagePriority[], messageTypes?: string[]): Promise<IOutboxMessage[]>;
26
31
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vytches/ddd-messaging",
3
- "version": "0.29.2",
3
+ "version": "0.30.0",
4
4
  "description": "Outbox pattern and reliable message delivery",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -50,7 +50,7 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@vytches/ddd-events": "0.29.2"
53
+ "@vytches/ddd-events": "0.30.0"
54
54
  },
55
55
  "scripts": {
56
56
  "build": "vite build",