@chidchanun/bcp 0.2.11 → 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.
@@ -0,0 +1,147 @@
1
+ # BCP Framework 0.2.12 — Transactional Outbox & Events
2
+
3
+ Release state: unreleased development target.
4
+
5
+ `0.2.12` adds a server-only transactional outbox and event-delivery platform that bridges BCP Database transactions with durable jobs or application-owned publishers without performing external side effects inside the business transaction.
6
+
7
+ ## New public entrypoint
8
+
9
+ ```ts
10
+ import {
11
+ createEventBus,
12
+ createMemoryOutboxStore,
13
+ createOutboxDispatcher,
14
+ createOutboxMigrationSql,
15
+ createSqlOutboxStore,
16
+ createTransactionalOutbox,
17
+ } from "bcp/events";
18
+ ```
19
+
20
+ `bcp/events` is server-only and receives the same browser/client boundary protection as `bcp/database`, `bcp/jobs` and `bcp/workflow`.
21
+
22
+ ## Transaction-bound outbox writes
23
+
24
+ Application code can insert business rows and outbox rows through the same `TransactionDatabase`:
25
+
26
+ ```ts
27
+ await db.transaction(async tx => {
28
+ await tx.execute(
29
+ "INSERT INTO orders ..."
30
+ );
31
+
32
+ await outbox.publish(
33
+ tx,
34
+ "order.created",
35
+ {
36
+ orderId: 42,
37
+ }
38
+ );
39
+ });
40
+ ```
41
+
42
+ `createSqlOutboxStore()` writes through the caller transaction rather than the root database connection.
43
+
44
+ ## SQL providers
45
+
46
+ Provider-specific migration SQL is available for:
47
+
48
+ ```text
49
+ mysql
50
+ postgresql
51
+ sqlite
52
+ ```
53
+
54
+ The SQL store persists:
55
+
56
+ - event ID/type/payload,
57
+ - metadata,
58
+ - correlation/causation/aggregate IDs,
59
+ - state,
60
+ - attempts/max attempts,
61
+ - created/available/processing/published/failed timestamps,
62
+ - delivery errors,
63
+ - lease owner/expiry.
64
+
65
+ ## Dispatcher
66
+
67
+ `createOutboxDispatcher()` adds:
68
+
69
+ - batched claiming,
70
+ - dispatcher leases,
71
+ - stale-lease recovery,
72
+ - retry/backoff,
73
+ - terminal failed state,
74
+ - durable job-queue handoff,
75
+ - custom publisher delivery,
76
+ - in-process EventBus delivery,
77
+ - polling runner lifecycle,
78
+ - cleanup/statistics through the store.
79
+
80
+ ## Durable job handoff
81
+
82
+ With `queue: jobs`, event type:
83
+
84
+ ```text
85
+ order.created
86
+ ```
87
+
88
+ is handed off as:
89
+
90
+ ```text
91
+ event.order.created
92
+ ```
93
+
94
+ The queued payload contains the event envelope and uses stable job ID:
95
+
96
+ ```text
97
+ outbox:<event-id>
98
+ ```
99
+
100
+ A successful enqueue marks the outbox event `published`. Downstream job completion remains the responsibility of the job runtime.
101
+
102
+ ## Event bus
103
+
104
+ `createEventBus()` provides local server-side handler registration and sequential event delivery for tests, local composition and single-process handlers.
105
+
106
+ It is not a distributed durable broker.
107
+
108
+ ## States
109
+
110
+ ```text
111
+ pending
112
+ processing
113
+ published
114
+ failed
115
+ ```
116
+
117
+ Expired `processing` leases can return to `pending` for another dispatcher attempt.
118
+
119
+ ## Delivery semantics
120
+
121
+ The platform closes the database-commit / external-publish gap but intentionally uses at-least-once delivery semantics.
122
+
123
+ Consumers should use stable event IDs and application-level idempotency when duplicate external side effects are unsafe.
124
+
125
+ ## Backward compatibility
126
+
127
+ `0.2.12` is additive relative to `0.2.11`:
128
+
129
+ - existing `bcp/jobs` APIs remain unchanged,
130
+ - existing `bcp/workflow` APIs remain unchanged,
131
+ - no database adapter contract method was removed,
132
+ - existing applications do not need an outbox unless they opt into `bcp/events`.
133
+
134
+ ## Validation
135
+
136
+ The release candidate must pass:
137
+
138
+ ```bash
139
+ npm run typecheck
140
+ npm run test:unit
141
+ npm run test:integration
142
+ npm run test:e2e
143
+ npm run test:package
144
+ npm run rc:check
145
+ ```
146
+
147
+ Package validation executes the compiled `events.mjs` runtime and verifies the `bcp/events` export, transaction/outbox API, dispatcher delivery and migration helper.
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -72,6 +72,11 @@
72
72
  "browser": "./packages/client/src/server-only.browser.mjs",
73
73
  "default": "./packages/client/src/workflow.mjs"
74
74
  },
75
+ "./events": {
76
+ "types": "./packages/client/src/events.ts",
77
+ "browser": "./packages/client/src/server-only.browser.mjs",
78
+ "default": "./packages/client/src/events.mjs"
79
+ },
75
80
  "./observability": {
76
81
  "types": "./packages/client/src/observability.ts",
77
82
  "browser": "./packages/client/src/server-only.browser.mjs",
@@ -30,6 +30,7 @@ const SERVER_ONLY_IMPORTS =
30
30
  "bcp/auth",
31
31
  "bcp/jobs",
32
32
  "bcp/workflow",
33
+ "bcp/events",
33
34
  "bcp/observability",
34
35
  ]);
35
36