@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,889 @@
1
+ // packages/server/src/events.ts
2
+ import {
3
+ randomUUID
4
+ } from "node:crypto";
5
+ function createMemoryOutboxStore() {
6
+ const events = /* @__PURE__ */ new Map();
7
+ return {
8
+ async append(_transaction, event) {
9
+ if (events.has(event.id)) {
10
+ throw new Error(
11
+ `BCP Events: outbox event id "${event.id}" already exists.`
12
+ );
13
+ }
14
+ events.set(
15
+ event.id,
16
+ cloneEvent(event)
17
+ );
18
+ },
19
+ async claimDue(now, options) {
20
+ const ownerId = normalizeText(
21
+ options.ownerId,
22
+ "dispatcher owner id"
23
+ );
24
+ const leaseMs = positiveInteger(
25
+ options.leaseMs,
26
+ "leaseMs"
27
+ );
28
+ const limit = positiveInteger(
29
+ options.limit ?? 100,
30
+ "batch limit"
31
+ );
32
+ recoverMemoryStale(
33
+ events,
34
+ now,
35
+ Number.MAX_SAFE_INTEGER
36
+ );
37
+ const due = Array.from(events.values()).filter(
38
+ (event) => event.state === "pending" && event.availableAt <= now && event.attempts < event.maxAttempts
39
+ ).sort(compareEvents).slice(0, limit);
40
+ for (const event of due) {
41
+ event.state = "processing";
42
+ event.attempts += 1;
43
+ event.processingAt = now;
44
+ event.leaseOwner = ownerId;
45
+ event.leaseUntil = now + leaseMs;
46
+ event.error = void 0;
47
+ }
48
+ return due.map(cloneEvent);
49
+ },
50
+ async markPublished(id, publishedAt, ownerId) {
51
+ const event = events.get(id);
52
+ if (!event || event.state !== "processing" || event.leaseOwner !== ownerId) {
53
+ return false;
54
+ }
55
+ event.state = "published";
56
+ event.publishedAt = publishedAt;
57
+ event.failedAt = void 0;
58
+ event.error = void 0;
59
+ clearLease(event);
60
+ return true;
61
+ },
62
+ async markFailed(id, options) {
63
+ const event = events.get(id);
64
+ if (!event || event.state !== "processing" || event.leaseOwner !== options.ownerId) {
65
+ return false;
66
+ }
67
+ event.error = options.error;
68
+ clearLease(event);
69
+ if (options.retryAt !== void 0 && event.attempts < event.maxAttempts) {
70
+ event.state = "pending";
71
+ event.availableAt = options.retryAt;
72
+ event.processingAt = void 0;
73
+ return true;
74
+ }
75
+ event.state = "failed";
76
+ event.failedAt = options.failedAt;
77
+ return true;
78
+ },
79
+ async recoverStale(now, limit = 100) {
80
+ return recoverMemoryStale(
81
+ events,
82
+ now,
83
+ positiveInteger(limit, "recovery limit")
84
+ );
85
+ },
86
+ async get(id) {
87
+ const event = events.get(id);
88
+ return event ? cloneEvent(event) : null;
89
+ },
90
+ async list() {
91
+ return Array.from(events.values()).map(cloneEvent).sort(compareEvents);
92
+ },
93
+ async cleanup(options) {
94
+ finiteNumber(
95
+ options.before,
96
+ "cleanup before"
97
+ );
98
+ let removed = 0;
99
+ for (const [id, event] of events) {
100
+ const terminalAt = event.publishedAt ?? event.failedAt;
101
+ if ((event.state === "published" || event.state === "failed") && terminalAt !== void 0 && terminalAt <= options.before) {
102
+ events.delete(id);
103
+ removed += 1;
104
+ }
105
+ }
106
+ return removed;
107
+ },
108
+ async stats() {
109
+ return calculateStats(
110
+ Array.from(events.values())
111
+ );
112
+ },
113
+ clear() {
114
+ events.clear();
115
+ }
116
+ };
117
+ }
118
+ function createTransactionalOutbox(options) {
119
+ if (!options?.store) {
120
+ throw new TypeError(
121
+ "BCP Events: transactional outbox requires a store."
122
+ );
123
+ }
124
+ const store = options.store;
125
+ const now = options.now ?? Date.now;
126
+ const idFactory = options.idFactory ?? randomUUID;
127
+ const defaultMaxAttempts = positiveInteger(
128
+ options.defaultMaxAttempts ?? 5,
129
+ "defaultMaxAttempts"
130
+ );
131
+ return {
132
+ store,
133
+ async publish(transaction, rawType, payload, publishOptions = {}) {
134
+ assertTransaction(transaction);
135
+ const type = normalizeText(
136
+ rawType,
137
+ "event type"
138
+ );
139
+ const createdAt = now();
140
+ const delayMs = nonNegativeNumber(
141
+ publishOptions.delayMs ?? 0,
142
+ "delayMs"
143
+ );
144
+ const event = {
145
+ id: normalizeText(
146
+ publishOptions.id ?? idFactory(),
147
+ "event id"
148
+ ),
149
+ type,
150
+ payload,
151
+ metadata: {
152
+ ...publishOptions.metadata ?? {}
153
+ },
154
+ state: "pending",
155
+ attempts: 0,
156
+ maxAttempts: positiveInteger(
157
+ publishOptions.maxAttempts ?? defaultMaxAttempts,
158
+ "maxAttempts"
159
+ ),
160
+ createdAt,
161
+ availableAt: createdAt + delayMs,
162
+ correlationId: optionalText(
163
+ publishOptions.correlationId
164
+ ),
165
+ causationId: optionalText(
166
+ publishOptions.causationId
167
+ ),
168
+ aggregateId: optionalText(
169
+ publishOptions.aggregateId
170
+ )
171
+ };
172
+ await store.append(
173
+ transaction,
174
+ event
175
+ );
176
+ return cloneEvent(event);
177
+ }
178
+ };
179
+ }
180
+ function createEventBus() {
181
+ const handlers = /* @__PURE__ */ new Map();
182
+ return {
183
+ on(type, handler) {
184
+ const normalizedType = normalizeText(type, "event type");
185
+ if (typeof handler !== "function") {
186
+ throw new TypeError(
187
+ "BCP Events: event handler must be a function."
188
+ );
189
+ }
190
+ let group = handlers.get(normalizedType);
191
+ if (!group) {
192
+ group = /* @__PURE__ */ new Set();
193
+ handlers.set(normalizedType, group);
194
+ }
195
+ group.add(handler);
196
+ return () => {
197
+ group?.delete(handler);
198
+ if (group?.size === 0) {
199
+ handlers.delete(normalizedType);
200
+ }
201
+ };
202
+ },
203
+ async emit(event) {
204
+ const group = handlers.get(event.type);
205
+ if (!group || group.size === 0) {
206
+ return;
207
+ }
208
+ for (const handler of group) {
209
+ await handler({
210
+ event: cloneEvent(event),
211
+ payload: event.payload
212
+ });
213
+ }
214
+ },
215
+ clear() {
216
+ handlers.clear();
217
+ }
218
+ };
219
+ }
220
+ function createOutboxDispatcher(options) {
221
+ if (!options?.store) {
222
+ throw new TypeError(
223
+ "BCP Events: dispatcher requires an outbox store."
224
+ );
225
+ }
226
+ if (!options.queue && !options.publish && !options.eventBus) {
227
+ throw new TypeError(
228
+ "BCP Events: dispatcher requires queue, publish or eventBus delivery."
229
+ );
230
+ }
231
+ const store = options.store;
232
+ const queue = options.queue;
233
+ const eventBus = options.eventBus;
234
+ const publish = options.publish;
235
+ const ownerId = normalizeText(
236
+ options.ownerId ?? `outbox-${randomUUID()}`,
237
+ "dispatcher owner id"
238
+ );
239
+ const leaseMs = positiveInteger(
240
+ options.leaseMs ?? 3e4,
241
+ "leaseMs"
242
+ );
243
+ const batchSize = positiveInteger(
244
+ options.batchSize ?? 100,
245
+ "batchSize"
246
+ );
247
+ const pollIntervalMs = nonNegativeNumber(
248
+ options.pollIntervalMs ?? 1e3,
249
+ "pollIntervalMs"
250
+ );
251
+ const retryDelay = options.retryDelayMs ?? ((attempt) => Math.min(
252
+ 6e4,
253
+ 1e3 * Math.pow(2, Math.max(0, attempt - 1))
254
+ ));
255
+ const queueNamePrefix = String(
256
+ options.queueNamePrefix ?? "event."
257
+ );
258
+ const now = options.now ?? Date.now;
259
+ const runners = /* @__PURE__ */ new Set();
260
+ const dispatcher = {
261
+ store,
262
+ ownerId,
263
+ async dispatchBatch() {
264
+ await store.recoverStale(
265
+ now(),
266
+ batchSize
267
+ );
268
+ const events = await store.claimDue(
269
+ now(),
270
+ {
271
+ ownerId,
272
+ leaseMs,
273
+ limit: batchSize
274
+ }
275
+ );
276
+ let publishedCount = 0;
277
+ for (const event of events) {
278
+ try {
279
+ if (queue) {
280
+ await queue.enqueue(
281
+ `${queueNamePrefix}${event.type}`,
282
+ createEventEnvelope(event),
283
+ {
284
+ id: `outbox:${event.id}`,
285
+ maxAttempts: 1
286
+ }
287
+ );
288
+ }
289
+ if (eventBus) {
290
+ await eventBus.emit(event);
291
+ }
292
+ if (publish) {
293
+ await publish(event);
294
+ }
295
+ const marked = await store.markPublished(
296
+ event.id,
297
+ now(),
298
+ ownerId
299
+ );
300
+ if (marked) {
301
+ publishedCount += 1;
302
+ }
303
+ } catch (error) {
304
+ const failedAt = now();
305
+ const retryAt = event.attempts < event.maxAttempts ? failedAt + resolveRetryDelay(
306
+ retryDelay,
307
+ event.attempts
308
+ ) : void 0;
309
+ await store.markFailed(
310
+ event.id,
311
+ {
312
+ ownerId,
313
+ error: formatError(error),
314
+ failedAt,
315
+ retryAt
316
+ }
317
+ );
318
+ if (options.onError) {
319
+ await options.onError(
320
+ error,
321
+ cloneEvent(event)
322
+ );
323
+ }
324
+ }
325
+ }
326
+ return publishedCount;
327
+ },
328
+ recoverStale(limit = batchSize) {
329
+ return store.recoverStale(
330
+ now(),
331
+ positiveInteger(
332
+ limit,
333
+ "recovery limit"
334
+ )
335
+ );
336
+ },
337
+ start() {
338
+ let running = true;
339
+ let stopPromise = null;
340
+ const controller = new AbortController();
341
+ const loop = async () => {
342
+ while (running && !controller.signal.aborted) {
343
+ try {
344
+ await dispatcher.dispatchBatch();
345
+ } catch (error) {
346
+ if (options.onError) {
347
+ await options.onError(error);
348
+ }
349
+ }
350
+ if (running && !controller.signal.aborted) {
351
+ await sleep(
352
+ pollIntervalMs,
353
+ controller.signal
354
+ );
355
+ }
356
+ }
357
+ };
358
+ const loopPromise = loop();
359
+ const runner = {
360
+ get running() {
361
+ return running;
362
+ },
363
+ stop() {
364
+ if (!stopPromise) {
365
+ running = false;
366
+ controller.abort();
367
+ stopPromise = loopPromise.catch((error) => {
368
+ if (!isAbortError(error)) {
369
+ throw error;
370
+ }
371
+ }).then(() => {
372
+ runners.delete(runner);
373
+ });
374
+ }
375
+ return stopPromise;
376
+ }
377
+ };
378
+ runners.add(runner);
379
+ return runner;
380
+ },
381
+ async close() {
382
+ await Promise.all(
383
+ Array.from(
384
+ runners,
385
+ (runner) => runner.stop()
386
+ )
387
+ );
388
+ await store.close?.();
389
+ }
390
+ };
391
+ return dispatcher;
392
+ }
393
+ function createSqlOutboxStore(options) {
394
+ if (!options?.database) {
395
+ throw new TypeError(
396
+ "BCP Events: SQL outbox store requires a database."
397
+ );
398
+ }
399
+ const database = options.database;
400
+ const driver = normalizeDriver(options.driver);
401
+ const tableName = normalizeIdentifier(
402
+ options.tableName ?? "bcp_outbox_events"
403
+ );
404
+ return {
405
+ driver,
406
+ tableName,
407
+ async append(transaction, event) {
408
+ assertTransaction(transaction);
409
+ const values = [
410
+ event.id,
411
+ event.type,
412
+ JSON.stringify(event.payload),
413
+ JSON.stringify(event.metadata),
414
+ event.state,
415
+ event.attempts,
416
+ event.maxAttempts,
417
+ event.createdAt,
418
+ event.availableAt,
419
+ event.correlationId ?? null,
420
+ event.causationId ?? null,
421
+ event.aggregateId ?? null
422
+ ];
423
+ await transaction.execute(
424
+ `INSERT INTO ${tableName} (id, event_type, payload_json, metadata_json, state, attempts, max_attempts, created_at, available_at, correlation_id, causation_id, aggregate_id) VALUES (${placeholders(driver, values.length)})`,
425
+ values
426
+ );
427
+ },
428
+ async claimDue(now, claimOptions) {
429
+ const ownerId = normalizeText(
430
+ claimOptions.ownerId,
431
+ "dispatcher owner id"
432
+ );
433
+ const leaseMs = positiveInteger(
434
+ claimOptions.leaseMs,
435
+ "leaseMs"
436
+ );
437
+ const limit = positiveInteger(
438
+ claimOptions.limit ?? 100,
439
+ "batch limit"
440
+ );
441
+ return database.transaction(
442
+ async (transaction) => {
443
+ await recoverSqlStale(
444
+ transaction,
445
+ driver,
446
+ tableName,
447
+ now
448
+ );
449
+ const candidates = await transaction.query(
450
+ `SELECT * FROM ${tableName} WHERE state = ` + bind(driver, 1) + " AND available_at <= " + bind(driver, 2) + ` AND attempts < max_attempts ORDER BY available_at ASC, created_at ASC, id ASC LIMIT ${limit}` + (driver === "sqlite" ? "" : " FOR UPDATE SKIP LOCKED"),
451
+ ["pending", now]
452
+ );
453
+ const claimed = [];
454
+ for (const row of candidates) {
455
+ const id = String(row.id ?? "");
456
+ if (!id) {
457
+ continue;
458
+ }
459
+ const updateValues = [
460
+ "processing",
461
+ now,
462
+ ownerId,
463
+ now + leaseMs,
464
+ id,
465
+ "pending"
466
+ ];
467
+ await transaction.execute(
468
+ `UPDATE ${tableName} SET state = ${bind(driver, 1)}, attempts = attempts + 1, processing_at = ${bind(driver, 2)}, lease_owner = ${bind(driver, 3)}, lease_until = ${bind(driver, 4)}, error_text = NULL WHERE id = ${bind(driver, 5)} AND state = ${bind(driver, 6)}`,
469
+ updateValues
470
+ );
471
+ const rows = await transaction.query(
472
+ `SELECT * FROM ${tableName} WHERE id = ${bind(driver, 1)} AND state = ${bind(driver, 2)} AND lease_owner = ${bind(driver, 3)}`,
473
+ [id, "processing", ownerId]
474
+ );
475
+ if (rows[0]) {
476
+ claimed.push(
477
+ rowToEvent(rows[0])
478
+ );
479
+ }
480
+ }
481
+ return claimed;
482
+ }
483
+ );
484
+ },
485
+ async markPublished(id, publishedAt, ownerId) {
486
+ return conditionalUpdate(
487
+ database,
488
+ driver,
489
+ tableName,
490
+ id,
491
+ ownerId,
492
+ `state = ${bind(driver, 1)}, published_at = ${bind(driver, 2)}, error_text = NULL, lease_owner = NULL, lease_until = NULL`,
493
+ ["published", publishedAt]
494
+ );
495
+ },
496
+ async markFailed(id, failOptions) {
497
+ const retry = failOptions.retryAt !== void 0;
498
+ const setClause = retry ? `state = ${bind(driver, 1)}, available_at = ${bind(driver, 2)}, processing_at = NULL, error_text = ${bind(driver, 3)}, lease_owner = NULL, lease_until = NULL` : `state = ${bind(driver, 1)}, failed_at = ${bind(driver, 2)}, error_text = ${bind(driver, 3)}, lease_owner = NULL, lease_until = NULL`;
499
+ const values = retry ? ["pending", failOptions.retryAt, failOptions.error] : ["failed", failOptions.failedAt, failOptions.error];
500
+ return conditionalUpdate(
501
+ database,
502
+ driver,
503
+ tableName,
504
+ id,
505
+ failOptions.ownerId,
506
+ setClause,
507
+ values
508
+ );
509
+ },
510
+ async recoverStale(now, limit = 100) {
511
+ const normalizedLimit = positiveInteger(limit, "recovery limit");
512
+ return database.transaction(
513
+ async (transaction) => {
514
+ const rows = await transaction.query(
515
+ `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}`,
516
+ ["processing", now]
517
+ );
518
+ for (const row of rows) {
519
+ const id = String(row.id ?? "");
520
+ if (!id) {
521
+ continue;
522
+ }
523
+ await transaction.execute(
524
+ `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)}`,
525
+ ["pending", id, "processing", now]
526
+ );
527
+ }
528
+ return rows.length;
529
+ }
530
+ );
531
+ },
532
+ async get(id) {
533
+ const rows = await database.query(
534
+ `SELECT * FROM ${tableName} WHERE id = ${bind(driver, 1)}`,
535
+ [id]
536
+ );
537
+ return rows[0] ? rowToEvent(rows[0]) : null;
538
+ },
539
+ async list() {
540
+ const rows = await database.query(
541
+ `SELECT * FROM ${tableName} ORDER BY created_at ASC, id ASC`
542
+ );
543
+ return rows.map(rowToEvent);
544
+ },
545
+ async cleanup(cleanupOptions) {
546
+ finiteNumber(
547
+ cleanupOptions.before,
548
+ "cleanup before"
549
+ );
550
+ const rows = await database.query(
551
+ `SELECT id FROM ${tableName} WHERE (state = ${bind(driver, 1)} AND published_at <= ${bind(driver, 2)}) OR (state = ${bind(driver, 3)} AND failed_at <= ${bind(driver, 4)})`,
552
+ [
553
+ "published",
554
+ cleanupOptions.before,
555
+ "failed",
556
+ cleanupOptions.before
557
+ ]
558
+ );
559
+ await database.execute(
560
+ `DELETE FROM ${tableName} WHERE (state = ${bind(driver, 1)} AND published_at <= ${bind(driver, 2)}) OR (state = ${bind(driver, 3)} AND failed_at <= ${bind(driver, 4)})`,
561
+ [
562
+ "published",
563
+ cleanupOptions.before,
564
+ "failed",
565
+ cleanupOptions.before
566
+ ]
567
+ );
568
+ return rows.length;
569
+ },
570
+ async stats() {
571
+ const rows = await database.query(
572
+ `SELECT state, COUNT(*) AS count FROM ${tableName} GROUP BY state`
573
+ );
574
+ const stats = {
575
+ total: 0,
576
+ pending: 0,
577
+ processing: 0,
578
+ published: 0,
579
+ failed: 0
580
+ };
581
+ for (const row of rows) {
582
+ const count = Number(row.count ?? 0);
583
+ if (row.state === "pending" || row.state === "processing" || row.state === "published" || row.state === "failed") {
584
+ stats[row.state] += count;
585
+ stats.total += count;
586
+ }
587
+ }
588
+ return stats;
589
+ }
590
+ };
591
+ }
592
+ function createOutboxMigrationSql(driver, tableName = "bcp_outbox_events") {
593
+ const normalizedDriver = normalizeDriver(driver);
594
+ const table = normalizeIdentifier(tableName);
595
+ if (normalizedDriver === "mysql") {
596
+ return `CREATE TABLE IF NOT EXISTS ${table} (
597
+ id VARCHAR(191) PRIMARY KEY,
598
+ event_type VARCHAR(191) NOT NULL,
599
+ payload_json LONGTEXT NOT NULL,
600
+ metadata_json LONGTEXT NOT NULL,
601
+ state VARCHAR(32) NOT NULL,
602
+ attempts INT NOT NULL DEFAULT 0,
603
+ max_attempts INT NOT NULL,
604
+ created_at BIGINT NOT NULL,
605
+ available_at BIGINT NOT NULL,
606
+ processing_at BIGINT NULL,
607
+ published_at BIGINT NULL,
608
+ failed_at BIGINT NULL,
609
+ error_text TEXT NULL,
610
+ correlation_id VARCHAR(191) NULL,
611
+ causation_id VARCHAR(191) NULL,
612
+ aggregate_id VARCHAR(191) NULL,
613
+ lease_owner VARCHAR(191) NULL,
614
+ lease_until BIGINT NULL,
615
+ INDEX idx_bcp_outbox_due (state, available_at),
616
+ INDEX idx_bcp_outbox_lease (state, lease_until)
617
+ ) ENGINE=InnoDB;`;
618
+ }
619
+ const idType = normalizedDriver === "postgresql" ? "VARCHAR(191)" : "TEXT";
620
+ const textType = normalizedDriver === "postgresql" ? "TEXT" : "TEXT";
621
+ return `CREATE TABLE IF NOT EXISTS ${table} (
622
+ id ${idType} PRIMARY KEY,
623
+ event_type ${idType} NOT NULL,
624
+ payload_json ${textType} NOT NULL,
625
+ metadata_json ${textType} NOT NULL,
626
+ state ${idType} NOT NULL,
627
+ attempts INTEGER NOT NULL DEFAULT 0,
628
+ max_attempts INTEGER NOT NULL,
629
+ created_at BIGINT NOT NULL,
630
+ available_at BIGINT NOT NULL,
631
+ processing_at BIGINT NULL,
632
+ published_at BIGINT NULL,
633
+ failed_at BIGINT NULL,
634
+ error_text ${textType} NULL,
635
+ correlation_id ${idType} NULL,
636
+ causation_id ${idType} NULL,
637
+ aggregate_id ${idType} NULL,
638
+ lease_owner ${idType} NULL,
639
+ lease_until BIGINT NULL
640
+ );
641
+ CREATE INDEX IF NOT EXISTS idx_bcp_outbox_due ON ${table} (state, available_at);
642
+ CREATE INDEX IF NOT EXISTS idx_bcp_outbox_lease ON ${table} (state, lease_until);`;
643
+ }
644
+ function createEventEnvelope(event) {
645
+ return {
646
+ eventId: event.id,
647
+ type: event.type,
648
+ payload: event.payload,
649
+ metadata: event.metadata,
650
+ correlationId: event.correlationId,
651
+ causationId: event.causationId,
652
+ aggregateId: event.aggregateId,
653
+ createdAt: event.createdAt
654
+ };
655
+ }
656
+ function recoverMemoryStale(events, now, limit) {
657
+ const stale = Array.from(events.values()).filter(
658
+ (event) => event.state === "processing" && event.leaseUntil !== void 0 && event.leaseUntil <= now
659
+ ).sort(
660
+ (left, right) => (left.leaseUntil ?? 0) - (right.leaseUntil ?? 0) || left.id.localeCompare(right.id)
661
+ ).slice(0, limit);
662
+ for (const event of stale) {
663
+ event.state = "pending";
664
+ event.processingAt = void 0;
665
+ clearLease(event);
666
+ }
667
+ return stale.length;
668
+ }
669
+ async function recoverSqlStale(transaction, driver, tableName, now) {
670
+ await transaction.execute(
671
+ `UPDATE ${tableName} SET state = ${bind(driver, 1)}, processing_at = NULL, lease_owner = NULL, lease_until = NULL WHERE state = ${bind(driver, 2)} AND lease_until IS NOT NULL AND lease_until <= ${bind(driver, 3)}`,
672
+ ["pending", "processing", now]
673
+ );
674
+ }
675
+ async function conditionalUpdate(database, driver, tableName, id, ownerId, setClause, setValues) {
676
+ const idIndex = setValues.length + 1;
677
+ const stateIndex = idIndex + 1;
678
+ const ownerIndex = idIndex + 2;
679
+ await database.execute(
680
+ `UPDATE ${tableName} SET ${setClause} WHERE id = ${bind(driver, idIndex)} AND state = ${bind(driver, stateIndex)} AND lease_owner = ${bind(driver, ownerIndex)}`,
681
+ [...setValues, id, "processing", ownerId]
682
+ );
683
+ const rows = await database.query(
684
+ `SELECT state, lease_owner FROM ${tableName} WHERE id = ${bind(driver, 1)}`,
685
+ [id]
686
+ );
687
+ const row = rows[0];
688
+ return Boolean(
689
+ row && row.lease_owner !== ownerId && row.state !== "processing"
690
+ );
691
+ }
692
+ function rowToEvent(row) {
693
+ return {
694
+ id: String(row.id ?? ""),
695
+ type: String(row.event_type ?? ""),
696
+ payload: parseJson(row.payload_json),
697
+ metadata: parseMetadata(row.metadata_json),
698
+ state: normalizeState(row.state),
699
+ attempts: Number(row.attempts ?? 0),
700
+ maxAttempts: Number(row.max_attempts ?? 1),
701
+ createdAt: Number(row.created_at ?? 0),
702
+ availableAt: Number(row.available_at ?? 0),
703
+ processingAt: optionalNumber(row.processing_at),
704
+ publishedAt: optionalNumber(row.published_at),
705
+ failedAt: optionalNumber(row.failed_at),
706
+ error: optionalText(row.error_text),
707
+ correlationId: optionalText(row.correlation_id),
708
+ causationId: optionalText(row.causation_id),
709
+ aggregateId: optionalText(row.aggregate_id),
710
+ leaseOwner: optionalText(row.lease_owner),
711
+ leaseUntil: optionalNumber(row.lease_until)
712
+ };
713
+ }
714
+ function calculateStats(events) {
715
+ const stats = {
716
+ total: events.length,
717
+ pending: 0,
718
+ processing: 0,
719
+ published: 0,
720
+ failed: 0
721
+ };
722
+ for (const event of events) {
723
+ stats[event.state] += 1;
724
+ }
725
+ return stats;
726
+ }
727
+ function compareEvents(left, right) {
728
+ return left.availableAt - right.availableAt || left.createdAt - right.createdAt || left.id.localeCompare(right.id);
729
+ }
730
+ function clearLease(event) {
731
+ event.leaseOwner = void 0;
732
+ event.leaseUntil = void 0;
733
+ }
734
+ function cloneEvent(event) {
735
+ return {
736
+ ...event,
737
+ metadata: {
738
+ ...event.metadata
739
+ }
740
+ };
741
+ }
742
+ function normalizeState(value) {
743
+ if (value === "pending" || value === "processing" || value === "published" || value === "failed") {
744
+ return value;
745
+ }
746
+ throw new Error(
747
+ `BCP Events: invalid outbox state "${String(value)}".`
748
+ );
749
+ }
750
+ function normalizeDriver(driver) {
751
+ if (driver === "mysql" || driver === "postgresql" || driver === "sqlite") {
752
+ return driver;
753
+ }
754
+ throw new TypeError(
755
+ `BCP Events: unsupported database driver "${String(driver)}".`
756
+ );
757
+ }
758
+ function normalizeIdentifier(value) {
759
+ const identifier = String(value ?? "").trim();
760
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
761
+ throw new TypeError(
762
+ "BCP Events: table name must be a simple SQL identifier."
763
+ );
764
+ }
765
+ return identifier;
766
+ }
767
+ function placeholders(driver, count) {
768
+ return Array.from(
769
+ { length: count },
770
+ (_, index) => bind(driver, index + 1)
771
+ ).join(", ");
772
+ }
773
+ function bind(driver, index) {
774
+ return driver === "postgresql" ? `$${index}` : "?";
775
+ }
776
+ function assertTransaction(transaction) {
777
+ if (!transaction || typeof transaction.query !== "function" || typeof transaction.execute !== "function") {
778
+ throw new TypeError(
779
+ "BCP Events: publish requires a BCP transaction database."
780
+ );
781
+ }
782
+ }
783
+ function normalizeText(value, field) {
784
+ const text = String(value ?? "").trim();
785
+ if (!text) {
786
+ throw new TypeError(
787
+ `BCP Events: ${field} must be a non-empty string.`
788
+ );
789
+ }
790
+ return text;
791
+ }
792
+ function optionalText(value) {
793
+ if (value === void 0 || value === null) {
794
+ return void 0;
795
+ }
796
+ const text = String(value).trim();
797
+ return text || void 0;
798
+ }
799
+ function optionalNumber(value) {
800
+ if (value === void 0 || value === null) {
801
+ return void 0;
802
+ }
803
+ const number = Number(value);
804
+ return Number.isFinite(number) ? number : void 0;
805
+ }
806
+ function positiveInteger(value, field) {
807
+ if (!Number.isInteger(value) || value <= 0) {
808
+ throw new TypeError(
809
+ `BCP Events: ${field} must be a positive integer.`
810
+ );
811
+ }
812
+ return value;
813
+ }
814
+ function nonNegativeNumber(value, field) {
815
+ if (!Number.isFinite(value) || value < 0) {
816
+ throw new TypeError(
817
+ `BCP Events: ${field} must be a non-negative finite number.`
818
+ );
819
+ }
820
+ return Math.floor(value);
821
+ }
822
+ function finiteNumber(value, field) {
823
+ if (!Number.isFinite(value)) {
824
+ throw new TypeError(
825
+ `BCP Events: ${field} must be a finite number.`
826
+ );
827
+ }
828
+ return value;
829
+ }
830
+ function resolveRetryDelay(retryDelay, attempt) {
831
+ const delay = typeof retryDelay === "function" ? retryDelay(attempt) : retryDelay;
832
+ return nonNegativeNumber(
833
+ delay,
834
+ "retry delay"
835
+ );
836
+ }
837
+ function parseJson(value) {
838
+ if (typeof value !== "string") {
839
+ return value;
840
+ }
841
+ return JSON.parse(value);
842
+ }
843
+ function parseMetadata(value) {
844
+ const parsed = parseJson(value);
845
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
846
+ }
847
+ function formatError(error) {
848
+ if (error instanceof Error) {
849
+ return error.message || error.name;
850
+ }
851
+ if (typeof error === "string") {
852
+ return error;
853
+ }
854
+ try {
855
+ return JSON.stringify(error) ?? String(error);
856
+ } catch {
857
+ return String(error);
858
+ }
859
+ }
860
+ function sleep(delayMs, signal) {
861
+ if (delayMs === 0) {
862
+ return Promise.resolve();
863
+ }
864
+ return new Promise((resolve, reject) => {
865
+ const timer = setTimeout(resolve, delayMs);
866
+ const abort = () => {
867
+ clearTimeout(timer);
868
+ const error = new Error("Outbox dispatcher stopped.");
869
+ error.name = "AbortError";
870
+ reject(error);
871
+ };
872
+ if (signal.aborted) {
873
+ abort();
874
+ return;
875
+ }
876
+ signal.addEventListener("abort", abort, { once: true });
877
+ });
878
+ }
879
+ function isAbortError(error) {
880
+ return error instanceof Error && error.name === "AbortError";
881
+ }
882
+ export {
883
+ createEventBus,
884
+ createMemoryOutboxStore,
885
+ createOutboxDispatcher,
886
+ createOutboxMigrationSql,
887
+ createSqlOutboxStore,
888
+ createTransactionalOutbox
889
+ };