@vytches/ddd-messaging 0.27.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const l=require("@vytches/ddd-logging"),d=require("@vytches/ddd-utils");var o=(i=>(i.PENDING="PENDING",i.PROCESSING="PROCESSING",i.PROCESSED="PROCESSED",i.FAILED="FAILED",i))(o||{}),n=(i=>(i.LOW="low",i.NORMAL="normal",i.HIGH="high",i.CRITICAL="critical",i))(n||{});class u{}class g{static createMessage(e,t,s){const r={id:d.LibUtils.getUUID(),messageType:e,payload:t,metadata:s?.metadata||{},status:o.PENDING,attempts:0,createdAt:new Date,priority:s?.priority||n.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:n.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 h{constructor(e,t={}){this.handlers=new Map,this.middlewares=[],this.isRunning=!1,this.logger=l.Logger.create("OutboxProcessor"),this.repository=e,this.options={batchSize:t.batchSize??10,maxRetries:t.maxRetries??3,processingInterval:t.processingInterval??5e3,priorityOrder:t.priorityOrder??[n.CRITICAL,n.HIGH,n.NORMAL,n.LOW],messageTimeout:t.messageTimeout??3e4,enableLogging:t.enableLogging??!1}}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"),this.scheduleProcessing()}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.logger.info("Stopped outbox processor")}async processBatch(){if(!this.isRunning)return;const[e,t]=await d.safeRun(()=>this.repository.getUnprocessedMessages(this.options.batchSize,this.options.priorityOrder));if(e){const a=e instanceof Error?e.message:String(e);this.logger.error(`Error retrieving messages: ${a}`);return}const s=t;if(!s||!Array.isArray(s)||s.length===0){this.logger.debug("No pending messages to process");return}this.logger.info(`Processing ${s.length} messages`);const r=s.map(a=>this.processMessage(a));await Promise.allSettled(r),this.logger.info(`Completed processing batch of ${s.length} messages`)}async processMessage(e){const[t]=await d.safeRun(async()=>{await this.repository.updateStatus(e.id,o.PROCESSING),await this.buildPipeline(e)(e),await this.repository.updateStatus(e.id,o.PROCESSED),this.logger.debug(`Successfully processed message ${e.id}`)});t&&await this.handleMessageError(e,t)}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,c)=>setTimeout(()=>c(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);this.logger.warn(`Message ${e.id} failed, attempt ${s}: ${t?.message}`),s>=this.options.maxRetries?(await this.repository.updateStatus(e.id,o.FAILED,t),this.logger.error(`Message ${e.id} marked as failed after ${s} attempts`)):(await this.repository.updateStatus(e.id,o.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(){this.isRunning&&(this.processingTimer=setTimeout(async()=>{await this.processBatch(),this.scheduleProcessing()},this.options.processingInterval))}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 p{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 y{constructor(e,t={}){this.logger=l.Logger.create("OutboxService"),this.repository=e,this.options={defaultPriority:t.defaultPriority??n.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:c})=>g.createMessage(r,a,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...c?.metadata},...c})),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}),c=await this.repository.scheduleMessage(a,a.processAfter);return this.logger.debug(`Scheduled message ${c} of type ${e} for processing in ${s}ms`),c}async saveHighPriorityMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:n.HIGH})}async saveCriticalMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:n.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,o.PENDING),this.logger.info(`Reset message ${e} to pending for retry`)}async retryMessages(e){await this.repository.updateStatusBatch(e,o.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,o.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(o.PENDING),totalProcessing:e.getMessageCountByStatus(o.PROCESSING),totalProcessed:e.getMessageCountByStatus(o.PROCESSED),totalFailed:e.getMessageCountByStatus(o.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=p;exports.IOutboxRepository=u;exports.MessagePriority=n;exports.MessageStatus=o;exports.OutboxMessageFactory=g;exports.OutboxProcessor=h;exports.OutboxService=y;
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;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export type { IOutboxMessage, OutboxMessageOptions, IOutboxMessageHandler, OutboxMiddleware, OutboxProcessorOptions, OutboxServiceOptions, } from './outbox';
1
+ export type { IOutboxMessage, OutboxMessageOptions, IOutboxMessageHandler, OutboxMiddleware, OutboxProcessorOptions, OutboxProcessorHooks, RetryBackoffConfig, OutboxServiceOptions, } from './outbox';
2
2
  export { MessageStatus, MessagePriority } from './outbox';
3
3
  export { IOutboxRepository, OutboxMessageFactory, OutboxProcessor, EventBusOutboxHandler, OutboxService, } from './outbox';
package/dist/index.js CHANGED
@@ -1,9 +1,49 @@
1
- import { Logger as l } from "@vytches/ddd-logging";
2
- import { LibUtils as h, safeRun as d } from "@vytches/ddd-utils";
3
- var o = /* @__PURE__ */ ((i) => (i.PENDING = "PENDING", i.PROCESSING = "PROCESSING", i.PROCESSED = "PROCESSED", i.FAILED = "FAILED", i))(o || {}), n = /* @__PURE__ */ ((i) => (i.LOW = "low", i.NORMAL = "normal", i.HIGH = "high", i.CRITICAL = "critical", i))(n || {});
4
- class y {
1
+ import { Logger as u } from "@vytches/ddd-logging";
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 {
5
+ /**
6
+ * Schedules a retry for a failed message at a specific future time.
7
+ *
8
+ * Default implementation is a no-op — repositories that do not support
9
+ * delayed processing will fall back to immediate retry (PENDING reset).
10
+ * Override this in concrete repositories to enable exponential backoff.
11
+ *
12
+ * **Multi-worker note:** `OutboxProcessor` calls `updateStatus(PENDING)` immediately
13
+ * before this method. In multi-worker environments, override this to perform
14
+ * an atomic `UPDATE ... SET status=PENDING, process_after=?` in a single statement,
15
+ * and ensure `getUnprocessedMessages` filters `WHERE process_after IS NULL OR process_after <= NOW()`
16
+ * to prevent duplicate dispatch during the window between the two calls.
17
+ *
18
+ * @param id Message ID
19
+ * @param processAfter When to retry the message
20
+ */
21
+ scheduleRetry(e, t) {
22
+ return Promise.resolve();
23
+ }
24
+ /**
25
+ * Resets messages stuck in `PROCESSING` back to `PENDING` so they can be
26
+ * re-dispatched. Used for crash recovery: if a worker dies mid-batch, its
27
+ * in-flight messages remain `PROCESSING` forever without this.
28
+ *
29
+ * Default implementation is a no-op returning `0` — repositories that do not
30
+ * track a per-message "processing since" timestamp simply opt out. Override
31
+ * in concrete repositories, e.g.:
32
+ * `UPDATE outbox_messages SET status='PENDING' WHERE status='PROCESSING' AND updated_at < olderThan`.
33
+ *
34
+ * **Safety:** implementations MUST only reset messages whose processing
35
+ * started strictly before `olderThan`. `OutboxProcessor` always passes a past
36
+ * timestamp (`now - crashRecoveryThresholdMs`, with the threshold validated to
37
+ * be `>= messageTimeout`), so legitimately in-flight messages are never reset.
38
+ *
39
+ * @param olderThan Reset only `PROCESSING` messages last touched before this instant
40
+ * @returns Number of messages reset to `PENDING`
41
+ */
42
+ resetStaleProcessing(e) {
43
+ return Promise.resolve(0);
44
+ }
5
45
  }
6
- class g {
46
+ class h {
7
47
  /**
8
48
  * Creates a new outbox message
9
49
  * @param messageType Type of the message
@@ -13,14 +53,14 @@ class g {
13
53
  */
14
54
  static createMessage(e, t, s) {
15
55
  const r = {
16
- id: h.getUUID(),
56
+ id: p.getUUID(),
17
57
  messageType: e,
18
58
  payload: t,
19
59
  metadata: s?.metadata || {},
20
- status: o.PENDING,
60
+ status: n.PENDING,
21
61
  attempts: 0,
22
62
  createdAt: /* @__PURE__ */ new Date(),
23
- priority: s?.priority || n.NORMAL
63
+ priority: s?.priority || g.NORMAL
24
64
  };
25
65
  return s?.processAfter && (r.processAfter = s.processAfter), r;
26
66
  }
@@ -34,7 +74,7 @@ class g {
34
74
  */
35
75
  static createDelayedMessage(e, t, s, r) {
36
76
  const a = new Date(Date.now() + s);
37
- return g.createMessage(e, t, {
77
+ return h.createMessage(e, t, {
38
78
  ...r,
39
79
  processAfter: a
40
80
  });
@@ -47,9 +87,9 @@ class g {
47
87
  * @returns High priority outbox message
48
88
  */
49
89
  static createHighPriorityMessage(e, t, s) {
50
- return g.createMessage(e, t, {
90
+ return h.createMessage(e, t, {
51
91
  ...s,
52
- priority: n.HIGH
92
+ priority: g.HIGH
53
93
  });
54
94
  }
55
95
  /**
@@ -65,29 +105,67 @@ class g {
65
105
  ...t?.metadata || {}
66
106
  }
67
107
  };
68
- return t?.priority && (s.priority = t.priority), t?.processAfter && (s.processAfter = t.processAfter), g.createMessage(
108
+ return t?.priority && (s.priority = t.priority), t?.processAfter && (s.processAfter = t.processAfter), h.createMessage(
69
109
  `integration_event:${e.eventName}`,
70
110
  e.payload,
71
111
  s
72
112
  );
73
113
  }
74
114
  }
75
- class m {
115
+ class v {
76
116
  constructor(e, t = {}) {
77
- this.handlers = /* @__PURE__ */ new Map(), this.middlewares = [], this.isRunning = !1, this.logger = l.create("OutboxProcessor"), this.repository = e, this.options = {
78
- batchSize: t.batchSize ?? 10,
117
+ this.handlers = /* @__PURE__ */ new Map(), this.middlewares = [], this.isRunning = !1, this.logger = u.create("OutboxProcessor");
118
+ const s = t.batchSize ?? 10;
119
+ if (s > 1e4)
120
+ throw new RangeError(`OutboxProcessor: batchSize must be ≤ 10,000, got ${s}`);
121
+ if (t.retryBackoff) {
122
+ const { initial: o, multiplier: l, maxDelay: c } = t.retryBackoff;
123
+ if (o <= 0 || l <= 0 || c <= 0)
124
+ throw new RangeError(
125
+ `OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${l}, maxDelay=${c})`
126
+ );
127
+ }
128
+ const r = t.messageTimeout ?? 3e4, a = t.crashRecoveryThresholdMs ?? Math.max(3e5, r);
129
+ if (a < r)
130
+ throw new RangeError(
131
+ `OutboxProcessor: crashRecoveryThresholdMs (${a}) must be ≥ messageTimeout (${r}) to avoid resetting in-flight messages`
132
+ );
133
+ this.repository = e, this.options = {
134
+ batchSize: s,
79
135
  maxRetries: t.maxRetries ?? 3,
80
136
  processingInterval: t.processingInterval ?? 5e3,
81
137
  priorityOrder: t.priorityOrder ?? [
82
- n.CRITICAL,
83
- n.HIGH,
84
- n.NORMAL,
85
- n.LOW
138
+ g.CRITICAL,
139
+ g.HIGH,
140
+ g.NORMAL,
141
+ g.LOW
86
142
  ],
87
- messageTimeout: t.messageTimeout ?? 3e4,
88
- enableLogging: t.enableLogging ?? !1
143
+ messageTimeout: r,
144
+ enableLogging: t.enableLogging ?? !1,
145
+ retryBackoff: t.retryBackoff,
146
+ messageTypes: t.messageTypes,
147
+ crashRecoveryIntervalMs: t.crashRecoveryIntervalMs,
148
+ crashRecoveryThresholdMs: a,
149
+ adaptiveRepoll: t.adaptiveRepoll ?? !1,
150
+ adaptiveRepollMaxConsecutive: t.adaptiveRepollMaxConsecutive ?? 5,
151
+ adaptiveRepollPauseMs: t.adaptiveRepollPauseMs ?? 50,
152
+ startupJitterMs: t.startupJitterMs ?? 0,
153
+ hooks: t.hooks
89
154
  };
90
155
  }
156
+ /**
157
+ * Invokes an observability hook in isolation. A throwing hook is logged and
158
+ * swallowed so it can never break the processing loop (mitigation T3).
159
+ */
160
+ safelyInvokeHook(e, t) {
161
+ if (this.options.hooks)
162
+ try {
163
+ t();
164
+ } catch (s) {
165
+ const r = s instanceof Error ? s.message : String(s);
166
+ this.logger.error(`Outbox hook ${e} threw and was ignored: ${r}`);
167
+ }
168
+ }
91
169
  /**
92
170
  * Registers a message handler for a specific message type
93
171
  */
@@ -108,7 +186,9 @@ class m {
108
186
  this.logger.warn("Processor is already running");
109
187
  return;
110
188
  }
111
- this.isRunning = !0, this.logger.info("Starting outbox processor"), this.scheduleProcessing();
189
+ this.isRunning = !0, this.logger.info("Starting outbox processor");
190
+ const e = this.options.startupJitterMs > 0 ? Math.floor(Math.random() * this.options.startupJitterMs) : 0;
191
+ this.scheduleProcessing(0, this.options.processingInterval + e), this.scheduleCrashRecovery();
112
192
  }
113
193
  /**
114
194
  * Stops the processor
@@ -118,7 +198,7 @@ class m {
118
198
  this.logger.warn("Processor is not running");
119
199
  return;
120
200
  }
121
- this.isRunning = !1, this.processingTimer && (clearTimeout(this.processingTimer), this.processingTimer = void 0), this.logger.info("Stopped outbox processor");
201
+ 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");
122
202
  }
123
203
  /**
124
204
  * Processes a single batch of pending messages.
@@ -141,38 +221,57 @@ class m {
141
221
  * **Quiescence:** if there are no pending messages, returns immediately
142
222
  * without invoking handlers.
143
223
  *
224
+ * @returns `{ processed, batchSize }` — `processed` is how many messages were
225
+ * dispatched this batch, `batchSize` the configured limit. A full batch
226
+ * (`processed >= batchSize`) signals a likely backlog. Callers that ignore
227
+ * the return value are unaffected (the value was previously `void`).
144
228
  * @public
145
229
  * @stable
146
230
  * @since 0.1.0
147
231
  */
148
232
  async processBatch() {
233
+ const e = this.options.batchSize;
149
234
  if (!this.isRunning)
150
- return;
151
- const [e, t] = await d(
152
- () => this.repository.getUnprocessedMessages(this.options.batchSize, this.options.priorityOrder)
235
+ return { processed: 0, batchSize: e };
236
+ const [t, s] = await d(
237
+ () => this.repository.getUnprocessedMessages(
238
+ e,
239
+ this.options.priorityOrder,
240
+ this.options.messageTypes
241
+ )
153
242
  );
154
- if (e) {
155
- const a = e instanceof Error ? e.message : String(e);
156
- this.logger.error(`Error retrieving messages: ${a}`);
157
- return;
158
- }
159
- const s = t;
160
- if (!s || !Array.isArray(s) || s.length === 0) {
161
- this.logger.debug("No pending messages to process");
162
- return;
243
+ if (t) {
244
+ const c = t instanceof Error ? t.message : String(t);
245
+ return this.logger.error(`Error retrieving messages: ${c}`), { processed: 0, batchSize: e };
163
246
  }
164
- this.logger.info(`Processing ${s.length} messages`);
165
- const r = s.map((a) => this.processMessage(a));
166
- await Promise.allSettled(r), this.logger.info(`Completed processing batch of ${s.length} messages`);
247
+ const r = s;
248
+ if (!r || !Array.isArray(r) || r.length === 0)
249
+ return this.logger.debug("No pending messages to process"), { processed: 0, batchSize: e };
250
+ this.logger.info(`Processing ${r.length} messages`);
251
+ const a = Date.now(), l = (await Promise.allSettled(
252
+ r.map((c) => this.processMessage(c))
253
+ )).filter(
254
+ (c) => c.status === "rejected" || c.status === "fulfilled" && c.value === !1
255
+ ).length;
256
+ return this.logger.info(`Completed processing batch of ${r.length} messages`), this.safelyInvokeHook(
257
+ "onBatchComplete",
258
+ () => this.options.hooks?.onBatchComplete?.({
259
+ processed: r.length,
260
+ failed: l,
261
+ batchSize: e,
262
+ durationMs: Date.now() - a
263
+ })
264
+ ), { processed: r.length, batchSize: e };
167
265
  }
168
266
  /**
169
- * Processes a single message through the middleware pipeline
267
+ * Processes a single message through the middleware pipeline.
268
+ * @returns `true` if the message was processed successfully, `false` if it failed.
170
269
  */
171
270
  async processMessage(e) {
172
271
  const [t] = await d(async () => {
173
- await this.repository.updateStatus(e.id, o.PROCESSING), await this.buildPipeline(e)(e), await this.repository.updateStatus(e.id, o.PROCESSED), this.logger.debug(`Successfully processed message ${e.id}`);
272
+ 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}`);
174
273
  });
175
- t && await this.handleMessageError(e, t);
274
+ return t ? (await this.handleMessageError(e, t), !1) : !0;
176
275
  }
177
276
  /**
178
277
  * Builds the middleware pipeline for message processing
@@ -185,8 +284,8 @@ class m {
185
284
  await Promise.race([
186
285
  t.handle(r),
187
286
  new Promise(
188
- (a, c) => setTimeout(
189
- () => c(new Error("Message processing timeout")),
287
+ (a, o) => setTimeout(
288
+ () => o(new Error("Message processing timeout")),
190
289
  this.options.messageTimeout
191
290
  )
192
291
  )
@@ -200,19 +299,70 @@ class m {
200
299
  async handleMessageError(e, t) {
201
300
  try {
202
301
  const s = await this.repository.incrementAttempt(e.id);
203
- this.logger.warn(`Message ${e.id} failed, attempt ${s}: ${t?.message}`), s >= this.options.maxRetries ? (await this.repository.updateStatus(e.id, o.FAILED, t), this.logger.error(`Message ${e.id} marked as failed after ${s} attempts`)) : (await this.repository.updateStatus(e.id, o.PENDING), this.logger.info(`Message ${e.id} scheduled for retry (attempt ${s + 1})`));
302
+ if (this.logger.warn(`Message ${e.id} failed, attempt ${s}: ${t?.message}`), this.safelyInvokeHook(
303
+ "onMessageFailed",
304
+ () => this.options.hooks?.onMessageFailed?.(e, t, s)
305
+ ), s >= this.options.maxRetries)
306
+ await this.repository.updateStatus(e.id, n.FAILED, t), this.logger.error(`Message ${e.id} marked as failed after ${s} attempts`), this.safelyInvokeHook(
307
+ "onPermanentFailure",
308
+ () => this.options.hooks?.onPermanentFailure?.(e, t)
309
+ );
310
+ 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);
312
+ 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})`
314
+ );
315
+ } else
316
+ await this.repository.updateStatus(e.id, n.PENDING), this.logger.info(`Message ${e.id} scheduled for retry (attempt ${s + 1})`);
204
317
  } catch (s) {
205
318
  const r = s instanceof Error ? s.message : String(s);
206
319
  this.logger.error(`Error updating message status: ${r}`);
207
320
  }
208
321
  }
209
322
  /**
210
- * Schedules the next processing cycle
323
+ * Schedules the next processing cycle.
324
+ *
325
+ * With `adaptiveRepoll` enabled, a full batch re-polls immediately (`delay 0`)
326
+ * to drain a backlog — until `adaptiveRepollMaxConsecutive` consecutive full
327
+ * batches trip the livelock guard, after which an `adaptiveRepollPauseMs`
328
+ * pause is inserted. Any non-full batch resets the cadence to
329
+ * `processingInterval`.
330
+ *
331
+ * @param consecutiveFullBatches running count of back-to-back full batches
332
+ * @param delay milliseconds to wait before the next batch
211
333
  */
212
- scheduleProcessing() {
334
+ scheduleProcessing(e = 0, t = this.options.processingInterval) {
213
335
  this.isRunning && (this.processingTimer = setTimeout(async () => {
214
- await this.processBatch(), this.scheduleProcessing();
215
- }, this.options.processingInterval));
336
+ const s = await this.processBatch();
337
+ if (this.options.adaptiveRepoll && s.processed > 0 && s.processed >= s.batchSize) {
338
+ const a = e + 1, o = a > this.options.adaptiveRepollMaxConsecutive ? this.options.adaptiveRepollPauseMs : 0;
339
+ this.scheduleProcessing(a, o);
340
+ } else
341
+ this.scheduleProcessing(0, this.options.processingInterval);
342
+ }, t));
343
+ }
344
+ /**
345
+ * Schedules the next crash-recovery cycle. No-op unless
346
+ * `crashRecoveryIntervalMs` was configured.
347
+ */
348
+ scheduleCrashRecovery() {
349
+ !this.isRunning || this.options.crashRecoveryIntervalMs === void 0 || (this.crashRecoveryTimer = setTimeout(async () => {
350
+ await this.runCrashRecovery(), this.scheduleCrashRecovery();
351
+ }, this.options.crashRecoveryIntervalMs));
352
+ }
353
+ /**
354
+ * Resets messages orphaned in `PROCESSING` (older than
355
+ * `crashRecoveryThresholdMs`) back to `PENDING` via the repository. Errors are
356
+ * logged, never thrown — crash recovery must not crash the processor.
357
+ */
358
+ async runCrashRecovery() {
359
+ const e = new Date(Date.now() - this.options.crashRecoveryThresholdMs), [t, s] = await d(() => this.repository.resetStaleProcessing(e));
360
+ if (t) {
361
+ const r = t instanceof Error ? t.message : String(t);
362
+ this.logger.error(`Crash recovery failed: ${r}`);
363
+ return;
364
+ }
365
+ s && s > 0 && this.logger.warn(`Crash recovery reset ${s} stale PROCESSING message(s) to PENDING`);
216
366
  }
217
367
  /**
218
368
  * Logs messages if logging is enabled
@@ -234,7 +384,7 @@ class m {
234
384
  };
235
385
  }
236
386
  }
237
- class f {
387
+ class M {
238
388
  constructor(e) {
239
389
  this.eventBus = e;
240
390
  }
@@ -250,10 +400,10 @@ class f {
250
400
  await this.eventBus.publish(s);
251
401
  }
252
402
  }
253
- class M {
403
+ class R {
254
404
  constructor(e, t = {}) {
255
- this.logger = l.create("OutboxService"), this.repository = e, this.options = {
256
- defaultPriority: t.defaultPriority ?? n.NORMAL,
405
+ this.logger = u.create("OutboxService"), this.repository = e, this.options = {
406
+ defaultPriority: t.defaultPriority ?? g.NORMAL,
257
407
  defaultMetadata: t.defaultMetadata ?? {},
258
408
  autoCleanup: t.autoCleanup ?? !1,
259
409
  cleanupAfterDays: t.cleanupAfterDays ?? 7,
@@ -264,7 +414,7 @@ class M {
264
414
  * Saves a message to the outbox
265
415
  */
266
416
  async saveMessage(e, t, s) {
267
- const r = g.createMessage(e, t, {
417
+ const r = h.createMessage(e, t, {
268
418
  priority: this.options.defaultPriority,
269
419
  metadata: { ...this.options.defaultMetadata, ...s?.metadata },
270
420
  ...s
@@ -276,10 +426,10 @@ class M {
276
426
  */
277
427
  async saveMessages(e) {
278
428
  const t = e.map(
279
- ({ messageType: r, payload: a, options: c }) => g.createMessage(r, a, {
429
+ ({ messageType: r, payload: a, options: o }) => h.createMessage(r, a, {
280
430
  priority: this.options.defaultPriority,
281
- metadata: { ...this.options.defaultMetadata, ...c?.metadata },
282
- ...c
431
+ metadata: { ...this.options.defaultMetadata, ...o?.metadata },
432
+ ...o
283
433
  })
284
434
  ), s = await this.repository.saveBatch(t);
285
435
  return this.logger.debug(`Saved batch of ${e.length} messages`), s;
@@ -288,14 +438,14 @@ class M {
288
438
  * Schedules a message for delayed processing
289
439
  */
290
440
  async scheduleMessage(e, t, s, r) {
291
- const a = g.createDelayedMessage(e, t, s, {
441
+ const a = h.createDelayedMessage(e, t, s, {
292
442
  priority: this.options.defaultPriority,
293
443
  metadata: { ...this.options.defaultMetadata, ...r?.metadata },
294
444
  ...r
295
- }), c = await this.repository.scheduleMessage(a, a.processAfter);
445
+ }), o = await this.repository.scheduleMessage(a, a.processAfter);
296
446
  return this.logger.debug(
297
- `Scheduled message ${c} of type ${e} for processing in ${s}ms`
298
- ), c;
447
+ `Scheduled message ${o} of type ${e} for processing in ${s}ms`
448
+ ), o;
299
449
  }
300
450
  /**
301
451
  * Saves a high priority message to the outbox
@@ -303,7 +453,7 @@ class M {
303
453
  async saveHighPriorityMessage(e, t, s) {
304
454
  return this.saveMessage(e, t, {
305
455
  ...s,
306
- priority: n.HIGH
456
+ priority: g.HIGH
307
457
  });
308
458
  }
309
459
  /**
@@ -312,14 +462,14 @@ class M {
312
462
  async saveCriticalMessage(e, t, s) {
313
463
  return this.saveMessage(e, t, {
314
464
  ...s,
315
- priority: n.CRITICAL
465
+ priority: g.CRITICAL
316
466
  });
317
467
  }
318
468
  /**
319
469
  * Converts a domain event to an outbox message and saves it
320
470
  */
321
471
  async saveDomainEvent(e, t) {
322
- const s = g.createFromIntegrationEvent(
472
+ const s = h.createFromIntegrationEvent(
323
473
  {
324
474
  eventName: e.eventName,
325
475
  payload: e,
@@ -353,13 +503,13 @@ class M {
353
503
  * Retries a failed message by resetting it to pending status
354
504
  */
355
505
  async retryMessage(e) {
356
- await this.repository.updateStatus(e, o.PENDING), this.logger.info(`Reset message ${e} to pending for retry`);
506
+ await this.repository.updateStatus(e, n.PENDING), this.logger.info(`Reset message ${e} to pending for retry`);
357
507
  }
358
508
  /**
359
509
  * Retries multiple failed messages
360
510
  */
361
511
  async retryMessages(e) {
362
- await this.repository.updateStatusBatch(e, o.PENDING), this.logger.info(`Reset ${e.length} messages to pending for retry`);
512
+ await this.repository.updateStatusBatch(e, n.PENDING), this.logger.info(`Reset ${e.length} messages to pending for retry`);
363
513
  }
364
514
  /**
365
515
  * Cleans up old processed messages
@@ -369,7 +519,7 @@ class M {
369
519
  s.setDate(s.getDate() - t);
370
520
  const r = await this.repository.deleteByStatusAndAge(
371
521
  s,
372
- o.PROCESSED
522
+ n.PROCESSED
373
523
  );
374
524
  return this.logger.info(`Cleaned up ${r} processed messages older than ${t} days`), r;
375
525
  }
@@ -379,10 +529,10 @@ class M {
379
529
  async getStats() {
380
530
  const e = this.repository;
381
531
  return e.getMessageCountByStatus ? {
382
- totalPending: e.getMessageCountByStatus(o.PENDING),
383
- totalProcessing: e.getMessageCountByStatus(o.PROCESSING),
384
- totalProcessed: e.getMessageCountByStatus(o.PROCESSED),
385
- totalFailed: e.getMessageCountByStatus(o.FAILED)
532
+ totalPending: e.getMessageCountByStatus(n.PENDING),
533
+ totalProcessing: e.getMessageCountByStatus(n.PROCESSING),
534
+ totalProcessed: e.getMessageCountByStatus(n.PROCESSED),
535
+ totalFailed: e.getMessageCountByStatus(n.FAILED)
386
536
  } : {
387
537
  totalPending: (await this.repository.getUnprocessedMessages(1e3)).length,
388
538
  totalProcessing: 0,
@@ -401,11 +551,11 @@ class M {
401
551
  }
402
552
  }
403
553
  export {
404
- f as EventBusOutboxHandler,
405
- y as IOutboxRepository,
406
- n as MessagePriority,
407
- o as MessageStatus,
408
- g as OutboxMessageFactory,
409
- m as OutboxProcessor,
410
- M as OutboxService
554
+ M as EventBusOutboxHandler,
555
+ m as IOutboxRepository,
556
+ g as MessagePriority,
557
+ n as MessageStatus,
558
+ h as OutboxMessageFactory,
559
+ v as OutboxProcessor,
560
+ R as OutboxService
411
561
  };
@@ -2,4 +2,5 @@ export * from './outbox-interfaces';
2
2
  export * from './outbox-repository.interface';
3
3
  export * from './outbox-message-factory';
4
4
  export * from './outbox-processor';
5
+ export * from './outbox-hooks';
5
6
  export * from './outbox-service';
@@ -0,0 +1,24 @@
1
+ import { IOutboxMessage } from './outbox-interfaces';
2
+ /**
3
+ * Observability callbacks for {@link OutboxProcessor}. Use them to wire metrics
4
+ * (Prometheus counters, DataDog), alerting on permanent failures, or dead-letter
5
+ * routing — without coupling the processor to any specific telemetry stack.
6
+ *
7
+ * **Hooks must be synchronous and fast.** Each call is wrapped in a try/catch by
8
+ * the processor, so a throwing hook is logged and ignored (it never breaks the
9
+ * processing loop), but a slow hook still blocks the batch. Offload heavy work
10
+ * (network, disk) to a queue inside the hook.
11
+ */
12
+ export interface OutboxProcessorHooks {
13
+ /** Fired once per processed batch with aggregate counts and wall-clock duration. */
14
+ onBatchComplete?(result: {
15
+ processed: number;
16
+ failed: number;
17
+ batchSize: number;
18
+ durationMs: number;
19
+ }): void;
20
+ /** Fired on every message failure, including ones that will be retried. */
21
+ onMessageFailed?(message: IOutboxMessage, error: Error, attempt: number): void;
22
+ /** Fired when a message is marked `FAILED` after exhausting `maxRetries`. */
23
+ onPermanentFailure?(message: IOutboxMessage, error: Error): void;
24
+ }