@vytches/ddd-messaging 0.30.0 → 0.31.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/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||{}),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;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const p=require("@vytches/ddd-utils"),c=require("@vytches/ddd-contracts/internal");var n=(i=>(i.PENDING="PENDING",i.PROCESSING="PROCESSING",i.PROCESSED="PROCESSED",i.FAILED="FAILED",i))(n||{}),l=(i=>(i.LOW="low",i.NORMAL="normal",i.HIGH="high",i.CRITICAL="critical",i))(l||{});const m=["critical","high","normal","low"];function f(i,e,t=m){const s=t.includes(i)?t.indexOf(i):t.length,a=t.includes(e)?t.indexOf(e):t.length;return s-a}class v{async claimBatch(e,t,s){const a=await this.getUnprocessedMessages(e,t,s);return a.length===0?a:(await this.updateStatusBatch(a.map(r=>r.id),n.PROCESSING),a.map(r=>({...r,status:n.PROCESSING})))}scheduleRetry(e,t){return Promise.resolve()}resetStaleProcessing(e){return Promise.resolve(0)}}class h{static createMessage(e,t,s){const a={id:p.LibUtils.getUUID(),messageType:e,payload:t,metadata:s?.metadata||{},status:n.PENDING,attempts:0,createdAt:new Date,priority:s?.priority||l.NORMAL};return s?.processAfter&&(a.processAfter=s.processAfter),a}static createDelayedMessage(e,t,s,a){const r=new Date(Date.now()+s);return h.createMessage(e,t,{...a,processAfter:r})}static createHighPriorityMessage(e,t,s){return h.createMessage(e,t,{...s,priority:l.HIGH})}static createFromIntegrationEvent(e,t){const s={metadata:{...e.metadata||{},...t?.metadata||{}}};return t?.priority&&(s.priority=t.priority),t?.processAfter&&(s.processAfter=t.processAfter),h.createMessage(`integration_event:${e.eventName}`,e.payload,s)}}class M{constructor(e,t={}){this.handlers=new Map,this.middlewares=[],this.isRunning=!1,this.warnedMissingClaimBatch=!1;const s=t.batchSize??10;if(s>1e4)throw new RangeError(`OutboxProcessor: batchSize must be ≤ 10,000, got ${s}`);if(t.retryBackoff){const{initial:o,multiplier:u,maxDelay:d}=t.retryBackoff;if(o<=0||u<=0||d<=0)throw new RangeError(`OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${u}, maxDelay=${d})`)}const a=t.messageTimeout??3e4,r=t.crashRecoveryThresholdMs??Math.max(3e5,a);if(r<a)throw new RangeError(`OutboxProcessor: crashRecoveryThresholdMs (${r}) must be ≥ messageTimeout (${a}) to avoid resetting in-flight messages`);this.repository=e,this.options={batchSize:s,maxRetries:t.maxRetries??3,processingInterval:t.processingInterval??5e3,priorityOrder:t.priorityOrder??[l.CRITICAL,l.HIGH,l.NORMAL,l.LOW],messageTimeout:a,enableLogging:t.enableLogging??!1,retryBackoff:t.retryBackoff,messageTypes:t.messageTypes,crashRecoveryIntervalMs:t.crashRecoveryIntervalMs,crashRecoveryThresholdMs:r,adaptiveRepoll:t.adaptiveRepoll??!1,adaptiveRepollMaxConsecutive:t.adaptiveRepollMaxConsecutive??5,adaptiveRepollPauseMs:t.adaptiveRepollPauseMs??50,startupJitterMs:t.startupJitterMs??0,hooks:t.hooks,useClaimBatch:t.useClaimBatch??!1}}safelyInvokeHook(e,t){if(this.options.hooks)try{t()}catch(s){c.internalLogger.error(`OutboxProcessor: hook ${e} threw and was ignored`,s instanceof Error?s:void 0,{hookName:e})}}registerHandler(e,t){this.handlers.set(e,t)}registerDefaultHandler(e){this.defaultHandler&&c.internalLogger.warn("registerDefaultHandler: replacing existing default handler — previous handler discarded"),this.defaultHandler=e}use(e){this.middlewares.push(e)}start(){if(this.isRunning){c.internalLogger.warn("OutboxProcessor: processor is already running");return}this.isRunning=!0;const e=this.options.startupJitterMs>0?Math.floor(Math.random()*this.options.startupJitterMs):0;this.scheduleProcessing(0,this.options.processingInterval+e),this.scheduleCrashRecovery()}stop(){if(!this.isRunning){c.internalLogger.warn("OutboxProcessor: processor is not running");return}this.isRunning=!1,this.processingTimer&&(clearTimeout(this.processingTimer),this.processingTimer=void 0),this.crashRecoveryTimer&&(clearTimeout(this.crashRecoveryTimer),this.crashRecoveryTimer=void 0)}async processBatch(){const e=this.options.batchSize;if(!this.isRunning)return{processed:0,batchSize:e};const t=this.repository.claimBatch,s=this.options.useClaimBatch===!0&&typeof t=="function";this.options.useClaimBatch&&!s&&!this.warnedMissingClaimBatch&&(this.warnedMissingClaimBatch=!0,c.internalLogger.warn("OutboxProcessor: useClaimBatch is enabled but the repository does not implement claimBatch() — falling back to getUnprocessedMessages (no atomic-claim protection)"));const[a,r]=await p.safeRun(()=>s&&t?t.call(this.repository,e,this.options.priorityOrder,this.options.messageTypes):this.repository.getUnprocessedMessages(e,this.options.priorityOrder,this.options.messageTypes));if(a)return c.internalLogger.error("OutboxProcessor: error retrieving messages",a instanceof Error?a:void 0),{processed:0,batchSize:e};const o=r;if(!o||!Array.isArray(o)||o.length===0)return{processed:0,batchSize:e};const u=Date.now(),y=(await Promise.allSettled(o.map(g=>this.processMessage(g,s)))).filter(g=>g.status==="rejected"||g.status==="fulfilled"&&g.value===!1).length;return this.safelyInvokeHook("onBatchComplete",()=>this.options.hooks?.onBatchComplete?.({processed:o.length,failed:y,batchSize:e,durationMs:Date.now()-u})),{processed:o.length,batchSize:e}}async processMessage(e,t=!1){const[s]=await p.safeRun(async()=>{t||await this.repository.updateStatus(e.id,n.PROCESSING),await this.buildPipeline(e)(e),await this.repository.updateStatus(e.id,n.PROCESSED)});return s?(await this.handleMessageError(e,s),!1):!0}buildPipeline(e){const t=this.handlers.get(e.messageType)??this.defaultHandler;if(!t)throw new Error(`No handler registered for message type: ${e.messageType}`);const s=async a=>{let r;try{await Promise.race([t.handle(a),new Promise((o,u)=>{r=setTimeout(()=>u(new Error("Message processing timeout")),this.options.messageTimeout)})])}finally{r!==void 0&&clearTimeout(r)}};return this.middlewares.reduceRight((a,r)=>r(a),s)}async handleMessageError(e,t){try{const s=await this.repository.incrementAttempt(e.id);if(c.internalLogger.warn(`OutboxProcessor: message ${e.id} failed, attempt ${s}: ${p.LibUtils.sanitizeLogMessage(t?.message??String(t))}`),this.safelyInvokeHook("onMessageFailed",()=>this.options.hooks?.onMessageFailed?.(e,t,s)),s>=this.options.maxRetries)await this.repository.updateStatus(e.id,n.FAILED,t),c.internalLogger.error("OutboxProcessor: message permanently failed",t,{messageId:e.id,attempts:s}),this.safelyInvokeHook("onPermanentFailure",()=>this.options.hooks?.onPermanentFailure?.(e,t));else if(this.options.retryBackoff){const{initial:a,multiplier:r,maxDelay:o}=this.options.retryBackoff,u=Math.min(a*Math.pow(r,s-1),o),d=new Date(Date.now()+u);await this.repository.updateStatus(e.id,n.PENDING),await this.repository.scheduleRetry(e.id,d)}else await this.repository.updateStatus(e.id,n.PENDING)}catch(s){c.internalLogger.error("OutboxProcessor: error updating message status",s instanceof Error?s:void 0,{messageId:e.id})}}scheduleProcessing(e=0,t=this.options.processingInterval){this.isRunning&&(this.processingTimer=setTimeout(async()=>{const s=await this.processBatch();if(this.options.adaptiveRepoll&&s.processed>0&&s.processed>=s.batchSize){const r=e+1,o=r>this.options.adaptiveRepollMaxConsecutive?this.options.adaptiveRepollPauseMs:0;this.scheduleProcessing(r,o)}else this.scheduleProcessing(0,this.options.processingInterval)},t))}scheduleCrashRecovery(){!this.isRunning||this.options.crashRecoveryIntervalMs===void 0||(this.crashRecoveryTimer=setTimeout(async()=>{await this.runCrashRecovery(),this.scheduleCrashRecovery()},this.options.crashRecoveryIntervalMs))}async runCrashRecovery(){const e=new Date(Date.now()-this.options.crashRecoveryThresholdMs),[t,s]=await p.safeRun(()=>this.repository.resetStaleProcessing(e));if(t){c.internalLogger.error("OutboxProcessor: crash recovery failed",t instanceof Error?t:void 0);return}s&&s>0&&c.internalLogger.warn(`OutboxProcessor: crash recovery reset ${s} stale PROCESSING message(s) to PENDING`)}async getStats(){return{isRunning:this.isRunning,batchSize:this.options.batchSize,maxRetries:this.options.maxRetries,processingInterval:this.options.processingInterval,registeredHandlers:Array.from(this.handlers.keys()),middlewareCount:this.middlewares.length,hasDefaultHandler:this.defaultHandler!==void 0}}}class R{constructor(e){this.eventBus=e}async handle(e){const s={eventName:e.messageType.startsWith("integration_event:")?e.messageType.replace("integration_event:",""):e.messageType,eventId:e.id,occurredOn:e.createdAt,version:1,...e.payload,...e.metadata};await this.eventBus.publish(s)}}class P{constructor(e,t={}){this.repository=e,this.options={defaultPriority:t.defaultPriority??l.NORMAL,defaultMetadata:t.defaultMetadata??{},autoCleanup:t.autoCleanup??!1,cleanupAfterDays:t.cleanupAfterDays??7,enableLogging:t.enableLogging??!1}}async saveMessage(e,t,s){const a=h.createMessage(e,t,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...s?.metadata},...s});return await this.repository.saveMessage(a)}async saveMessages(e){const t=e.map(({messageType:a,payload:r,options:o})=>h.createMessage(a,r,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...o?.metadata},...o}));return await this.repository.saveBatch(t)}async scheduleMessage(e,t,s,a){const r=h.createDelayedMessage(e,t,s,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...a?.metadata},...a});return await this.repository.scheduleMessage(r,r.processAfter)}async saveHighPriorityMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:l.HIGH})}async saveCriticalMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:l.CRITICAL})}async saveDomainEvent(e,t){const s=h.createFromIntegrationEvent({eventName:e.eventName,payload:e,metadata:{eventId:e?.metadata?.eventId,occurredOn:e?.metadata?.occurredOn,version:e?.metadata?.version}},{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...t?.metadata},...t});return await this.repository.saveMessage(s)}async getMessage(e){return this.repository.getById(e)}async getPendingMessages(e=100){return this.repository.getUnprocessedMessages(e)}async retryMessage(e){await this.repository.updateStatus(e,n.PENDING)}async retryMessages(e){await this.repository.updateStatusBatch(e,n.PENDING)}async cleanup(e){const t=e??this.options.cleanupAfterDays,s=new Date;return s.setDate(s.getDate()-t),await this.repository.deleteByStatusAndAge(s,n.PROCESSED)}async getStats(){const e=this.repository;return e.getMessageCountByStatus?{totalPending:e.getMessageCountByStatus(n.PENDING),totalProcessing:e.getMessageCountByStatus(n.PROCESSING),totalProcessed:e.getMessageCountByStatus(n.PROCESSED),totalFailed:e.getMessageCountByStatus(n.FAILED)}:{totalPending:(await this.repository.getUnprocessedMessages(1e3)).length,totalProcessing:0,totalProcessed:0,totalFailed:0}}}exports.EventBusOutboxHandler=R;exports.IOutboxRepository=v;exports.MessagePriority=l;exports.MessageStatus=n;exports.OutboxMessageFactory=h;exports.OutboxProcessor=M;exports.OutboxService=P;exports.comparePriority=f;
package/dist/index.js CHANGED
@@ -1,18 +1,59 @@
1
- import { Logger as u } from "@vytches/ddd-logging";
2
- import { LibUtils as p, safeRun as d } from "@vytches/ddd-utils";
1
+ import { LibUtils as y, safeRun as g } from "@vytches/ddd-utils";
2
+ import { internalLogger as c } from "@vytches/ddd-contracts/internal";
3
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 = [
4
+ const f = [
5
5
  "critical",
6
6
  "high",
7
7
  "normal",
8
8
  "low"
9
9
  /* LOW */
10
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;
11
+ function R(i, e, t = f) {
12
+ const s = t.includes(i) ? t.indexOf(i) : t.length, a = t.includes(e) ? t.indexOf(e) : t.length;
13
+ return s - a;
14
14
  }
15
- class M {
15
+ class w {
16
+ /**
17
+ * Atomically claims a batch of unprocessed messages: reads PENDING
18
+ * messages *and* marks them `PROCESSING` in the same operation, so two
19
+ * concurrent callers (two processor instances, or two racing calls on one
20
+ * instance) never both receive the same message.
21
+ *
22
+ * **Optional, and non-atomic by default.** This method is declared with
23
+ * `?` — it is NOT part of the required abstract contract, so existing
24
+ * `IOutboxRepository` subclasses keep compiling unmodified (AC#5). The
25
+ * default implementation provided here is a convenience shim only: it
26
+ * calls {@link getUnprocessedMessages} and then `updateStatusBatch(...,
27
+ * PROCESSING)` as two separate steps. **That is NOT atomic** — it has the
28
+ * exact same read-then-mark race window as calling
29
+ * `getUnprocessedMessages` + `updateStatus` manually, and is safe only for
30
+ * a single-worker deployment. Override `claimBatch` in a concrete
31
+ * repository (e.g. backed by `SELECT ... FOR UPDATE SKIP LOCKED`, or a
32
+ * single atomic `UPDATE ... WHERE status='PENDING' LIMIT n RETURNING *`)
33
+ * to get real multi-worker/multi-processor safety.
34
+ *
35
+ * `OutboxProcessor` only calls this method when
36
+ * `OutboxProcessorOptions.useClaimBatch` is explicitly set to `true`
37
+ * (opt-in, default `false`) — see AC#1/AC#2. When enabled, the processor
38
+ * skips its own redundant `updateStatus(id, PROCESSING)` call for messages
39
+ * returned by `claimBatch`, since claiming already performed that mark.
40
+ *
41
+ * Handlers should be idempotent to side effects regardless — transient
42
+ * failures or duplicate-claim races (e.g. with the non-atomic default
43
+ * implementation) may still cause a handler to run more than once.
44
+ *
45
+ * @param limit - Maximum number of messages to claim.
46
+ * @param priorityOrder - See {@link getUnprocessedMessages}.
47
+ * @param messageTypes - See {@link getUnprocessedMessages}.
48
+ * @returns Messages that were claimed (already marked `PROCESSING`).
49
+ */
50
+ async claimBatch(e, t, s) {
51
+ const a = await this.getUnprocessedMessages(e, t, s);
52
+ return a.length === 0 ? a : (await this.updateStatusBatch(
53
+ a.map((r) => r.id),
54
+ n.PROCESSING
55
+ ), a.map((r) => ({ ...r, status: n.PROCESSING })));
56
+ }
16
57
  /**
17
58
  * Schedules a retry for a failed message at a specific future time.
18
59
  *
@@ -54,7 +95,7 @@ class M {
54
95
  return Promise.resolve(0);
55
96
  }
56
97
  }
57
- class h {
98
+ class d {
58
99
  /**
59
100
  * Creates a new outbox message
60
101
  * @param messageType Type of the message
@@ -63,8 +104,8 @@ class h {
63
104
  * @returns Outbox message
64
105
  */
65
106
  static createMessage(e, t, s) {
66
- const r = {
67
- id: p.getUUID(),
107
+ const a = {
108
+ id: y.getUUID(),
68
109
  messageType: e,
69
110
  payload: t,
70
111
  metadata: s?.metadata || {},
@@ -73,7 +114,7 @@ class h {
73
114
  createdAt: /* @__PURE__ */ new Date(),
74
115
  priority: s?.priority || l.NORMAL
75
116
  };
76
- return s?.processAfter && (r.processAfter = s.processAfter), r;
117
+ return s?.processAfter && (a.processAfter = s.processAfter), a;
77
118
  }
78
119
  /**
79
120
  * Creates a delayed outbox message
@@ -83,11 +124,11 @@ class h {
83
124
  * @param options Additional options
84
125
  * @returns Outbox message scheduled for later processing
85
126
  */
86
- static createDelayedMessage(e, t, s, r) {
87
- const a = new Date(Date.now() + s);
88
- return h.createMessage(e, t, {
89
- ...r,
90
- processAfter: a
127
+ static createDelayedMessage(e, t, s, a) {
128
+ const r = new Date(Date.now() + s);
129
+ return d.createMessage(e, t, {
130
+ ...a,
131
+ processAfter: r
91
132
  });
92
133
  }
93
134
  /**
@@ -98,7 +139,7 @@ class h {
98
139
  * @returns High priority outbox message
99
140
  */
100
141
  static createHighPriorityMessage(e, t, s) {
101
- return h.createMessage(e, t, {
142
+ return d.createMessage(e, t, {
102
143
  ...s,
103
144
  priority: l.HIGH
104
145
  });
@@ -116,30 +157,30 @@ class h {
116
157
  ...t?.metadata || {}
117
158
  }
118
159
  };
119
- return t?.priority && (s.priority = t.priority), t?.processAfter && (s.processAfter = t.processAfter), h.createMessage(
160
+ return t?.priority && (s.priority = t.priority), t?.processAfter && (s.processAfter = t.processAfter), d.createMessage(
120
161
  `integration_event:${e.eventName}`,
121
162
  e.payload,
122
163
  s
123
164
  );
124
165
  }
125
166
  }
126
- class R {
167
+ class P {
127
168
  constructor(e, t = {}) {
128
- this.handlers = /* @__PURE__ */ new Map(), this.middlewares = [], this.isRunning = !1, this.logger = u.create("OutboxProcessor");
169
+ this.handlers = /* @__PURE__ */ new Map(), this.middlewares = [], this.isRunning = !1, this.warnedMissingClaimBatch = !1;
129
170
  const s = t.batchSize ?? 10;
130
171
  if (s > 1e4)
131
172
  throw new RangeError(`OutboxProcessor: batchSize must be ≤ 10,000, got ${s}`);
132
173
  if (t.retryBackoff) {
133
- const { initial: o, multiplier: g, maxDelay: c } = t.retryBackoff;
134
- if (o <= 0 || g <= 0 || c <= 0)
174
+ const { initial: o, multiplier: h, maxDelay: u } = t.retryBackoff;
175
+ if (o <= 0 || h <= 0 || u <= 0)
135
176
  throw new RangeError(
136
- `OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${g}, maxDelay=${c})`
177
+ `OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${h}, maxDelay=${u})`
137
178
  );
138
179
  }
139
- const r = t.messageTimeout ?? 3e4, a = t.crashRecoveryThresholdMs ?? Math.max(3e5, r);
140
- if (a < r)
180
+ const a = t.messageTimeout ?? 3e4, r = t.crashRecoveryThresholdMs ?? Math.max(3e5, a);
181
+ if (r < a)
141
182
  throw new RangeError(
142
- `OutboxProcessor: crashRecoveryThresholdMs (${a}) must be ≥ messageTimeout (${r}) to avoid resetting in-flight messages`
183
+ `OutboxProcessor: crashRecoveryThresholdMs (${r}) must be ≥ messageTimeout (${a}) to avoid resetting in-flight messages`
143
184
  );
144
185
  this.repository = e, this.options = {
145
186
  batchSize: s,
@@ -151,17 +192,18 @@ class R {
151
192
  l.NORMAL,
152
193
  l.LOW
153
194
  ],
154
- messageTimeout: r,
195
+ messageTimeout: a,
155
196
  enableLogging: t.enableLogging ?? !1,
156
197
  retryBackoff: t.retryBackoff,
157
198
  messageTypes: t.messageTypes,
158
199
  crashRecoveryIntervalMs: t.crashRecoveryIntervalMs,
159
- crashRecoveryThresholdMs: a,
200
+ crashRecoveryThresholdMs: r,
160
201
  adaptiveRepoll: t.adaptiveRepoll ?? !1,
161
202
  adaptiveRepollMaxConsecutive: t.adaptiveRepollMaxConsecutive ?? 5,
162
203
  adaptiveRepollPauseMs: t.adaptiveRepollPauseMs ?? 50,
163
204
  startupJitterMs: t.startupJitterMs ?? 0,
164
- hooks: t.hooks
205
+ hooks: t.hooks,
206
+ useClaimBatch: t.useClaimBatch ?? !1
165
207
  };
166
208
  }
167
209
  /**
@@ -173,15 +215,18 @@ class R {
173
215
  try {
174
216
  t();
175
217
  } catch (s) {
176
- const r = s instanceof Error ? s.message : String(s);
177
- this.logger.error(`Outbox hook ${e} threw and was ignored: ${r}`);
218
+ c.error(
219
+ `OutboxProcessor: hook ${e} threw and was ignored`,
220
+ s instanceof Error ? s : void 0,
221
+ { hookName: e }
222
+ );
178
223
  }
179
224
  }
180
225
  /**
181
226
  * Registers a message handler for a specific message type
182
227
  */
183
228
  registerHandler(e, t) {
184
- this.handlers.set(e, t), this.logger.debug(`Registered handler for message type: ${e}`);
229
+ this.handlers.set(e, t);
185
230
  }
186
231
  /**
187
232
  * Registers a catch-all handler invoked when no type-specific handler matches.
@@ -196,27 +241,28 @@ class R {
196
241
  * only types that pass through that filter. To handle all types, leave
197
242
  * `messageTypes` unset.
198
243
  *
199
- * Calling this method a second time silently replaces the previous default
200
- * handler (idempotent).
244
+ * Second/subsequent call emits a warning and replaces the previous handler.
201
245
  */
202
246
  registerDefaultHandler(e) {
203
- this.defaultHandler = e;
247
+ this.defaultHandler && c.warn(
248
+ "registerDefaultHandler: replacing existing default handler — previous handler discarded"
249
+ ), this.defaultHandler = e;
204
250
  }
205
251
  /**
206
252
  * Registers middleware for message processing pipeline
207
253
  */
208
254
  use(e) {
209
- this.middlewares.push(e), this.logger.debug("Registered middleware");
255
+ this.middlewares.push(e);
210
256
  }
211
257
  /**
212
258
  * Starts the processor with automatic interval processing
213
259
  */
214
260
  start() {
215
261
  if (this.isRunning) {
216
- this.logger.warn("Processor is already running");
262
+ c.warn("OutboxProcessor: processor is already running");
217
263
  return;
218
264
  }
219
- this.isRunning = !0, this.logger.info("Starting outbox processor");
265
+ this.isRunning = !0;
220
266
  const e = this.options.startupJitterMs > 0 ? Math.floor(Math.random() * this.options.startupJitterMs) : 0;
221
267
  this.scheduleProcessing(0, this.options.processingInterval + e), this.scheduleCrashRecovery();
222
268
  }
@@ -225,10 +271,10 @@ class R {
225
271
  */
226
272
  stop() {
227
273
  if (!this.isRunning) {
228
- this.logger.warn("Processor is not running");
274
+ c.warn("OutboxProcessor: processor is not running");
229
275
  return;
230
276
  }
231
- 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");
277
+ this.isRunning = !1, this.processingTimer && (clearTimeout(this.processingTimer), this.processingTimer = void 0), this.crashRecoveryTimer && (clearTimeout(this.crashRecoveryTimer), this.crashRecoveryTimer = void 0);
232
278
  }
233
279
  /**
234
280
  * Processes a single batch of pending messages.
@@ -263,45 +309,57 @@ class R {
263
309
  const e = this.options.batchSize;
264
310
  if (!this.isRunning)
265
311
  return { processed: 0, batchSize: e };
266
- const [t, s] = await d(
267
- () => this.repository.getUnprocessedMessages(
312
+ const t = this.repository.claimBatch, s = this.options.useClaimBatch === !0 && typeof t == "function";
313
+ this.options.useClaimBatch && !s && !this.warnedMissingClaimBatch && (this.warnedMissingClaimBatch = !0, c.warn(
314
+ "OutboxProcessor: useClaimBatch is enabled but the repository does not implement claimBatch() — falling back to getUnprocessedMessages (no atomic-claim protection)"
315
+ ));
316
+ const [a, r] = await g(
317
+ () => s && t ? t.call(
318
+ this.repository,
319
+ e,
320
+ this.options.priorityOrder,
321
+ this.options.messageTypes
322
+ ) : this.repository.getUnprocessedMessages(
268
323
  e,
269
324
  this.options.priorityOrder,
270
325
  this.options.messageTypes
271
326
  )
272
327
  );
273
- if (t) {
274
- const c = t instanceof Error ? t.message : String(t);
275
- return this.logger.error(`Error retrieving messages: ${c}`), { processed: 0, batchSize: e };
276
- }
277
- const r = s;
278
- if (!r || !Array.isArray(r) || r.length === 0)
279
- return this.logger.debug("No pending messages to process"), { processed: 0, batchSize: e };
280
- this.logger.info(`Processing ${r.length} messages`);
281
- const a = Date.now(), g = (await Promise.allSettled(
282
- r.map((c) => this.processMessage(c))
328
+ if (a)
329
+ return c.error(
330
+ "OutboxProcessor: error retrieving messages",
331
+ a instanceof Error ? a : void 0
332
+ ), { processed: 0, batchSize: e };
333
+ const o = r;
334
+ if (!o || !Array.isArray(o) || o.length === 0)
335
+ return { processed: 0, batchSize: e };
336
+ const h = Date.now(), m = (await Promise.allSettled(
337
+ o.map((p) => this.processMessage(p, s))
283
338
  )).filter(
284
- (c) => c.status === "rejected" || c.status === "fulfilled" && c.value === !1
339
+ (p) => p.status === "rejected" || p.status === "fulfilled" && p.value === !1
285
340
  ).length;
286
- return this.logger.info(`Completed processing batch of ${r.length} messages`), this.safelyInvokeHook(
341
+ return this.safelyInvokeHook(
287
342
  "onBatchComplete",
288
343
  () => this.options.hooks?.onBatchComplete?.({
289
- processed: r.length,
290
- failed: g,
344
+ processed: o.length,
345
+ failed: m,
291
346
  batchSize: e,
292
- durationMs: Date.now() - a
347
+ durationMs: Date.now() - h
293
348
  })
294
- ), { processed: r.length, batchSize: e };
349
+ ), { processed: o.length, batchSize: e };
295
350
  }
296
351
  /**
297
352
  * Processes a single message through the middleware pipeline.
353
+ * @param alreadyClaimed When `true`, the message was already marked
354
+ * `PROCESSING` by `IOutboxRepository.claimBatch()` — the redundant
355
+ * `updateStatus(id, PROCESSING)` call is skipped.
298
356
  * @returns `true` if the message was processed successfully, `false` if it failed.
299
357
  */
300
- async processMessage(e) {
301
- const [t] = await d(async () => {
302
- 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}`);
358
+ async processMessage(e, t = !1) {
359
+ const [s] = await g(async () => {
360
+ t || await this.repository.updateStatus(e.id, n.PROCESSING), await this.buildPipeline(e)(e), await this.repository.updateStatus(e.id, n.PROCESSED);
303
361
  });
304
- return t ? (await this.handleMessageError(e, t), !1) : !0;
362
+ return s ? (await this.handleMessageError(e, s), !1) : !0;
305
363
  }
306
364
  /**
307
365
  * Builds the middleware pipeline for message processing
@@ -310,19 +368,23 @@ class R {
310
368
  const t = this.handlers.get(e.messageType) ?? this.defaultHandler;
311
369
  if (!t)
312
370
  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}`);
314
- const s = async (r) => {
315
- await Promise.race([
316
- t.handle(r),
317
- new Promise(
318
- (a, o) => setTimeout(
319
- () => o(new Error("Message processing timeout")),
320
- this.options.messageTimeout
321
- )
322
- )
323
- ]);
371
+ const s = async (a) => {
372
+ let r;
373
+ try {
374
+ await Promise.race([
375
+ t.handle(a),
376
+ new Promise((o, h) => {
377
+ r = setTimeout(
378
+ () => h(new Error("Message processing timeout")),
379
+ this.options.messageTimeout
380
+ );
381
+ })
382
+ ]);
383
+ } finally {
384
+ r !== void 0 && clearTimeout(r);
385
+ }
324
386
  };
325
- return this.middlewares.reduceRight((r, a) => a(r), s);
387
+ return this.middlewares.reduceRight((a, r) => r(a), s);
326
388
  }
327
389
  /**
328
390
  * Handles errors during message processing
@@ -330,24 +392,30 @@ class R {
330
392
  async handleMessageError(e, t) {
331
393
  try {
332
394
  const s = await this.repository.incrementAttempt(e.id);
333
- if (this.logger.warn(`Message ${e.id} failed, attempt ${s}: ${t?.message}`), this.safelyInvokeHook(
395
+ if (c.warn(
396
+ `OutboxProcessor: message ${e.id} failed, attempt ${s}: ${y.sanitizeLogMessage(t?.message ?? String(t))}`
397
+ ), this.safelyInvokeHook(
334
398
  "onMessageFailed",
335
399
  () => this.options.hooks?.onMessageFailed?.(e, t, s)
336
400
  ), s >= this.options.maxRetries)
337
- await this.repository.updateStatus(e.id, n.FAILED, t), this.logger.error(`Message ${e.id} marked as failed after ${s} attempts`), this.safelyInvokeHook(
401
+ await this.repository.updateStatus(e.id, n.FAILED, t), c.error("OutboxProcessor: message permanently failed", t, {
402
+ messageId: e.id,
403
+ attempts: s
404
+ }), this.safelyInvokeHook(
338
405
  "onPermanentFailure",
339
406
  () => this.options.hooks?.onPermanentFailure?.(e, t)
340
407
  );
341
408
  else if (this.options.retryBackoff) {
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);
343
- await this.repository.updateStatus(e.id, n.PENDING), await this.repository.scheduleRetry(e.id, c), this.logger.info(
344
- `Message ${e.id} scheduled for retry in ${g}ms (attempt ${s})`
345
- );
409
+ const { initial: a, multiplier: r, maxDelay: o } = this.options.retryBackoff, h = Math.min(a * Math.pow(r, s - 1), o), u = new Date(Date.now() + h);
410
+ await this.repository.updateStatus(e.id, n.PENDING), await this.repository.scheduleRetry(e.id, u);
346
411
  } else
347
- await this.repository.updateStatus(e.id, n.PENDING), this.logger.info(`Message ${e.id} scheduled for retry (attempt ${s + 1})`);
412
+ await this.repository.updateStatus(e.id, n.PENDING);
348
413
  } catch (s) {
349
- const r = s instanceof Error ? s.message : String(s);
350
- this.logger.error(`Error updating message status: ${r}`);
414
+ c.error(
415
+ "OutboxProcessor: error updating message status",
416
+ s instanceof Error ? s : void 0,
417
+ { messageId: e.id }
418
+ );
351
419
  }
352
420
  }
353
421
  /**
@@ -366,8 +434,8 @@ class R {
366
434
  this.isRunning && (this.processingTimer = setTimeout(async () => {
367
435
  const s = await this.processBatch();
368
436
  if (this.options.adaptiveRepoll && s.processed > 0 && s.processed >= s.batchSize) {
369
- const a = e + 1, o = a > this.options.adaptiveRepollMaxConsecutive ? this.options.adaptiveRepollPauseMs : 0;
370
- this.scheduleProcessing(a, o);
437
+ const r = e + 1, o = r > this.options.adaptiveRepollMaxConsecutive ? this.options.adaptiveRepollPauseMs : 0;
438
+ this.scheduleProcessing(r, o);
371
439
  } else
372
440
  this.scheduleProcessing(0, this.options.processingInterval);
373
441
  }, t));
@@ -387,19 +455,17 @@ class R {
387
455
  * logged, never thrown — crash recovery must not crash the processor.
388
456
  */
389
457
  async runCrashRecovery() {
390
- const e = new Date(Date.now() - this.options.crashRecoveryThresholdMs), [t, s] = await d(() => this.repository.resetStaleProcessing(e));
458
+ const e = new Date(Date.now() - this.options.crashRecoveryThresholdMs), [t, s] = await g(() => this.repository.resetStaleProcessing(e));
391
459
  if (t) {
392
- const r = t instanceof Error ? t.message : String(t);
393
- this.logger.error(`Crash recovery failed: ${r}`);
460
+ c.error(
461
+ "OutboxProcessor: crash recovery failed",
462
+ t instanceof Error ? t : void 0
463
+ );
394
464
  return;
395
465
  }
396
- s && s > 0 && this.logger.warn(`Crash recovery reset ${s} stale PROCESSING message(s) to PENDING`);
397
- }
398
- /**
399
- * Logs messages if logging is enabled
400
- */
401
- log(e) {
402
- this.options.enableLogging && this.logger.info(e);
466
+ s && s > 0 && c.warn(
467
+ `OutboxProcessor: crash recovery reset ${s} stale PROCESSING message(s) to PENDING`
468
+ );
403
469
  }
404
470
  /**
405
471
  * Gets processor statistics
@@ -416,7 +482,7 @@ class R {
416
482
  };
417
483
  }
418
484
  }
419
- class w {
485
+ class I {
420
486
  constructor(e) {
421
487
  this.eventBus = e;
422
488
  }
@@ -432,9 +498,9 @@ class w {
432
498
  await this.eventBus.publish(s);
433
499
  }
434
500
  }
435
- class P {
501
+ class S {
436
502
  constructor(e, t = {}) {
437
- this.logger = u.create("OutboxService"), this.repository = e, this.options = {
503
+ this.repository = e, this.options = {
438
504
  defaultPriority: t.defaultPriority ?? l.NORMAL,
439
505
  defaultMetadata: t.defaultMetadata ?? {},
440
506
  autoCleanup: t.autoCleanup ?? !1,
@@ -446,38 +512,36 @@ class P {
446
512
  * Saves a message to the outbox
447
513
  */
448
514
  async saveMessage(e, t, s) {
449
- const r = h.createMessage(e, t, {
515
+ const a = d.createMessage(e, t, {
450
516
  priority: this.options.defaultPriority,
451
517
  metadata: { ...this.options.defaultMetadata, ...s?.metadata },
452
518
  ...s
453
- }), a = await this.repository.saveMessage(r);
454
- return this.logger.debug(`Saved message ${a} of type ${e}`), a;
519
+ });
520
+ return await this.repository.saveMessage(a);
455
521
  }
456
522
  /**
457
523
  * Saves multiple messages to the outbox in a batch
458
524
  */
459
525
  async saveMessages(e) {
460
526
  const t = e.map(
461
- ({ messageType: r, payload: a, options: o }) => h.createMessage(r, a, {
527
+ ({ messageType: a, payload: r, options: o }) => d.createMessage(a, r, {
462
528
  priority: this.options.defaultPriority,
463
529
  metadata: { ...this.options.defaultMetadata, ...o?.metadata },
464
530
  ...o
465
531
  })
466
- ), s = await this.repository.saveBatch(t);
467
- return this.logger.debug(`Saved batch of ${e.length} messages`), s;
532
+ );
533
+ return await this.repository.saveBatch(t);
468
534
  }
469
535
  /**
470
536
  * Schedules a message for delayed processing
471
537
  */
472
- async scheduleMessage(e, t, s, r) {
473
- const a = h.createDelayedMessage(e, t, s, {
538
+ async scheduleMessage(e, t, s, a) {
539
+ const r = d.createDelayedMessage(e, t, s, {
474
540
  priority: this.options.defaultPriority,
475
- metadata: { ...this.options.defaultMetadata, ...r?.metadata },
476
- ...r
477
- }), o = await this.repository.scheduleMessage(a, a.processAfter);
478
- return this.logger.debug(
479
- `Scheduled message ${o} of type ${e} for processing in ${s}ms`
480
- ), o;
541
+ metadata: { ...this.options.defaultMetadata, ...a?.metadata },
542
+ ...a
543
+ });
544
+ return await this.repository.scheduleMessage(r, r.processAfter);
481
545
  }
482
546
  /**
483
547
  * Saves a high priority message to the outbox
@@ -501,7 +565,7 @@ class P {
501
565
  * Converts a domain event to an outbox message and saves it
502
566
  */
503
567
  async saveDomainEvent(e, t) {
504
- const s = h.createFromIntegrationEvent(
568
+ const s = d.createFromIntegrationEvent(
505
569
  {
506
570
  eventName: e.eventName,
507
571
  payload: e,
@@ -516,8 +580,8 @@ class P {
516
580
  metadata: { ...this.options.defaultMetadata, ...t?.metadata },
517
581
  ...t
518
582
  }
519
- ), r = await this.repository.saveMessage(s);
520
- return this.logger.debug(`Saved domain event ${e.eventName} as message ${r}`), r;
583
+ );
584
+ return await this.repository.saveMessage(s);
521
585
  }
522
586
  /**
523
587
  * Gets a message by ID
@@ -535,25 +599,23 @@ class P {
535
599
  * Retries a failed message by resetting it to pending status
536
600
  */
537
601
  async retryMessage(e) {
538
- await this.repository.updateStatus(e, n.PENDING), this.logger.info(`Reset message ${e} to pending for retry`);
602
+ await this.repository.updateStatus(e, n.PENDING);
539
603
  }
540
604
  /**
541
605
  * Retries multiple failed messages
542
606
  */
543
607
  async retryMessages(e) {
544
- await this.repository.updateStatusBatch(e, n.PENDING), this.logger.info(`Reset ${e.length} messages to pending for retry`);
608
+ await this.repository.updateStatusBatch(e, n.PENDING);
545
609
  }
546
610
  /**
547
611
  * Cleans up old processed messages
548
612
  */
549
613
  async cleanup(e) {
550
614
  const t = e ?? this.options.cleanupAfterDays, s = /* @__PURE__ */ new Date();
551
- s.setDate(s.getDate() - t);
552
- const r = await this.repository.deleteByStatusAndAge(
615
+ return s.setDate(s.getDate() - t), await this.repository.deleteByStatusAndAge(
553
616
  s,
554
617
  n.PROCESSED
555
618
  );
556
- return this.logger.info(`Cleaned up ${r} processed messages older than ${t} days`), r;
557
619
  }
558
620
  /**
559
621
  * Gets outbox statistics
@@ -575,20 +637,14 @@ class P {
575
637
  // Would need separate query
576
638
  };
577
639
  }
578
- /**
579
- * Logs messages if logging is enabled
580
- */
581
- log(e) {
582
- this.options.enableLogging && this.logger.info(e);
583
- }
584
640
  }
585
641
  export {
586
- w as EventBusOutboxHandler,
587
- M as IOutboxRepository,
642
+ I as EventBusOutboxHandler,
643
+ w as IOutboxRepository,
588
644
  l as MessagePriority,
589
645
  n as MessageStatus,
590
- h as OutboxMessageFactory,
591
- R as OutboxProcessor,
592
- P as OutboxService,
593
- v as comparePriority
646
+ d as OutboxMessageFactory,
647
+ P as OutboxProcessor,
648
+ S as OutboxService,
649
+ R as comparePriority
594
650
  };
@@ -1,4 +1,4 @@
1
- import { IEventBus } from '../../../contracts/src/index.ts';
1
+ import { IEventBus } from '@vytches/ddd-contracts';
2
2
  import { IOutboxMessage, IOutboxMessageHandler, OutboxMiddleware, MessagePriority } from './outbox-interfaces';
3
3
  import { IOutboxRepository } from './outbox-repository.interface';
4
4
  import { OutboxProcessorHooks } from './outbox-hooks';
@@ -80,6 +80,30 @@ export interface OutboxProcessorOptions {
80
80
  * logged and never breaks the processing loop. Omit to disable.
81
81
  */
82
82
  hooks?: OutboxProcessorHooks;
83
+ /**
84
+ * Opt-in: when `true`, `processBatch` calls `IOutboxRepository.claimBatch()`
85
+ * instead of `getUnprocessedMessages()`, and `processMessage` skips its own
86
+ * (now redundant) `updateStatus(id, PROCESSING)` call for messages obtained
87
+ * this way — the repository's `claimBatch` is responsible for the
88
+ * PROCESSING mark.
89
+ *
90
+ * **Correctness depends on the repository.** `claimBatch` is an optional
91
+ * method on `IOutboxRepository` with a non-atomic default implementation
92
+ * (read then mark, two steps). Setting `useClaimBatch: true` against a
93
+ * repository that has NOT overridden `claimBatch` with a real atomic
94
+ * claim (e.g. `SELECT ... FOR UPDATE SKIP LOCKED`) provides **no**
95
+ * additional safety over the default `getUnprocessedMessages` path — it
96
+ * only prevents duplicate dispatch when the repository's `claimBatch` is
97
+ * genuinely atomic.
98
+ *
99
+ * If the repository does not implement `claimBatch` at all (the method is
100
+ * `undefined`, e.g. a hand-rolled repository predating this option), the
101
+ * processor logs a warning once and falls back to
102
+ * `getUnprocessedMessages` for that batch.
103
+ *
104
+ * Defaults to `false` — no behavior change for existing consumers (AC#5).
105
+ */
106
+ useClaimBatch?: boolean;
83
107
  }
84
108
  export declare class OutboxProcessor {
85
109
  private readonly repository;
@@ -90,7 +114,8 @@ export declare class OutboxProcessor {
90
114
  private isRunning;
91
115
  private processingTimer?;
92
116
  private crashRecoveryTimer?;
93
- private readonly logger;
117
+ /** Guards the one-time fallback warning when `useClaimBatch` is set but the repository has no `claimBatch`. */
118
+ private warnedMissingClaimBatch;
94
119
  constructor(repository: IOutboxRepository, options?: OutboxProcessorOptions);
95
120
  /**
96
121
  * Invokes an observability hook in isolation. A throwing hook is logged and
@@ -114,8 +139,7 @@ export declare class OutboxProcessor {
114
139
  * only types that pass through that filter. To handle all types, leave
115
140
  * `messageTypes` unset.
116
141
  *
117
- * Calling this method a second time silently replaces the previous default
118
- * handler (idempotent).
142
+ * Second/subsequent call emits a warning and replaces the previous handler.
119
143
  */
120
144
  registerDefaultHandler(handler: IOutboxMessageHandler): void;
121
145
  /**
@@ -165,6 +189,9 @@ export declare class OutboxProcessor {
165
189
  }>;
166
190
  /**
167
191
  * Processes a single message through the middleware pipeline.
192
+ * @param alreadyClaimed When `true`, the message was already marked
193
+ * `PROCESSING` by `IOutboxRepository.claimBatch()` — the redundant
194
+ * `updateStatus(id, PROCESSING)` call is skipped.
168
195
  * @returns `true` if the message was processed successfully, `false` if it failed.
169
196
  */
170
197
  private processMessage;
@@ -200,10 +227,6 @@ export declare class OutboxProcessor {
200
227
  * logged, never thrown — crash recovery must not crash the processor.
201
228
  */
202
229
  private runCrashRecovery;
203
- /**
204
- * Logs messages if logging is enabled
205
- */
206
- private log;
207
230
  /**
208
231
  * Gets processor statistics
209
232
  */
@@ -1,4 +1,4 @@
1
- import { IOutboxMessage, MessagePriority, MessageStatus } from './outbox-interfaces';
1
+ import { MessageStatus, IOutboxMessage, MessagePriority } from './outbox-interfaces';
2
2
  export declare abstract class IOutboxRepository {
3
3
  /**
4
4
  * Saves a message to the outbox
@@ -15,6 +15,23 @@ export declare abstract class IOutboxRepository {
15
15
  /**
16
16
  * Returns unprocessed messages up to the given limit.
17
17
  *
18
+ * **Not a claim.** This is a pure read — it does not mark returned messages
19
+ * as `PROCESSING` or otherwise reserve them. `OutboxProcessor` calls
20
+ * `updateStatus(id, PROCESSING)` for each message *after* this read
21
+ * returns, which means there is a window between "read" and "claimed"
22
+ * during which two concurrent processors (or two calls racing on the same
23
+ * processor) can both read the same PENDING message and both dispatch it.
24
+ * Multi-worker deployments that need "exactly one processor claims each
25
+ * message" should implement {@link claimBatch} with an atomic
26
+ * read+mark (e.g. `SELECT ... FOR UPDATE SKIP LOCKED` or a single
27
+ * `UPDATE ... WHERE status='PENDING' RETURNING *` statement / compare-
28
+ * and-swap on `status`) rather than relying on this method plus a
29
+ * follow-up `updateStatus`.
30
+ *
31
+ * Regardless of claim strategy, handlers should be idempotent to side
32
+ * effects — transient failures or duplicate-claim races may cause a
33
+ * handler to run more than once.
34
+ *
18
35
  * @param limit - Maximum number of messages to return.
19
36
  * @param priorityOrder - Ordered array of MessagePriority values defining
20
37
  * processing order (first element = highest priority, processed first).
@@ -28,6 +45,41 @@ export declare abstract class IOutboxRepository {
28
45
  * returned. When absent, all message types are returned.
29
46
  */
30
47
  abstract getUnprocessedMessages(limit?: number, priorityOrder?: MessagePriority[], messageTypes?: string[]): Promise<IOutboxMessage[]>;
48
+ /**
49
+ * Atomically claims a batch of unprocessed messages: reads PENDING
50
+ * messages *and* marks them `PROCESSING` in the same operation, so two
51
+ * concurrent callers (two processor instances, or two racing calls on one
52
+ * instance) never both receive the same message.
53
+ *
54
+ * **Optional, and non-atomic by default.** This method is declared with
55
+ * `?` — it is NOT part of the required abstract contract, so existing
56
+ * `IOutboxRepository` subclasses keep compiling unmodified (AC#5). The
57
+ * default implementation provided here is a convenience shim only: it
58
+ * calls {@link getUnprocessedMessages} and then `updateStatusBatch(...,
59
+ * PROCESSING)` as two separate steps. **That is NOT atomic** — it has the
60
+ * exact same read-then-mark race window as calling
61
+ * `getUnprocessedMessages` + `updateStatus` manually, and is safe only for
62
+ * a single-worker deployment. Override `claimBatch` in a concrete
63
+ * repository (e.g. backed by `SELECT ... FOR UPDATE SKIP LOCKED`, or a
64
+ * single atomic `UPDATE ... WHERE status='PENDING' LIMIT n RETURNING *`)
65
+ * to get real multi-worker/multi-processor safety.
66
+ *
67
+ * `OutboxProcessor` only calls this method when
68
+ * `OutboxProcessorOptions.useClaimBatch` is explicitly set to `true`
69
+ * (opt-in, default `false`) — see AC#1/AC#2. When enabled, the processor
70
+ * skips its own redundant `updateStatus(id, PROCESSING)` call for messages
71
+ * returned by `claimBatch`, since claiming already performed that mark.
72
+ *
73
+ * Handlers should be idempotent to side effects regardless — transient
74
+ * failures or duplicate-claim races (e.g. with the non-atomic default
75
+ * implementation) may still cause a handler to run more than once.
76
+ *
77
+ * @param limit - Maximum number of messages to claim.
78
+ * @param priorityOrder - See {@link getUnprocessedMessages}.
79
+ * @param messageTypes - See {@link getUnprocessedMessages}.
80
+ * @returns Messages that were claimed (already marked `PROCESSING`).
81
+ */
82
+ claimBatch?(limit?: number, priorityOrder?: MessagePriority[], messageTypes?: string[]): Promise<IOutboxMessage[]>;
31
83
  /**
32
84
  * Gets specific message by ID
33
85
  * @param id Message ID
@@ -1,4 +1,4 @@
1
- import { IDomainEvent } from '../../../contracts/src/index.ts';
1
+ import { IDomainEvent } from '@vytches/ddd-contracts';
2
2
  import { IOutboxMessage, OutboxMessageOptions, MessagePriority } from './outbox-interfaces';
3
3
  import { IOutboxRepository } from './outbox-repository.interface';
4
4
  export interface OutboxServiceOptions {
@@ -16,7 +16,6 @@ export interface OutboxServiceOptions {
16
16
  export declare class OutboxService {
17
17
  private readonly repository;
18
18
  private readonly options;
19
- private readonly logger;
20
19
  constructor(repository: IOutboxRepository, options?: OutboxServiceOptions);
21
20
  /**
22
21
  * Saves a message to the outbox
@@ -75,8 +74,4 @@ export declare class OutboxService {
75
74
  totalProcessed: number;
76
75
  totalFailed: number;
77
76
  }>;
78
- /**
79
- * Logs messages if logging is enabled
80
- */
81
- private log;
82
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vytches/ddd-messaging",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Outbox pattern and reliable message delivery",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -17,7 +17,8 @@
17
17
  "files": [
18
18
  "LLMGUIDE.md",
19
19
  "README.md",
20
- "dist"
20
+ "dist",
21
+ "LICENSE"
21
22
  ],
22
23
  "keywords": [
23
24
  "ddd",
@@ -36,7 +37,7 @@
36
37
  "typescript": "^5.0.0",
37
38
  "vite": "^7.3.2",
38
39
  "vite-plugin-dts": "^4.0.0",
39
- "vitest": "^4.0.0"
40
+ "vitest": "^4.1.0"
40
41
  },
41
42
  "nx": {
42
43
  "tags": [
@@ -50,7 +51,9 @@
50
51
  "access": "public"
51
52
  },
52
53
  "dependencies": {
53
- "@vytches/ddd-events": "0.30.0"
54
+ "@vytches/ddd-events": "0.31.0",
55
+ "@vytches/ddd-contracts": "0.31.0",
56
+ "@vytches/ddd-utils": "0.31.0"
54
57
  },
55
58
  "scripts": {
56
59
  "build": "vite build",