@chidchanun/bcp 0.2.10 → 0.2.12
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/README.md +231 -286
- package/docs/README.md +60 -61
- package/docs/api-manifest.json +29 -2
- package/docs/api-reference.md +105 -85
- package/docs/docs-web-manifest.json +9 -5
- package/docs/platform-manifest.json +27 -4
- package/docs/releases/0.2.11.md +180 -0
- package/docs/releases/0.2.12.md +147 -0
- package/docs/transactional-outbox-events.md +465 -0
- package/docs/workflow-orchestration.md +374 -0
- package/package.json +11 -1
- package/packages/bundler/src/client-boundary.ts +2 -0
- package/packages/client/src/events.mjs +889 -0
- package/packages/client/src/events.ts +31 -0
- package/packages/client/src/workflow.mjs +601 -0
- package/packages/client/src/workflow.ts +23 -0
- package/packages/server/src/events.ts +1416 -0
- package/packages/server/src/workflow.ts +887 -0
|
@@ -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
|
+
}
|