@chidchanun/bcp 0.2.11 → 0.2.13

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.
@@ -0,0 +1,1416 @@
1
+ import {
2
+ randomUUID,
3
+ } from "node:crypto";
4
+
5
+ import type {
6
+ BcpDatabase,
7
+ DatabaseDriver,
8
+ TransactionDatabase,
9
+ } from "../../client/src/database.js";
10
+ import type {
11
+ BackgroundJobQueue,
12
+ } from "./jobs.js";
13
+
14
+ export type OutboxEventState =
15
+ | "pending"
16
+ | "processing"
17
+ | "published"
18
+ | "failed";
19
+
20
+ export interface OutboxEventRecord<TPayload = unknown> {
21
+ id: string;
22
+ type: string;
23
+ payload: TPayload;
24
+ metadata: Record<string, unknown>;
25
+ state: OutboxEventState;
26
+ attempts: number;
27
+ maxAttempts: number;
28
+ createdAt: number;
29
+ availableAt: number;
30
+ processingAt?: number;
31
+ publishedAt?: number;
32
+ failedAt?: number;
33
+ error?: string;
34
+ correlationId?: string;
35
+ causationId?: string;
36
+ aggregateId?: string;
37
+ leaseOwner?: string;
38
+ leaseUntil?: number;
39
+ }
40
+
41
+ export interface PublishOutboxEventOptions {
42
+ id?: string;
43
+ metadata?: Record<string, unknown>;
44
+ correlationId?: string;
45
+ causationId?: string;
46
+ aggregateId?: string;
47
+ delayMs?: number;
48
+ maxAttempts?: number;
49
+ }
50
+
51
+ export interface ClaimOutboxEventsOptions {
52
+ ownerId: string;
53
+ leaseMs: number;
54
+ limit?: number;
55
+ }
56
+
57
+ export interface FailOutboxEventOptions {
58
+ ownerId: string;
59
+ error: string;
60
+ failedAt: number;
61
+ retryAt?: number;
62
+ }
63
+
64
+ export interface CleanupOutboxOptions {
65
+ before: number;
66
+ }
67
+
68
+ export interface OutboxStats {
69
+ total: number;
70
+ pending: number;
71
+ processing: number;
72
+ published: number;
73
+ failed: number;
74
+ }
75
+
76
+ export interface OutboxStore {
77
+ append<TPayload = unknown>(
78
+ transaction: TransactionDatabase,
79
+ event: OutboxEventRecord<TPayload>
80
+ ): Promise<void>;
81
+ claimDue(
82
+ now: number,
83
+ options: ClaimOutboxEventsOptions
84
+ ): Promise<OutboxEventRecord[]>;
85
+ markPublished(
86
+ id: string,
87
+ publishedAt: number,
88
+ ownerId: string
89
+ ): Promise<boolean>;
90
+ markFailed(
91
+ id: string,
92
+ options: FailOutboxEventOptions
93
+ ): Promise<boolean>;
94
+ recoverStale(
95
+ now: number,
96
+ limit?: number
97
+ ): Promise<number>;
98
+ get<TPayload = unknown>(
99
+ id: string
100
+ ): Promise<OutboxEventRecord<TPayload> | null>;
101
+ list(): Promise<OutboxEventRecord[]>;
102
+ cleanup(
103
+ options: CleanupOutboxOptions
104
+ ): Promise<number>;
105
+ stats(): Promise<OutboxStats>;
106
+ close?(): Promise<void>;
107
+ }
108
+
109
+ export interface MemoryOutboxStore
110
+ extends OutboxStore {
111
+ clear(): void;
112
+ }
113
+
114
+ export interface TransactionalOutboxOptions {
115
+ store: OutboxStore;
116
+ now?: () => number;
117
+ idFactory?: () => string;
118
+ defaultMaxAttempts?: number;
119
+ }
120
+
121
+ export interface TransactionalOutbox {
122
+ readonly store: OutboxStore;
123
+ publish<TPayload = unknown>(
124
+ transaction: TransactionDatabase,
125
+ type: string,
126
+ payload: TPayload,
127
+ options?: PublishOutboxEventOptions
128
+ ): Promise<OutboxEventRecord<TPayload>>;
129
+ }
130
+
131
+ export interface OutboxEventHandlerContext<TPayload = unknown> {
132
+ event: Readonly<OutboxEventRecord<TPayload>>;
133
+ payload: TPayload;
134
+ }
135
+
136
+ export type OutboxEventHandler<TPayload = unknown> = (
137
+ context: OutboxEventHandlerContext<TPayload>
138
+ ) => unknown | Promise<unknown>;
139
+
140
+ export interface EventBus {
141
+ on<TPayload = unknown>(
142
+ type: string,
143
+ handler: OutboxEventHandler<TPayload>
144
+ ): () => void;
145
+ emit(
146
+ event: OutboxEventRecord
147
+ ): Promise<void>;
148
+ clear(): void;
149
+ }
150
+
151
+ export type OutboxRetryDelay =
152
+ | number
153
+ | ((attempt: number) => number);
154
+
155
+ export interface OutboxDispatcherOptions {
156
+ store: OutboxStore;
157
+ queue?: BackgroundJobQueue;
158
+ publish?: (
159
+ event: OutboxEventRecord
160
+ ) => unknown | Promise<unknown>;
161
+ eventBus?: EventBus;
162
+ ownerId?: string;
163
+ leaseMs?: number;
164
+ batchSize?: number;
165
+ pollIntervalMs?: number;
166
+ retryDelayMs?: OutboxRetryDelay;
167
+ queueNamePrefix?: string;
168
+ now?: () => number;
169
+ onError?: (
170
+ error: unknown,
171
+ event?: OutboxEventRecord
172
+ ) => void | Promise<void>;
173
+ }
174
+
175
+ export interface OutboxDispatcherRunner {
176
+ readonly running: boolean;
177
+ stop(): Promise<void>;
178
+ }
179
+
180
+ export interface OutboxDispatcher {
181
+ readonly store: OutboxStore;
182
+ readonly ownerId: string;
183
+ dispatchBatch(): Promise<number>;
184
+ recoverStale(limit?: number): Promise<number>;
185
+ start(): OutboxDispatcherRunner;
186
+ close(): Promise<void>;
187
+ }
188
+
189
+ export interface SqlOutboxStoreOptions {
190
+ database: Pick<
191
+ BcpDatabase,
192
+ "query" | "execute" | "transaction"
193
+ >;
194
+ driver: DatabaseDriver;
195
+ tableName?: string;
196
+ }
197
+
198
+ export interface SqlOutboxStore
199
+ extends OutboxStore {
200
+ readonly driver: DatabaseDriver;
201
+ readonly tableName: string;
202
+ }
203
+
204
+ export function createMemoryOutboxStore():
205
+ MemoryOutboxStore {
206
+ const events =
207
+ new Map<string, OutboxEventRecord>();
208
+
209
+ return {
210
+ async append(_transaction, event) {
211
+ if (events.has(event.id)) {
212
+ throw new Error(
213
+ `BCP Events: outbox event id "${event.id}" already exists.`
214
+ );
215
+ }
216
+ events.set(
217
+ event.id,
218
+ cloneEvent(event)
219
+ );
220
+ },
221
+
222
+ async claimDue(now, options) {
223
+ const ownerId =
224
+ normalizeText(
225
+ options.ownerId,
226
+ "dispatcher owner id"
227
+ );
228
+ const leaseMs =
229
+ positiveInteger(
230
+ options.leaseMs,
231
+ "leaseMs"
232
+ );
233
+ const limit =
234
+ positiveInteger(
235
+ options.limit ?? 100,
236
+ "batch limit"
237
+ );
238
+
239
+ recoverMemoryStale(
240
+ events,
241
+ now,
242
+ Number.MAX_SAFE_INTEGER
243
+ );
244
+
245
+ const due =
246
+ Array.from(events.values())
247
+ .filter(event =>
248
+ event.state === "pending" &&
249
+ event.availableAt <= now &&
250
+ event.attempts < event.maxAttempts
251
+ )
252
+ .sort(compareEvents)
253
+ .slice(0, limit);
254
+
255
+ for (const event of due) {
256
+ event.state = "processing";
257
+ event.attempts += 1;
258
+ event.processingAt = now;
259
+ event.leaseOwner = ownerId;
260
+ event.leaseUntil = now + leaseMs;
261
+ event.error = undefined;
262
+ }
263
+
264
+ return due.map(cloneEvent);
265
+ },
266
+
267
+ async markPublished(id, publishedAt, ownerId) {
268
+ const event = events.get(id);
269
+ if (
270
+ !event ||
271
+ event.state !== "processing" ||
272
+ event.leaseOwner !== ownerId
273
+ ) {
274
+ return false;
275
+ }
276
+ event.state = "published";
277
+ event.publishedAt = publishedAt;
278
+ event.failedAt = undefined;
279
+ event.error = undefined;
280
+ clearLease(event);
281
+ return true;
282
+ },
283
+
284
+ async markFailed(id, options) {
285
+ const event = events.get(id);
286
+ if (
287
+ !event ||
288
+ event.state !== "processing" ||
289
+ event.leaseOwner !== options.ownerId
290
+ ) {
291
+ return false;
292
+ }
293
+
294
+ event.error = options.error;
295
+ clearLease(event);
296
+
297
+ if (
298
+ options.retryAt !== undefined &&
299
+ event.attempts < event.maxAttempts
300
+ ) {
301
+ event.state = "pending";
302
+ event.availableAt = options.retryAt;
303
+ event.processingAt = undefined;
304
+ return true;
305
+ }
306
+
307
+ event.state = "failed";
308
+ event.failedAt = options.failedAt;
309
+ return true;
310
+ },
311
+
312
+ async recoverStale(now, limit = 100) {
313
+ return recoverMemoryStale(
314
+ events,
315
+ now,
316
+ positiveInteger(limit, "recovery limit")
317
+ );
318
+ },
319
+
320
+ async get(id) {
321
+ const event = events.get(id);
322
+ return event
323
+ ? cloneEvent(event) as OutboxEventRecord<any>
324
+ : null;
325
+ },
326
+
327
+ async list() {
328
+ return Array.from(events.values())
329
+ .map(cloneEvent)
330
+ .sort(compareEvents);
331
+ },
332
+
333
+ async cleanup(options) {
334
+ finiteNumber(
335
+ options.before,
336
+ "cleanup before"
337
+ );
338
+ let removed = 0;
339
+ for (const [id, event] of events) {
340
+ const terminalAt =
341
+ event.publishedAt ??
342
+ event.failedAt;
343
+ if (
344
+ (event.state === "published" || event.state === "failed") &&
345
+ terminalAt !== undefined &&
346
+ terminalAt <= options.before
347
+ ) {
348
+ events.delete(id);
349
+ removed += 1;
350
+ }
351
+ }
352
+ return removed;
353
+ },
354
+
355
+ async stats() {
356
+ return calculateStats(
357
+ Array.from(events.values())
358
+ );
359
+ },
360
+
361
+ clear() {
362
+ events.clear();
363
+ },
364
+ };
365
+ }
366
+
367
+ export function createTransactionalOutbox(
368
+ options: TransactionalOutboxOptions
369
+ ): TransactionalOutbox {
370
+ if (!options?.store) {
371
+ throw new TypeError(
372
+ "BCP Events: transactional outbox requires a store."
373
+ );
374
+ }
375
+
376
+ const store = options.store;
377
+ const now = options.now ?? Date.now;
378
+ const idFactory = options.idFactory ?? randomUUID;
379
+ const defaultMaxAttempts =
380
+ positiveInteger(
381
+ options.defaultMaxAttempts ?? 5,
382
+ "defaultMaxAttempts"
383
+ );
384
+
385
+ return {
386
+ store,
387
+
388
+ async publish(
389
+ transaction,
390
+ rawType,
391
+ payload,
392
+ publishOptions = {}
393
+ ) {
394
+ assertTransaction(transaction);
395
+ const type =
396
+ normalizeText(
397
+ rawType,
398
+ "event type"
399
+ );
400
+ const createdAt = now();
401
+ const delayMs =
402
+ nonNegativeNumber(
403
+ publishOptions.delayMs ?? 0,
404
+ "delayMs"
405
+ );
406
+ const event:
407
+ OutboxEventRecord<typeof payload> = {
408
+ id: normalizeText(
409
+ publishOptions.id ?? idFactory(),
410
+ "event id"
411
+ ),
412
+ type,
413
+ payload,
414
+ metadata: {
415
+ ...(publishOptions.metadata ?? {}),
416
+ },
417
+ state: "pending",
418
+ attempts: 0,
419
+ maxAttempts: positiveInteger(
420
+ publishOptions.maxAttempts ?? defaultMaxAttempts,
421
+ "maxAttempts"
422
+ ),
423
+ createdAt,
424
+ availableAt: createdAt + delayMs,
425
+ correlationId:
426
+ optionalText(
427
+ publishOptions.correlationId
428
+ ),
429
+ causationId:
430
+ optionalText(
431
+ publishOptions.causationId
432
+ ),
433
+ aggregateId:
434
+ optionalText(
435
+ publishOptions.aggregateId
436
+ ),
437
+ };
438
+
439
+ await store.append(
440
+ transaction,
441
+ event
442
+ );
443
+
444
+ return cloneEvent(event);
445
+ },
446
+ };
447
+ }
448
+
449
+ export function createEventBus(): EventBus {
450
+ const handlers =
451
+ new Map<
452
+ string,
453
+ Set<OutboxEventHandler<any>>
454
+ >();
455
+
456
+ return {
457
+ on(type, handler) {
458
+ const normalizedType =
459
+ normalizeText(type, "event type");
460
+ if (typeof handler !== "function") {
461
+ throw new TypeError(
462
+ "BCP Events: event handler must be a function."
463
+ );
464
+ }
465
+ let group = handlers.get(normalizedType);
466
+ if (!group) {
467
+ group = new Set();
468
+ handlers.set(normalizedType, group);
469
+ }
470
+ group.add(handler);
471
+ return () => {
472
+ group?.delete(handler);
473
+ if (group?.size === 0) {
474
+ handlers.delete(normalizedType);
475
+ }
476
+ };
477
+ },
478
+
479
+ async emit(event) {
480
+ const group = handlers.get(event.type);
481
+ if (!group || group.size === 0) {
482
+ return;
483
+ }
484
+ for (const handler of group) {
485
+ await handler({
486
+ event: cloneEvent(event),
487
+ payload: event.payload,
488
+ });
489
+ }
490
+ },
491
+
492
+ clear() {
493
+ handlers.clear();
494
+ },
495
+ };
496
+ }
497
+
498
+ export function createOutboxDispatcher(
499
+ options: OutboxDispatcherOptions
500
+ ): OutboxDispatcher {
501
+ if (!options?.store) {
502
+ throw new TypeError(
503
+ "BCP Events: dispatcher requires an outbox store."
504
+ );
505
+ }
506
+ if (
507
+ !options.queue &&
508
+ !options.publish &&
509
+ !options.eventBus
510
+ ) {
511
+ throw new TypeError(
512
+ "BCP Events: dispatcher requires queue, publish or eventBus delivery."
513
+ );
514
+ }
515
+
516
+ const store = options.store;
517
+ const queue = options.queue;
518
+ const eventBus = options.eventBus;
519
+ const publish = options.publish;
520
+ const ownerId =
521
+ normalizeText(
522
+ options.ownerId ??
523
+ `outbox-${randomUUID()}`,
524
+ "dispatcher owner id"
525
+ );
526
+ const leaseMs =
527
+ positiveInteger(
528
+ options.leaseMs ?? 30_000,
529
+ "leaseMs"
530
+ );
531
+ const batchSize =
532
+ positiveInteger(
533
+ options.batchSize ?? 100,
534
+ "batchSize"
535
+ );
536
+ const pollIntervalMs =
537
+ nonNegativeNumber(
538
+ options.pollIntervalMs ?? 1_000,
539
+ "pollIntervalMs"
540
+ );
541
+ const retryDelay =
542
+ options.retryDelayMs ??
543
+ ((attempt: number) =>
544
+ Math.min(
545
+ 60_000,
546
+ 1_000 * Math.pow(2, Math.max(0, attempt - 1))
547
+ ));
548
+ const queueNamePrefix =
549
+ String(
550
+ options.queueNamePrefix ?? "event."
551
+ );
552
+ const now = options.now ?? Date.now;
553
+ const runners =
554
+ new Set<OutboxDispatcherRunner>();
555
+
556
+ const dispatcher: OutboxDispatcher = {
557
+ store,
558
+ ownerId,
559
+
560
+ async dispatchBatch() {
561
+ await store.recoverStale(
562
+ now(),
563
+ batchSize
564
+ );
565
+ const events =
566
+ await store.claimDue(
567
+ now(),
568
+ {
569
+ ownerId,
570
+ leaseMs,
571
+ limit: batchSize,
572
+ }
573
+ );
574
+
575
+ let publishedCount = 0;
576
+
577
+ for (const event of events) {
578
+ try {
579
+ if (queue) {
580
+ await queue.enqueue(
581
+ `${queueNamePrefix}${event.type}`,
582
+ createEventEnvelope(event),
583
+ {
584
+ id: `outbox:${event.id}`,
585
+ maxAttempts: 1,
586
+ }
587
+ );
588
+ }
589
+ if (eventBus) {
590
+ await eventBus.emit(event);
591
+ }
592
+ if (publish) {
593
+ await publish(event);
594
+ }
595
+
596
+ const marked =
597
+ await store.markPublished(
598
+ event.id,
599
+ now(),
600
+ ownerId
601
+ );
602
+ if (marked) {
603
+ publishedCount += 1;
604
+ }
605
+ } catch (error) {
606
+ const failedAt = now();
607
+ const retryAt =
608
+ event.attempts < event.maxAttempts
609
+ ? failedAt + resolveRetryDelay(
610
+ retryDelay,
611
+ event.attempts
612
+ )
613
+ : undefined;
614
+ await store.markFailed(
615
+ event.id,
616
+ {
617
+ ownerId,
618
+ error: formatError(error),
619
+ failedAt,
620
+ retryAt,
621
+ }
622
+ );
623
+ if (options.onError) {
624
+ await options.onError(
625
+ error,
626
+ cloneEvent(event)
627
+ );
628
+ }
629
+ }
630
+ }
631
+
632
+ return publishedCount;
633
+ },
634
+
635
+ recoverStale(limit = batchSize) {
636
+ return store.recoverStale(
637
+ now(),
638
+ positiveInteger(
639
+ limit,
640
+ "recovery limit"
641
+ )
642
+ );
643
+ },
644
+
645
+ start() {
646
+ let running = true;
647
+ let stopPromise: Promise<void> | null = null;
648
+ const controller = new AbortController();
649
+ const loop = async () => {
650
+ while (
651
+ running &&
652
+ !controller.signal.aborted
653
+ ) {
654
+ try {
655
+ await dispatcher.dispatchBatch();
656
+ } catch (error) {
657
+ if (options.onError) {
658
+ await options.onError(error);
659
+ }
660
+ }
661
+ if (
662
+ running &&
663
+ !controller.signal.aborted
664
+ ) {
665
+ await sleep(
666
+ pollIntervalMs,
667
+ controller.signal
668
+ );
669
+ }
670
+ }
671
+ };
672
+ const loopPromise = loop();
673
+
674
+ const runner: OutboxDispatcherRunner = {
675
+ get running() {
676
+ return running;
677
+ },
678
+ stop() {
679
+ if (!stopPromise) {
680
+ running = false;
681
+ controller.abort();
682
+ stopPromise =
683
+ loopPromise.catch(error => {
684
+ if (!isAbortError(error)) {
685
+ throw error;
686
+ }
687
+ }).then(() => {
688
+ runners.delete(runner);
689
+ });
690
+ }
691
+ return stopPromise;
692
+ },
693
+ };
694
+ runners.add(runner);
695
+ return runner;
696
+ },
697
+
698
+ async close() {
699
+ await Promise.all(
700
+ Array.from(
701
+ runners,
702
+ runner => runner.stop()
703
+ )
704
+ );
705
+ await store.close?.();
706
+ },
707
+ };
708
+
709
+ return dispatcher;
710
+ }
711
+
712
+ export function createSqlOutboxStore(
713
+ options: SqlOutboxStoreOptions
714
+ ): SqlOutboxStore {
715
+ if (!options?.database) {
716
+ throw new TypeError(
717
+ "BCP Events: SQL outbox store requires a database."
718
+ );
719
+ }
720
+ const database = options.database;
721
+ const driver = normalizeDriver(options.driver);
722
+ const tableName =
723
+ normalizeIdentifier(
724
+ options.tableName ?? "bcp_outbox_events"
725
+ );
726
+
727
+ return {
728
+ driver,
729
+ tableName,
730
+
731
+ async append(transaction, event) {
732
+ assertTransaction(transaction);
733
+ const values = [
734
+ event.id,
735
+ event.type,
736
+ JSON.stringify(event.payload),
737
+ JSON.stringify(event.metadata),
738
+ event.state,
739
+ event.attempts,
740
+ event.maxAttempts,
741
+ event.createdAt,
742
+ event.availableAt,
743
+ event.correlationId ?? null,
744
+ event.causationId ?? null,
745
+ event.aggregateId ?? null,
746
+ ];
747
+ await transaction.execute(
748
+ `INSERT INTO ${tableName} (` +
749
+ "id, event_type, payload_json, metadata_json, state, attempts, max_attempts, created_at, available_at, correlation_id, causation_id, aggregate_id" +
750
+ `) VALUES (${placeholders(driver, values.length)})`,
751
+ values
752
+ );
753
+ },
754
+
755
+ async claimDue(now, claimOptions) {
756
+ const ownerId =
757
+ normalizeText(
758
+ claimOptions.ownerId,
759
+ "dispatcher owner id"
760
+ );
761
+ const leaseMs =
762
+ positiveInteger(
763
+ claimOptions.leaseMs,
764
+ "leaseMs"
765
+ );
766
+ const limit =
767
+ positiveInteger(
768
+ claimOptions.limit ?? 100,
769
+ "batch limit"
770
+ );
771
+
772
+ return database.transaction(
773
+ async transaction => {
774
+ await recoverSqlStale(
775
+ transaction,
776
+ driver,
777
+ tableName,
778
+ now
779
+ );
780
+
781
+ const candidates =
782
+ await transaction.query<Record<string, unknown>[]>(
783
+ `SELECT * FROM ${tableName} ` +
784
+ "WHERE state = " + bind(driver, 1) +
785
+ " AND available_at <= " + bind(driver, 2) +
786
+ " AND attempts < max_attempts " +
787
+ "ORDER BY available_at ASC, created_at ASC, id ASC " +
788
+ `LIMIT ${limit}` +
789
+ (driver === "sqlite"
790
+ ? ""
791
+ : " FOR UPDATE SKIP LOCKED"),
792
+ ["pending", now]
793
+ );
794
+
795
+ const claimed:
796
+ OutboxEventRecord[] = [];
797
+ for (const row of candidates) {
798
+ const id = String(row.id ?? "");
799
+ if (!id) {
800
+ continue;
801
+ }
802
+ const updateValues = [
803
+ "processing",
804
+ now,
805
+ ownerId,
806
+ now + leaseMs,
807
+ id,
808
+ "pending",
809
+ ];
810
+ await transaction.execute(
811
+ `UPDATE ${tableName} SET ` +
812
+ `state = ${bind(driver, 1)}, ` +
813
+ `attempts = attempts + 1, ` +
814
+ `processing_at = ${bind(driver, 2)}, ` +
815
+ `lease_owner = ${bind(driver, 3)}, ` +
816
+ `lease_until = ${bind(driver, 4)}, error_text = NULL ` +
817
+ `WHERE id = ${bind(driver, 5)} AND state = ${bind(driver, 6)}`,
818
+ updateValues
819
+ );
820
+ const rows =
821
+ await transaction.query<Record<string, unknown>[]>(
822
+ `SELECT * FROM ${tableName} WHERE id = ${bind(driver, 1)} AND state = ${bind(driver, 2)} AND lease_owner = ${bind(driver, 3)}`,
823
+ [id, "processing", ownerId]
824
+ );
825
+ if (rows[0]) {
826
+ claimed.push(
827
+ rowToEvent(rows[0])
828
+ );
829
+ }
830
+ }
831
+ return claimed;
832
+ }
833
+ );
834
+ },
835
+
836
+ async markPublished(id, publishedAt, ownerId) {
837
+ return conditionalUpdate(
838
+ database,
839
+ driver,
840
+ tableName,
841
+ id,
842
+ ownerId,
843
+ `state = ${bind(driver, 1)}, published_at = ${bind(driver, 2)}, error_text = NULL, lease_owner = NULL, lease_until = NULL`,
844
+ ["published", publishedAt]
845
+ );
846
+ },
847
+
848
+ async markFailed(id, failOptions) {
849
+ const retry =
850
+ failOptions.retryAt !== undefined;
851
+ const setClause = retry
852
+ ? `state = ${bind(driver, 1)}, available_at = ${bind(driver, 2)}, processing_at = NULL, error_text = ${bind(driver, 3)}, lease_owner = NULL, lease_until = NULL`
853
+ : `state = ${bind(driver, 1)}, failed_at = ${bind(driver, 2)}, error_text = ${bind(driver, 3)}, lease_owner = NULL, lease_until = NULL`;
854
+ const values = retry
855
+ ? ["pending", failOptions.retryAt, failOptions.error]
856
+ : ["failed", failOptions.failedAt, failOptions.error];
857
+ return conditionalUpdate(
858
+ database,
859
+ driver,
860
+ tableName,
861
+ id,
862
+ failOptions.ownerId,
863
+ setClause,
864
+ values
865
+ );
866
+ },
867
+
868
+ async recoverStale(now, limit = 100) {
869
+ const normalizedLimit =
870
+ positiveInteger(limit, "recovery limit");
871
+ return database.transaction(
872
+ async transaction => {
873
+ const rows =
874
+ await transaction.query<Record<string, unknown>[]>(
875
+ `SELECT id FROM ${tableName} WHERE state = ${bind(driver, 1)} AND lease_until IS NOT NULL AND lease_until <= ${bind(driver, 2)} ORDER BY lease_until ASC, id ASC LIMIT ${normalizedLimit}`,
876
+ ["processing", now]
877
+ );
878
+ for (const row of rows) {
879
+ const id = String(row.id ?? "");
880
+ if (!id) {
881
+ continue;
882
+ }
883
+ await transaction.execute(
884
+ `UPDATE ${tableName} SET state = ${bind(driver, 1)}, processing_at = NULL, lease_owner = NULL, lease_until = NULL WHERE id = ${bind(driver, 2)} AND state = ${bind(driver, 3)} AND lease_until <= ${bind(driver, 4)}`,
885
+ ["pending", id, "processing", now]
886
+ );
887
+ }
888
+ return rows.length;
889
+ }
890
+ );
891
+ },
892
+
893
+ async get(id) {
894
+ const rows =
895
+ await database.query<Record<string, unknown>[]>(
896
+ `SELECT * FROM ${tableName} WHERE id = ${bind(driver, 1)}`,
897
+ [id]
898
+ );
899
+ return rows[0]
900
+ ? rowToEvent(rows[0]) as OutboxEventRecord<any>
901
+ : null;
902
+ },
903
+
904
+ async list() {
905
+ const rows =
906
+ await database.query<Record<string, unknown>[]>(
907
+ `SELECT * FROM ${tableName} ORDER BY created_at ASC, id ASC`
908
+ );
909
+ return rows.map(rowToEvent);
910
+ },
911
+
912
+ async cleanup(cleanupOptions) {
913
+ finiteNumber(
914
+ cleanupOptions.before,
915
+ "cleanup before"
916
+ );
917
+ const rows =
918
+ await database.query<Record<string, unknown>[]>(
919
+ `SELECT id FROM ${tableName} WHERE ` +
920
+ `(state = ${bind(driver, 1)} AND published_at <= ${bind(driver, 2)}) OR ` +
921
+ `(state = ${bind(driver, 3)} AND failed_at <= ${bind(driver, 4)})`,
922
+ [
923
+ "published",
924
+ cleanupOptions.before,
925
+ "failed",
926
+ cleanupOptions.before,
927
+ ]
928
+ );
929
+ await database.execute(
930
+ `DELETE FROM ${tableName} WHERE ` +
931
+ `(state = ${bind(driver, 1)} AND published_at <= ${bind(driver, 2)}) OR ` +
932
+ `(state = ${bind(driver, 3)} AND failed_at <= ${bind(driver, 4)})`,
933
+ [
934
+ "published",
935
+ cleanupOptions.before,
936
+ "failed",
937
+ cleanupOptions.before,
938
+ ]
939
+ );
940
+ return rows.length;
941
+ },
942
+
943
+ async stats() {
944
+ const rows =
945
+ await database.query<Array<{
946
+ state: string;
947
+ count: number | string;
948
+ }>>(
949
+ `SELECT state, COUNT(*) AS count FROM ${tableName} GROUP BY state`
950
+ );
951
+ const stats: OutboxStats = {
952
+ total: 0,
953
+ pending: 0,
954
+ processing: 0,
955
+ published: 0,
956
+ failed: 0,
957
+ };
958
+ for (const row of rows) {
959
+ const count = Number(row.count ?? 0);
960
+ if (
961
+ row.state === "pending" ||
962
+ row.state === "processing" ||
963
+ row.state === "published" ||
964
+ row.state === "failed"
965
+ ) {
966
+ stats[row.state] += count;
967
+ stats.total += count;
968
+ }
969
+ }
970
+ return stats;
971
+ },
972
+ };
973
+ }
974
+
975
+ export function createOutboxMigrationSql(
976
+ driver: DatabaseDriver,
977
+ tableName = "bcp_outbox_events"
978
+ ): string {
979
+ const normalizedDriver =
980
+ normalizeDriver(driver);
981
+ const table =
982
+ normalizeIdentifier(tableName);
983
+
984
+ if (normalizedDriver === "mysql") {
985
+ return `CREATE TABLE IF NOT EXISTS ${table} (\n` +
986
+ " id VARCHAR(191) PRIMARY KEY,\n" +
987
+ " event_type VARCHAR(191) NOT NULL,\n" +
988
+ " payload_json LONGTEXT NOT NULL,\n" +
989
+ " metadata_json LONGTEXT NOT NULL,\n" +
990
+ " state VARCHAR(32) NOT NULL,\n" +
991
+ " attempts INT NOT NULL DEFAULT 0,\n" +
992
+ " max_attempts INT NOT NULL,\n" +
993
+ " created_at BIGINT NOT NULL,\n" +
994
+ " available_at BIGINT NOT NULL,\n" +
995
+ " processing_at BIGINT NULL,\n" +
996
+ " published_at BIGINT NULL,\n" +
997
+ " failed_at BIGINT NULL,\n" +
998
+ " error_text TEXT NULL,\n" +
999
+ " correlation_id VARCHAR(191) NULL,\n" +
1000
+ " causation_id VARCHAR(191) NULL,\n" +
1001
+ " aggregate_id VARCHAR(191) NULL,\n" +
1002
+ " lease_owner VARCHAR(191) NULL,\n" +
1003
+ " lease_until BIGINT NULL,\n" +
1004
+ " INDEX idx_bcp_outbox_due (state, available_at),\n" +
1005
+ " INDEX idx_bcp_outbox_lease (state, lease_until)\n" +
1006
+ ") ENGINE=InnoDB;";
1007
+ }
1008
+
1009
+ const idType =
1010
+ normalizedDriver === "postgresql"
1011
+ ? "VARCHAR(191)"
1012
+ : "TEXT";
1013
+ const textType =
1014
+ normalizedDriver === "postgresql"
1015
+ ? "TEXT"
1016
+ : "TEXT";
1017
+ return `CREATE TABLE IF NOT EXISTS ${table} (\n` +
1018
+ ` id ${idType} PRIMARY KEY,\n` +
1019
+ ` event_type ${idType} NOT NULL,\n` +
1020
+ ` payload_json ${textType} NOT NULL,\n` +
1021
+ ` metadata_json ${textType} NOT NULL,\n` +
1022
+ ` state ${idType} NOT NULL,\n` +
1023
+ " attempts INTEGER NOT NULL DEFAULT 0,\n" +
1024
+ " max_attempts INTEGER NOT NULL,\n" +
1025
+ " created_at BIGINT NOT NULL,\n" +
1026
+ " available_at BIGINT NOT NULL,\n" +
1027
+ " processing_at BIGINT NULL,\n" +
1028
+ " published_at BIGINT NULL,\n" +
1029
+ " failed_at BIGINT NULL,\n" +
1030
+ ` error_text ${textType} NULL,\n` +
1031
+ ` correlation_id ${idType} NULL,\n` +
1032
+ ` causation_id ${idType} NULL,\n` +
1033
+ ` aggregate_id ${idType} NULL,\n` +
1034
+ ` lease_owner ${idType} NULL,\n` +
1035
+ " lease_until BIGINT NULL\n" +
1036
+ ");\n" +
1037
+ `CREATE INDEX IF NOT EXISTS idx_bcp_outbox_due ON ${table} (state, available_at);\n` +
1038
+ `CREATE INDEX IF NOT EXISTS idx_bcp_outbox_lease ON ${table} (state, lease_until);`;
1039
+ }
1040
+
1041
+ function createEventEnvelope(
1042
+ event: OutboxEventRecord
1043
+ ): Record<string, unknown> {
1044
+ return {
1045
+ eventId: event.id,
1046
+ type: event.type,
1047
+ payload: event.payload,
1048
+ metadata: event.metadata,
1049
+ correlationId: event.correlationId,
1050
+ causationId: event.causationId,
1051
+ aggregateId: event.aggregateId,
1052
+ createdAt: event.createdAt,
1053
+ };
1054
+ }
1055
+
1056
+ function recoverMemoryStale(
1057
+ events: Map<string, OutboxEventRecord>,
1058
+ now: number,
1059
+ limit: number
1060
+ ): number {
1061
+ const stale =
1062
+ Array.from(events.values())
1063
+ .filter(event =>
1064
+ event.state === "processing" &&
1065
+ event.leaseUntil !== undefined &&
1066
+ event.leaseUntil <= now
1067
+ )
1068
+ .sort((left, right) =>
1069
+ (left.leaseUntil ?? 0) - (right.leaseUntil ?? 0) ||
1070
+ left.id.localeCompare(right.id)
1071
+ )
1072
+ .slice(0, limit);
1073
+ for (const event of stale) {
1074
+ event.state = "pending";
1075
+ event.processingAt = undefined;
1076
+ clearLease(event);
1077
+ }
1078
+ return stale.length;
1079
+ }
1080
+
1081
+ async function recoverSqlStale(
1082
+ transaction: TransactionDatabase,
1083
+ driver: DatabaseDriver,
1084
+ tableName: string,
1085
+ now: number
1086
+ ): Promise<void> {
1087
+ await transaction.execute(
1088
+ `UPDATE ${tableName} SET state = ${bind(driver, 1)}, processing_at = NULL, lease_owner = NULL, lease_until = NULL ` +
1089
+ `WHERE state = ${bind(driver, 2)} AND lease_until IS NOT NULL AND lease_until <= ${bind(driver, 3)}`,
1090
+ ["pending", "processing", now]
1091
+ );
1092
+ }
1093
+
1094
+ async function conditionalUpdate(
1095
+ database: Pick<BcpDatabase, "query" | "execute">,
1096
+ driver: DatabaseDriver,
1097
+ tableName: string,
1098
+ id: string,
1099
+ ownerId: string,
1100
+ setClause: string,
1101
+ setValues: unknown[]
1102
+ ): Promise<boolean> {
1103
+ const idIndex = setValues.length + 1;
1104
+ const stateIndex = idIndex + 1;
1105
+ const ownerIndex = idIndex + 2;
1106
+ await database.execute(
1107
+ `UPDATE ${tableName} SET ${setClause} ` +
1108
+ `WHERE id = ${bind(driver, idIndex)} AND state = ${bind(driver, stateIndex)} AND lease_owner = ${bind(driver, ownerIndex)}`,
1109
+ [...setValues, id, "processing", ownerId]
1110
+ );
1111
+ const rows =
1112
+ await database.query<Record<string, unknown>[]>(
1113
+ `SELECT state, lease_owner FROM ${tableName} WHERE id = ${bind(driver, 1)}`,
1114
+ [id]
1115
+ );
1116
+ const row = rows[0];
1117
+ return Boolean(
1118
+ row &&
1119
+ row.lease_owner !== ownerId &&
1120
+ row.state !== "processing"
1121
+ );
1122
+ }
1123
+
1124
+ function rowToEvent(
1125
+ row: Record<string, unknown>
1126
+ ): OutboxEventRecord {
1127
+ return {
1128
+ id: String(row.id ?? ""),
1129
+ type: String(row.event_type ?? ""),
1130
+ payload: parseJson(row.payload_json),
1131
+ metadata: parseMetadata(row.metadata_json),
1132
+ state: normalizeState(row.state),
1133
+ attempts: Number(row.attempts ?? 0),
1134
+ maxAttempts: Number(row.max_attempts ?? 1),
1135
+ createdAt: Number(row.created_at ?? 0),
1136
+ availableAt: Number(row.available_at ?? 0),
1137
+ processingAt: optionalNumber(row.processing_at),
1138
+ publishedAt: optionalNumber(row.published_at),
1139
+ failedAt: optionalNumber(row.failed_at),
1140
+ error: optionalText(row.error_text),
1141
+ correlationId: optionalText(row.correlation_id),
1142
+ causationId: optionalText(row.causation_id),
1143
+ aggregateId: optionalText(row.aggregate_id),
1144
+ leaseOwner: optionalText(row.lease_owner),
1145
+ leaseUntil: optionalNumber(row.lease_until),
1146
+ };
1147
+ }
1148
+
1149
+ function calculateStats(
1150
+ events: OutboxEventRecord[]
1151
+ ): OutboxStats {
1152
+ const stats: OutboxStats = {
1153
+ total: events.length,
1154
+ pending: 0,
1155
+ processing: 0,
1156
+ published: 0,
1157
+ failed: 0,
1158
+ };
1159
+ for (const event of events) {
1160
+ stats[event.state] += 1;
1161
+ }
1162
+ return stats;
1163
+ }
1164
+
1165
+ function compareEvents(
1166
+ left: OutboxEventRecord,
1167
+ right: OutboxEventRecord
1168
+ ): number {
1169
+ return left.availableAt - right.availableAt ||
1170
+ left.createdAt - right.createdAt ||
1171
+ left.id.localeCompare(right.id);
1172
+ }
1173
+
1174
+ function clearLease(
1175
+ event: OutboxEventRecord
1176
+ ): void {
1177
+ event.leaseOwner = undefined;
1178
+ event.leaseUntil = undefined;
1179
+ }
1180
+
1181
+ function cloneEvent<TPayload>(
1182
+ event: OutboxEventRecord<TPayload>
1183
+ ): OutboxEventRecord<TPayload> {
1184
+ return {
1185
+ ...event,
1186
+ metadata: {
1187
+ ...event.metadata,
1188
+ },
1189
+ };
1190
+ }
1191
+
1192
+ function normalizeState(
1193
+ value: unknown
1194
+ ): OutboxEventState {
1195
+ if (
1196
+ value === "pending" ||
1197
+ value === "processing" ||
1198
+ value === "published" ||
1199
+ value === "failed"
1200
+ ) {
1201
+ return value;
1202
+ }
1203
+ throw new Error(
1204
+ `BCP Events: invalid outbox state "${String(value)}".`
1205
+ );
1206
+ }
1207
+
1208
+ function normalizeDriver(
1209
+ driver: DatabaseDriver
1210
+ ): DatabaseDriver {
1211
+ if (
1212
+ driver === "mysql" ||
1213
+ driver === "postgresql" ||
1214
+ driver === "sqlite"
1215
+ ) {
1216
+ return driver;
1217
+ }
1218
+ throw new TypeError(
1219
+ `BCP Events: unsupported database driver "${String(driver)}".`
1220
+ );
1221
+ }
1222
+
1223
+ function normalizeIdentifier(
1224
+ value: string
1225
+ ): string {
1226
+ const identifier =
1227
+ String(value ?? "").trim();
1228
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
1229
+ throw new TypeError(
1230
+ "BCP Events: table name must be a simple SQL identifier."
1231
+ );
1232
+ }
1233
+ return identifier;
1234
+ }
1235
+
1236
+ function placeholders(
1237
+ driver: DatabaseDriver,
1238
+ count: number
1239
+ ): string {
1240
+ return Array.from(
1241
+ { length: count },
1242
+ (_, index) => bind(driver, index + 1)
1243
+ ).join(", ");
1244
+ }
1245
+
1246
+ function bind(
1247
+ driver: DatabaseDriver,
1248
+ index: number
1249
+ ): string {
1250
+ return driver === "postgresql"
1251
+ ? `$${index}`
1252
+ : "?";
1253
+ }
1254
+
1255
+ function assertTransaction(
1256
+ transaction: TransactionDatabase
1257
+ ): void {
1258
+ if (
1259
+ !transaction ||
1260
+ typeof transaction.query !== "function" ||
1261
+ typeof transaction.execute !== "function"
1262
+ ) {
1263
+ throw new TypeError(
1264
+ "BCP Events: publish requires a BCP transaction database."
1265
+ );
1266
+ }
1267
+ }
1268
+
1269
+ function normalizeText(
1270
+ value: unknown,
1271
+ field: string
1272
+ ): string {
1273
+ const text = String(value ?? "").trim();
1274
+ if (!text) {
1275
+ throw new TypeError(
1276
+ `BCP Events: ${field} must be a non-empty string.`
1277
+ );
1278
+ }
1279
+ return text;
1280
+ }
1281
+
1282
+ function optionalText(
1283
+ value: unknown
1284
+ ): string | undefined {
1285
+ if (value === undefined || value === null) {
1286
+ return undefined;
1287
+ }
1288
+ const text = String(value).trim();
1289
+ return text || undefined;
1290
+ }
1291
+
1292
+ function optionalNumber(
1293
+ value: unknown
1294
+ ): number | undefined {
1295
+ if (value === undefined || value === null) {
1296
+ return undefined;
1297
+ }
1298
+ const number = Number(value);
1299
+ return Number.isFinite(number)
1300
+ ? number
1301
+ : undefined;
1302
+ }
1303
+
1304
+ function positiveInteger(
1305
+ value: number,
1306
+ field: string
1307
+ ): number {
1308
+ if (!Number.isInteger(value) || value <= 0) {
1309
+ throw new TypeError(
1310
+ `BCP Events: ${field} must be a positive integer.`
1311
+ );
1312
+ }
1313
+ return value;
1314
+ }
1315
+
1316
+ function nonNegativeNumber(
1317
+ value: number,
1318
+ field: string
1319
+ ): number {
1320
+ if (!Number.isFinite(value) || value < 0) {
1321
+ throw new TypeError(
1322
+ `BCP Events: ${field} must be a non-negative finite number.`
1323
+ );
1324
+ }
1325
+ return Math.floor(value);
1326
+ }
1327
+
1328
+ function finiteNumber(
1329
+ value: number,
1330
+ field: string
1331
+ ): number {
1332
+ if (!Number.isFinite(value)) {
1333
+ throw new TypeError(
1334
+ `BCP Events: ${field} must be a finite number.`
1335
+ );
1336
+ }
1337
+ return value;
1338
+ }
1339
+
1340
+ function resolveRetryDelay(
1341
+ retryDelay: OutboxRetryDelay,
1342
+ attempt: number
1343
+ ): number {
1344
+ const delay =
1345
+ typeof retryDelay === "function"
1346
+ ? retryDelay(attempt)
1347
+ : retryDelay;
1348
+ return nonNegativeNumber(
1349
+ delay,
1350
+ "retry delay"
1351
+ );
1352
+ }
1353
+
1354
+ function parseJson(
1355
+ value: unknown
1356
+ ): unknown {
1357
+ if (typeof value !== "string") {
1358
+ return value;
1359
+ }
1360
+ return JSON.parse(value);
1361
+ }
1362
+
1363
+ function parseMetadata(
1364
+ value: unknown
1365
+ ): Record<string, unknown> {
1366
+ const parsed = parseJson(value);
1367
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
1368
+ ? parsed as Record<string, unknown>
1369
+ : {};
1370
+ }
1371
+
1372
+ function formatError(
1373
+ error: unknown
1374
+ ): string {
1375
+ if (error instanceof Error) {
1376
+ return error.message || error.name;
1377
+ }
1378
+ if (typeof error === "string") {
1379
+ return error;
1380
+ }
1381
+ try {
1382
+ return JSON.stringify(error) ?? String(error);
1383
+ } catch {
1384
+ return String(error);
1385
+ }
1386
+ }
1387
+
1388
+ function sleep(
1389
+ delayMs: number,
1390
+ signal: AbortSignal
1391
+ ): Promise<void> {
1392
+ if (delayMs === 0) {
1393
+ return Promise.resolve();
1394
+ }
1395
+ return new Promise((resolve, reject) => {
1396
+ const timer = setTimeout(resolve, delayMs);
1397
+ const abort = () => {
1398
+ clearTimeout(timer);
1399
+ const error = new Error("Outbox dispatcher stopped.");
1400
+ error.name = "AbortError";
1401
+ reject(error);
1402
+ };
1403
+ if (signal.aborted) {
1404
+ abort();
1405
+ return;
1406
+ }
1407
+ signal.addEventListener("abort", abort, { once: true });
1408
+ });
1409
+ }
1410
+
1411
+ function isAbortError(
1412
+ error: unknown
1413
+ ): boolean {
1414
+ return error instanceof Error &&
1415
+ error.name === "AbortError";
1416
+ }