@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,122 @@
1
+ # BCP Framework 0.2.13
2
+
3
+ Status: unreleased development target.
4
+
5
+ ## Realtime Platform
6
+
7
+ `0.2.13` introduces `bcp/realtime`, a server-only provider-neutral realtime layer for channels/rooms, presence, cross-hub broker delivery, WebSocket adapter integration, SSE responses and heartbeat lifecycle.
8
+
9
+ ## Public API
10
+
11
+ New entrypoint:
12
+
13
+ ```ts
14
+ import {
15
+ createMemoryRealtimeBroker,
16
+ createMemoryRealtimePresenceStore,
17
+ createRealtime,
18
+ createRealtimeSseResponse,
19
+ } from "bcp/realtime";
20
+ ```
21
+
22
+ Key contracts:
23
+
24
+ - `RealtimeHub`
25
+ - `RealtimeConnection`
26
+ - `RealtimeBroker`
27
+ - `RealtimePresenceStore`
28
+ - `RealtimeSocket`
29
+ - `RealtimeEnvelope`
30
+ - `RealtimeEventHandler`
31
+ - `RealtimeAuthenticate`
32
+ - `RealtimeAuthorizeChannel`
33
+
34
+ ## Channels and presence
35
+
36
+ Connections can join/leave named channels and attach typed presence metadata. Hub broadcasts deliver only to local connections that joined the channel, while `RealtimeBroker` distributes envelopes across hubs.
37
+
38
+ `createMemoryRealtimeBroker()` and `createMemoryRealtimePresenceStore()` provide deterministic local/test implementations.
39
+
40
+ ## Authentication and authorization
41
+
42
+ `createRealtime()` accepts:
43
+
44
+ - `authenticate` for request/data-based connection identity,
45
+ - `getUserId` for presence identity,
46
+ - `authorizeChannel` for private-channel join checks.
47
+
48
+ Authentication policy remains application-owned so public and authenticated realtime endpoints can share the same runtime primitives.
49
+
50
+ ## WebSocket integration
51
+
52
+ BCP does not add a mandatory WebSocket dependency. `RealtimeSocket` defines the minimal transport surface required by `attachSocket()`.
53
+
54
+ The built-in JSON protocol supports:
55
+
56
+ - `join`
57
+ - `leave`
58
+ - `event`
59
+ - `ping`
60
+
61
+ Valid client messages refresh connection liveness. Ping receives `realtime.pong`.
62
+
63
+ ## Server-Sent Events
64
+
65
+ `RealtimeHub.sse()` and `createRealtimeSseResponse()` produce Web-standard streaming `Response` objects with event-stream headers, optional retry hints, event filtering and keep-alive comments.
66
+
67
+ ## Heartbeat lifecycle
68
+
69
+ `startHeartbeat()` periodically sends `realtime.ping` and sweeps stale connections. `sweepStale()` can also be invoked manually for deterministic infrastructure loops and tests.
70
+
71
+ ## Multi-instance contract
72
+
73
+ The memory broker and presence store are process-local.
74
+
75
+ Production multi-instance applications can implement shared `RealtimeBroker` and `RealtimePresenceStore` adapters using infrastructure such as Redis, NATS or another service without coupling BCP to one provider/client library.
76
+
77
+ ## Integration model
78
+
79
+ Realtime complements, but does not replace, durable platform primitives:
80
+
81
+ ```text
82
+ Database / Transactional Outbox
83
+ |
84
+ v
85
+ Durable Jobs / Workflow
86
+ |
87
+ v
88
+ Realtime Hub
89
+ |
90
+ WebSocket / SSE
91
+ ```
92
+
93
+ Realtime broadcasts are transient. Applications that require reconnect catch-up should refetch durable state/history after reconnect.
94
+
95
+ ## Package/runtime
96
+
97
+ - `bcp/realtime` is server-only.
98
+ - Client page/island graphs reject direct realtime imports.
99
+ - publish preparation compiles `packages/client/src/realtime.ts` to `realtime.mjs`.
100
+ - no new runtime dependency is required.
101
+
102
+ ## Validation
103
+
104
+ The release adds unit/package coverage for:
105
+
106
+ - cross-hub broker broadcasts,
107
+ - channel membership,
108
+ - presence metadata,
109
+ - authentication identity,
110
+ - channel authorization,
111
+ - socket join/event/ping protocol,
112
+ - stale heartbeat cleanup,
113
+ - SSE streaming,
114
+ - server-only boundary enforcement,
115
+ - compiled package runtime execution,
116
+ - public-entrypoint manifest parity.
117
+
118
+ ## Compatibility
119
+
120
+ Previous baseline: `0.2.12`.
121
+
122
+ There are no intentional breaking changes from `0.2.12`.
@@ -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.13",
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,16 @@
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
+ },
80
+ "./realtime": {
81
+ "types": "./packages/client/src/realtime.ts",
82
+ "browser": "./packages/client/src/server-only.browser.mjs",
83
+ "default": "./packages/client/src/realtime.mjs"
84
+ },
75
85
  "./observability": {
76
86
  "types": "./packages/client/src/observability.ts",
77
87
  "browser": "./packages/client/src/server-only.browser.mjs",
@@ -30,6 +30,8 @@ const SERVER_ONLY_IMPORTS =
30
30
  "bcp/auth",
31
31
  "bcp/jobs",
32
32
  "bcp/workflow",
33
+ "bcp/events",
34
+ "bcp/realtime",
33
35
  "bcp/observability",
34
36
  ]);
35
37