@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,465 @@
|
|
|
1
|
+
# Transactional Outbox & Events
|
|
2
|
+
|
|
3
|
+
BCP `0.2.12` adds a server-only `bcp/events` entrypoint for storing integration events in the same database transaction as application data and dispatching those events after commit.
|
|
4
|
+
|
|
5
|
+
## Why an outbox exists
|
|
6
|
+
|
|
7
|
+
This is unsafe when the database and message system are separate resources:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
await db.transaction(async tx => {
|
|
11
|
+
await tx.execute(
|
|
12
|
+
"INSERT INTO orders ..."
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
await jobs.enqueue(
|
|
16
|
+
"event.order.created",
|
|
17
|
+
payload
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The database can commit while the queue publish fails, or the queue can publish before the database transaction ultimately rolls back.
|
|
23
|
+
|
|
24
|
+
The transactional outbox changes the write path to:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
Database transaction
|
|
28
|
+
|
|
|
29
|
+
+-- business row
|
|
30
|
+
+-- outbox event row
|
|
31
|
+
|
|
|
32
|
+
COMMIT
|
|
33
|
+
|
|
|
34
|
+
v
|
|
35
|
+
Outbox Dispatcher
|
|
36
|
+
|
|
|
37
|
+
queue / publisher
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The business write and outbox write share one database transaction. External delivery happens later.
|
|
41
|
+
|
|
42
|
+
## Public entrypoint
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import {
|
|
46
|
+
createOutboxDispatcher,
|
|
47
|
+
createOutboxMigrationSql,
|
|
48
|
+
createSqlOutboxStore,
|
|
49
|
+
createTransactionalOutbox,
|
|
50
|
+
} from "bcp/events";
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`bcp/events` is server-only and must not be imported into page/client bundles.
|
|
54
|
+
|
|
55
|
+
## Create the outbox table
|
|
56
|
+
|
|
57
|
+
Generate provider-specific SQL:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import {
|
|
61
|
+
createOutboxMigrationSql,
|
|
62
|
+
} from "bcp/events";
|
|
63
|
+
|
|
64
|
+
const sql =
|
|
65
|
+
createOutboxMigrationSql(
|
|
66
|
+
"postgresql"
|
|
67
|
+
);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Supported providers match the SQL Database Platform:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
mysql
|
|
74
|
+
postgresql
|
|
75
|
+
sqlite
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The default table name is:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
bcp_outbox_events
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A custom table name must be a simple SQL identifier.
|
|
85
|
+
|
|
86
|
+
Use the generated SQL in a normal BCP migration. The outbox table stores event identity/type/payload/metadata, delivery state, retry counters, timestamps and dispatcher lease information.
|
|
87
|
+
|
|
88
|
+
## Create a SQL outbox
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import {
|
|
92
|
+
db,
|
|
93
|
+
} from "bcp/database";
|
|
94
|
+
|
|
95
|
+
import {
|
|
96
|
+
createSqlOutboxStore,
|
|
97
|
+
createTransactionalOutbox,
|
|
98
|
+
} from "bcp/events";
|
|
99
|
+
|
|
100
|
+
export const outboxStore =
|
|
101
|
+
createSqlOutboxStore({
|
|
102
|
+
database: db,
|
|
103
|
+
driver: "postgresql",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export const outbox =
|
|
107
|
+
createTransactionalOutbox({
|
|
108
|
+
store: outboxStore,
|
|
109
|
+
defaultMaxAttempts: 5,
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Publish inside the business transaction
|
|
114
|
+
|
|
115
|
+
Pass the `TransactionDatabase` supplied by `db.transaction()` directly to `outbox.publish()`:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
await db.transaction(
|
|
119
|
+
async tx => {
|
|
120
|
+
await tx.execute(
|
|
121
|
+
`INSERT INTO orders (
|
|
122
|
+
id,
|
|
123
|
+
user_id,
|
|
124
|
+
total
|
|
125
|
+
) VALUES ($1, $2, $3)`,
|
|
126
|
+
[
|
|
127
|
+
order.id,
|
|
128
|
+
order.userId,
|
|
129
|
+
order.total,
|
|
130
|
+
]
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await outbox.publish(
|
|
134
|
+
tx,
|
|
135
|
+
"order.created",
|
|
136
|
+
{
|
|
137
|
+
orderId: order.id,
|
|
138
|
+
userId: order.userId,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
aggregateId:
|
|
142
|
+
String(order.id),
|
|
143
|
+
correlationId:
|
|
144
|
+
requestId,
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
For MySQL or SQLite, use the placeholder syntax expected by that database in your business SQL. `createSqlOutboxStore()` handles its own provider-specific placeholders internally.
|
|
152
|
+
|
|
153
|
+
Do not publish to Redis, RabbitMQ, HTTP or another external system from inside this transaction. Only write the outbox row there.
|
|
154
|
+
|
|
155
|
+
## Event metadata
|
|
156
|
+
|
|
157
|
+
`publish()` accepts:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
await outbox.publish(
|
|
161
|
+
tx,
|
|
162
|
+
"payment.captured",
|
|
163
|
+
payload,
|
|
164
|
+
{
|
|
165
|
+
id: eventId,
|
|
166
|
+
metadata: {
|
|
167
|
+
source: "checkout",
|
|
168
|
+
},
|
|
169
|
+
correlationId: requestId,
|
|
170
|
+
causationId: previousEventId,
|
|
171
|
+
aggregateId: paymentId,
|
|
172
|
+
delayMs: 1_000,
|
|
173
|
+
maxAttempts: 8,
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Fields have these intended uses:
|
|
179
|
+
|
|
180
|
+
- `id`: stable event/idempotency identifier.
|
|
181
|
+
- `correlationId`: groups work belonging to one request or distributed operation.
|
|
182
|
+
- `causationId`: identifies the event/command that caused this event.
|
|
183
|
+
- `aggregateId`: identifies the domain resource associated with the event.
|
|
184
|
+
- `metadata`: application-defined non-secret event metadata.
|
|
185
|
+
|
|
186
|
+
## Dispatcher
|
|
187
|
+
|
|
188
|
+
The dispatcher claims due events using a lease, delivers them, then marks the outbox row published or schedules another attempt.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import {
|
|
192
|
+
createOutboxDispatcher,
|
|
193
|
+
} from "bcp/events";
|
|
194
|
+
|
|
195
|
+
export const dispatcher =
|
|
196
|
+
createOutboxDispatcher({
|
|
197
|
+
store: outboxStore,
|
|
198
|
+
queue: jobs,
|
|
199
|
+
ownerId: "outbox-a",
|
|
200
|
+
leaseMs: 30_000,
|
|
201
|
+
batchSize: 100,
|
|
202
|
+
pollIntervalMs: 1_000,
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Start it:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
const runner =
|
|
210
|
+
dispatcher.start();
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Shutdown:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
await runner.stop();
|
|
217
|
+
await dispatcher.close();
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Durable Jobs delivery
|
|
221
|
+
|
|
222
|
+
When `queue` is configured, event type `order.created` is enqueued as:
|
|
223
|
+
|
|
224
|
+
```text
|
|
225
|
+
event.order.created
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Register the consumer normally:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
jobs.register<{
|
|
232
|
+
eventId: string;
|
|
233
|
+
type: string;
|
|
234
|
+
payload: {
|
|
235
|
+
orderId: number;
|
|
236
|
+
};
|
|
237
|
+
}>(
|
|
238
|
+
"event.order.created",
|
|
239
|
+
async ({ payload }) => {
|
|
240
|
+
await notifyOrderCreated(
|
|
241
|
+
payload.payload.orderId
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The job payload is an envelope containing:
|
|
248
|
+
|
|
249
|
+
```text
|
|
250
|
+
eventId
|
|
251
|
+
type
|
|
252
|
+
payload
|
|
253
|
+
metadata
|
|
254
|
+
correlationId
|
|
255
|
+
causationId
|
|
256
|
+
aggregateId
|
|
257
|
+
createdAt
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The queue job ID is derived from the outbox event ID:
|
|
261
|
+
|
|
262
|
+
```text
|
|
263
|
+
outbox:<event-id>
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
This provides a stable handoff identity. Durable job handlers should still be idempotent because the overall delivery model is at-least-once.
|
|
267
|
+
|
|
268
|
+
## Custom publisher
|
|
269
|
+
|
|
270
|
+
A dispatcher can publish to an application-owned broker/API instead of the BCP job queue:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
const dispatcher =
|
|
274
|
+
createOutboxDispatcher({
|
|
275
|
+
store: outboxStore,
|
|
276
|
+
publish: async event => {
|
|
277
|
+
await broker.publish(
|
|
278
|
+
event.type,
|
|
279
|
+
event
|
|
280
|
+
);
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
BCP intentionally does not require RabbitMQ, Kafka, NATS or another event-broker dependency.
|
|
286
|
+
|
|
287
|
+
## In-process event bus
|
|
288
|
+
|
|
289
|
+
For local application delivery:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import {
|
|
293
|
+
createEventBus,
|
|
294
|
+
} from "bcp/events";
|
|
295
|
+
|
|
296
|
+
const bus =
|
|
297
|
+
createEventBus();
|
|
298
|
+
|
|
299
|
+
bus.on(
|
|
300
|
+
"account.updated",
|
|
301
|
+
async ({ payload }) => {
|
|
302
|
+
await refreshAccountCache(
|
|
303
|
+
payload
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Then pass `eventBus: bus` to `createOutboxDispatcher()`.
|
|
310
|
+
|
|
311
|
+
The in-process bus is not a durable distributed broker. Use it for local handlers, tests or composition, not as a replacement for a shared queue in multi-instance deployments.
|
|
312
|
+
|
|
313
|
+
## Retry and terminal failure
|
|
314
|
+
|
|
315
|
+
A claimed event increments `attempts`.
|
|
316
|
+
|
|
317
|
+
If delivery throws and attempts remain, the dispatcher returns the event to `pending` with a future `availableAt`.
|
|
318
|
+
|
|
319
|
+
The default delay is exponential with a 60-second cap. Override it:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
createOutboxDispatcher({
|
|
323
|
+
store: outboxStore,
|
|
324
|
+
publish,
|
|
325
|
+
retryDelayMs:
|
|
326
|
+
attempt =>
|
|
327
|
+
attempt * 2_000,
|
|
328
|
+
});
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
When `maxAttempts` is exhausted the event becomes:
|
|
332
|
+
|
|
333
|
+
```text
|
|
334
|
+
failed
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
The record remains available for inspection/retention until cleanup.
|
|
338
|
+
|
|
339
|
+
## Leases and stale recovery
|
|
340
|
+
|
|
341
|
+
Claimed events use:
|
|
342
|
+
|
|
343
|
+
```text
|
|
344
|
+
state = processing
|
|
345
|
+
leaseOwner
|
|
346
|
+
leaseUntil
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
If a dispatcher crashes before acknowledgement, another dispatcher can recover the expired lease and return the event to `pending`.
|
|
350
|
+
|
|
351
|
+
Manual recovery:
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
await dispatcher.recoverStale(
|
|
355
|
+
100
|
|
356
|
+
);
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Production SQL stores perform claim/update operations through the configured BCP database transaction boundary. PostgreSQL/MySQL claims also use `FOR UPDATE SKIP LOCKED`; SQLite uses its transactional write serialization and conditional state updates.
|
|
360
|
+
|
|
361
|
+
## States
|
|
362
|
+
|
|
363
|
+
```text
|
|
364
|
+
pending
|
|
365
|
+
|
|
|
366
|
+
v
|
|
367
|
+
processing
|
|
368
|
+
| \
|
|
369
|
+
| \ delivery error + attempts left
|
|
370
|
+
| -----------------> pending
|
|
371
|
+
|
|
|
372
|
+
+--------------------> published
|
|
373
|
+
|
|
|
374
|
+
+ retry exhausted ---> failed
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
`published` means the configured dispatcher destination accepted the event. For job-queue delivery, it means enqueue succeeded; it does not mean the downstream job handler has completed.
|
|
378
|
+
|
|
379
|
+
## Statistics
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
const stats =
|
|
383
|
+
await outboxStore.stats();
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Shape:
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
{
|
|
390
|
+
total: number;
|
|
391
|
+
pending: number;
|
|
392
|
+
processing: number;
|
|
393
|
+
published: number;
|
|
394
|
+
failed: number;
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
## Retention cleanup
|
|
399
|
+
|
|
400
|
+
Remove old terminal records:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
await outboxStore.cleanup({
|
|
404
|
+
before:
|
|
405
|
+
Date.now() -
|
|
406
|
+
7 * 24 * 60 * 60 * 1000,
|
|
407
|
+
});
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Only `published` and `failed` records are eligible. Pending or processing records are preserved.
|
|
411
|
+
|
|
412
|
+
## Memory store
|
|
413
|
+
|
|
414
|
+
For tests and single-process development:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import {
|
|
418
|
+
createMemoryOutboxStore,
|
|
419
|
+
} from "bcp/events";
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
The memory store demonstrates the `OutboxStore` contract but is not transactionally durable. Its `append()` accepts the transaction parameter for API compatibility, but its data lives only in process memory.
|
|
423
|
+
|
|
424
|
+
Use `createSqlOutboxStore()` when the outbox must participate in the same durable database transaction as application writes.
|
|
425
|
+
|
|
426
|
+
## Delivery guarantees
|
|
427
|
+
|
|
428
|
+
The outbox prevents the database-write / external-publish gap, but it does not provide exactly-once side effects.
|
|
429
|
+
|
|
430
|
+
Treat delivery as:
|
|
431
|
+
|
|
432
|
+
```text
|
|
433
|
+
at-least-once
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Use stable event IDs and consumer-level idempotency for external side effects such as charging cards, sending one-time notifications or calling third-party APIs.
|
|
437
|
+
|
|
438
|
+
## Production topology
|
|
439
|
+
|
|
440
|
+
```text
|
|
441
|
+
API / Form Action
|
|
442
|
+
|
|
|
443
|
+
v
|
|
444
|
+
Database transaction
|
|
445
|
+
|
|
|
446
|
+
+-- business data
|
|
447
|
+
+-- outbox event
|
|
448
|
+
|
|
|
449
|
+
COMMIT
|
|
450
|
+
|
|
|
451
|
+
v
|
|
452
|
+
Shared SQL Outbox
|
|
453
|
+
|
|
|
454
|
+
+---+---+
|
|
455
|
+
| |
|
|
456
|
+
Dispatcher A Dispatcher B
|
|
457
|
+
|
|
|
458
|
+
v
|
|
459
|
+
Durable queue / broker
|
|
460
|
+
|
|
|
461
|
+
+---+---+
|
|
462
|
+
Worker A Worker B
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
This allows multiple API, dispatcher and worker instances without coupling the business transaction to the external broker availability window.
|