@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.
- package/README.md +228 -401
- package/docs/README.md +51 -68
- package/docs/api-manifest.json +31 -63
- package/docs/api-reference.md +103 -118
- package/docs/docs-web-manifest.json +9 -5
- package/docs/platform-manifest.json +27 -4
- package/docs/realtime-platform.md +447 -0
- package/docs/releases/0.2.12.md +147 -0
- package/docs/releases/0.2.13.md +122 -0
- package/docs/transactional-outbox-events.md +465 -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/realtime.mjs +936 -0
- package/packages/client/src/realtime.ts +31 -0
- package/packages/server/src/events.ts +1416 -0
- package/packages/server/src/realtime.ts +1464 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"framework": "bcp",
|
|
4
|
-
"versionTarget": "0.2.
|
|
4
|
+
"versionTarget": "0.2.13",
|
|
5
5
|
"releaseState": "unreleased",
|
|
6
6
|
"sections": [
|
|
7
7
|
{
|
|
@@ -48,16 +48,17 @@
|
|
|
48
48
|
{
|
|
49
49
|
"id": "database",
|
|
50
50
|
"title": "Database",
|
|
51
|
-
"description": "Provider-neutral MySQL, PostgreSQL and SQLite primitives, lifecycle and
|
|
51
|
+
"description": "Provider-neutral MySQL, PostgreSQL and SQLite primitives, lifecycle, migrations and transactional outbox integration.",
|
|
52
52
|
"pages": [
|
|
53
53
|
{ "route": "/docs/database", "source": "database.md", "title": "Database" },
|
|
54
|
-
{ "route": "/docs/database-migrations", "source": "database-migrations.md", "title": "Database Migrations" }
|
|
54
|
+
{ "route": "/docs/database-migrations", "source": "database-migrations.md", "title": "Database Migrations" },
|
|
55
|
+
{ "route": "/docs/transactional-outbox-events", "source": "transactional-outbox-events.md", "title": "Transactional Outbox & Events" }
|
|
55
56
|
]
|
|
56
57
|
},
|
|
57
58
|
{
|
|
58
59
|
"id": "runtime",
|
|
59
60
|
"title": "Runtime & Infrastructure",
|
|
60
|
-
"description": "Middleware,
|
|
61
|
+
"description": "Middleware, jobs, scheduling, workflows, event delivery, realtime channels, observability, caching, security and production hardening.",
|
|
61
62
|
"pages": [
|
|
62
63
|
{ "route": "/docs/middleware", "source": "middleware.md", "title": "Middleware" },
|
|
63
64
|
{ "route": "/docs/hydration", "source": "hydration.md", "title": "Hydration" },
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
{ "route": "/docs/job-scheduling", "source": "job-scheduling.md", "title": "Job Scheduling Platform" },
|
|
68
69
|
{ "route": "/docs/durable-jobs", "source": "durable-jobs.md", "title": "Durable Jobs Platform" },
|
|
69
70
|
{ "route": "/docs/workflow-orchestration", "source": "workflow-orchestration.md", "title": "Workflow Orchestration" },
|
|
71
|
+
{ "route": "/docs/realtime-platform", "source": "realtime-platform.md", "title": "Realtime Platform" },
|
|
70
72
|
{ "route": "/docs/caching", "source": "caching.md", "title": "Caching" },
|
|
71
73
|
{ "route": "/docs/security", "source": "security.md", "title": "Security" },
|
|
72
74
|
{ "route": "/docs/production-hardening", "source": "production-hardening.md", "title": "Production Hardening" }
|
|
@@ -112,7 +114,9 @@
|
|
|
112
114
|
}
|
|
113
115
|
],
|
|
114
116
|
"releases": [
|
|
115
|
-
{ "route": "/releases/0.2.
|
|
117
|
+
{ "route": "/releases/0.2.13", "source": "releases/0.2.13.md", "version": "0.2.13", "state": "unreleased" },
|
|
118
|
+
{ "route": "/releases/0.2.12", "source": "releases/0.2.12.md", "version": "0.2.12" },
|
|
119
|
+
{ "route": "/releases/0.2.11", "source": "releases/0.2.11.md", "version": "0.2.11" },
|
|
116
120
|
{ "route": "/releases/0.2.10", "source": "releases/0.2.10.md", "version": "0.2.10" },
|
|
117
121
|
{ "route": "/releases/0.2.9", "source": "releases/0.2.9.md", "version": "0.2.9" },
|
|
118
122
|
{ "route": "/releases/0.2.8", "source": "releases/0.2.8.md", "version": "0.2.8" },
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"framework": "bcp",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.13",
|
|
5
5
|
"releaseState": "unreleased",
|
|
6
|
-
"baseline": "
|
|
6
|
+
"baseline": "realtime-platform",
|
|
7
7
|
"runtime": {
|
|
8
8
|
"node": ">=24.11.0",
|
|
9
9
|
"react": "19",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"bcp/auth",
|
|
22
22
|
"bcp/jobs",
|
|
23
23
|
"bcp/workflow",
|
|
24
|
+
"bcp/events",
|
|
25
|
+
"bcp/realtime",
|
|
24
26
|
"bcp/observability",
|
|
25
27
|
"bcp/server",
|
|
26
28
|
"bcp/server-only",
|
|
@@ -100,6 +102,25 @@
|
|
|
100
102
|
"workflowDelays": true,
|
|
101
103
|
"workflowCompensation": true,
|
|
102
104
|
"workflowQueueExecution": true,
|
|
105
|
+
"transactionalOutboxEvents": true,
|
|
106
|
+
"outboxStoreContract": true,
|
|
107
|
+
"sqlOutboxStore": true,
|
|
108
|
+
"outboxDispatcher": true,
|
|
109
|
+
"outboxDispatcherLeases": true,
|
|
110
|
+
"outboxRetryRecovery": true,
|
|
111
|
+
"outboxRetentionCleanup": true,
|
|
112
|
+
"outboxStatistics": true,
|
|
113
|
+
"eventBus": true,
|
|
114
|
+
"outboxJobDelivery": true,
|
|
115
|
+
"realtimePlatform": true,
|
|
116
|
+
"realtimeBrokerContract": true,
|
|
117
|
+
"realtimePresenceStore": true,
|
|
118
|
+
"realtimeChannels": true,
|
|
119
|
+
"realtimeChannelAuthorization": true,
|
|
120
|
+
"realtimeSocketAdapter": true,
|
|
121
|
+
"realtimeServerSentEvents": true,
|
|
122
|
+
"realtimeHeartbeats": true,
|
|
123
|
+
"realtimeCrossHubBroadcast": true,
|
|
103
124
|
"databaseMigrations": true,
|
|
104
125
|
"databaseAdapterContract": true,
|
|
105
126
|
"databasePostgresql": true,
|
|
@@ -139,7 +160,7 @@
|
|
|
139
160
|
"s3-compatible"
|
|
140
161
|
],
|
|
141
162
|
"compatibility": {
|
|
142
|
-
"previousBaseline": "0.2.
|
|
163
|
+
"previousBaseline": "0.2.12",
|
|
143
164
|
"intentionalBreakingChangesFromPreviousBaseline": false,
|
|
144
165
|
"migrationGuide": "migration-0.2.md"
|
|
145
166
|
},
|
|
@@ -160,7 +181,9 @@
|
|
|
160
181
|
"jobScheduling": "job-scheduling.md",
|
|
161
182
|
"durableJobs": "durable-jobs.md",
|
|
162
183
|
"workflowOrchestration": "workflow-orchestration.md",
|
|
184
|
+
"transactionalOutboxEvents": "transactional-outbox-events.md",
|
|
185
|
+
"realtimePlatform": "realtime-platform.md",
|
|
163
186
|
"migrationGuide": "migration-0.2.md",
|
|
164
|
-
"releaseNotes": "releases/0.2.
|
|
187
|
+
"releaseNotes": "releases/0.2.13.md"
|
|
165
188
|
}
|
|
166
189
|
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# Realtime Platform
|
|
2
|
+
|
|
3
|
+
BCP Framework `0.2.13` adds the server-only `bcp/realtime` entrypoint for application realtime delivery without forcing one WebSocket or pub/sub provider.
|
|
4
|
+
|
|
5
|
+
The platform separates four concerns:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
connection/session
|
|
9
|
+
|
|
|
10
|
+
+--> channel membership
|
|
11
|
+
+--> authentication / authorization
|
|
12
|
+
+--> heartbeat
|
|
13
|
+
|
|
|
14
|
+
v
|
|
15
|
+
RealtimeHub
|
|
16
|
+
|
|
|
17
|
+
+--> RealtimeBroker
|
|
18
|
+
| -> cross-hub delivery
|
|
19
|
+
|
|
|
20
|
+
+--> RealtimePresenceStore
|
|
21
|
+
| -> room/channel presence
|
|
22
|
+
|
|
|
23
|
+
+--> RealtimeSocket adapter
|
|
24
|
+
| -> WebSocket provider integration
|
|
25
|
+
|
|
|
26
|
+
+--> SSE Response
|
|
27
|
+
-> built-in HTTP streaming
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Create a hub
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import {
|
|
34
|
+
createRealtime,
|
|
35
|
+
} from "bcp/realtime";
|
|
36
|
+
|
|
37
|
+
export const realtime =
|
|
38
|
+
createRealtime();
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The default broker and presence store are process-local and intended for development, tests and single-process deployments.
|
|
42
|
+
|
|
43
|
+
## Channels and rooms
|
|
44
|
+
|
|
45
|
+
BCP treats rooms as named channels.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const connection =
|
|
49
|
+
await realtime.connect();
|
|
50
|
+
|
|
51
|
+
await connection.join(
|
|
52
|
+
"orders:42"
|
|
53
|
+
);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Broadcast to everyone currently joined:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
await realtime.broadcast(
|
|
60
|
+
"orders:42",
|
|
61
|
+
"order.updated",
|
|
62
|
+
{
|
|
63
|
+
status: "paid",
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
You can exclude one connection when echo suppression is needed:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
await realtime.broadcast(
|
|
72
|
+
"chat:general",
|
|
73
|
+
"chat.message",
|
|
74
|
+
message,
|
|
75
|
+
{
|
|
76
|
+
excludeConnectionId:
|
|
77
|
+
sender.id,
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Presence
|
|
83
|
+
|
|
84
|
+
Presence is stored separately from message delivery.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
await connection.join(
|
|
88
|
+
"project:7",
|
|
89
|
+
{
|
|
90
|
+
presence: {
|
|
91
|
+
status: "online",
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const members =
|
|
97
|
+
await realtime.members(
|
|
98
|
+
"project:7"
|
|
99
|
+
);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The default `createMemoryRealtimePresenceStore()` is local-only. Multi-instance deployments should use a shared store whose `join`, `leave`, `leaveConnection`, `touch` and `list` operations are visible to every instance.
|
|
103
|
+
|
|
104
|
+
## Authentication
|
|
105
|
+
|
|
106
|
+
A realtime connection may be authenticated when it is created:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const realtime =
|
|
110
|
+
createRealtime({
|
|
111
|
+
authenticate:
|
|
112
|
+
async ({ request }) => {
|
|
113
|
+
if (!request) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return loadRealtimeUser(
|
|
118
|
+
request
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
getUserId:
|
|
123
|
+
user => user.id,
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The authenticated value is available as:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
connection.user
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
BCP does not automatically turn a failed authentication callback into a rejected connection. Applications that require authentication should enforce that policy in their authentication callback/adapter or channel authorization policy.
|
|
134
|
+
|
|
135
|
+
## Channel authorization
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const realtime =
|
|
139
|
+
createRealtime({
|
|
140
|
+
authorizeChannel:
|
|
141
|
+
async ({
|
|
142
|
+
connection,
|
|
143
|
+
channel,
|
|
144
|
+
}) => {
|
|
145
|
+
if (
|
|
146
|
+
channel.startsWith(
|
|
147
|
+
"private:"
|
|
148
|
+
)
|
|
149
|
+
) {
|
|
150
|
+
return Boolean(
|
|
151
|
+
connection.user
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return true;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Authorization is evaluated before a connection joins a channel.
|
|
161
|
+
|
|
162
|
+
## Client-originated events
|
|
163
|
+
|
|
164
|
+
Register a server event handler:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
realtime.on(
|
|
168
|
+
"chat.message",
|
|
169
|
+
async ({
|
|
170
|
+
connection,
|
|
171
|
+
channel,
|
|
172
|
+
payload,
|
|
173
|
+
}) => {
|
|
174
|
+
await saveMessage(
|
|
175
|
+
payload
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await realtime.broadcast(
|
|
179
|
+
channel,
|
|
180
|
+
"chat.message",
|
|
181
|
+
payload,
|
|
182
|
+
{
|
|
183
|
+
excludeConnectionId:
|
|
184
|
+
connection.id,
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Calling `connection.emit()` runs registered server handlers. It does not automatically broadcast the message. This keeps application authorization and mutation logic explicit.
|
|
192
|
+
|
|
193
|
+
## WebSocket adapter contract
|
|
194
|
+
|
|
195
|
+
BCP does not install `ws`, Socket.IO, uWebSockets.js or another WebSocket server package.
|
|
196
|
+
|
|
197
|
+
Adapt your selected provider to this shape:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
interface RealtimeSocket {
|
|
201
|
+
send(
|
|
202
|
+
data: string
|
|
203
|
+
): void | Promise<void>;
|
|
204
|
+
|
|
205
|
+
close?(
|
|
206
|
+
code?: number,
|
|
207
|
+
reason?: string
|
|
208
|
+
): void | Promise<void>;
|
|
209
|
+
|
|
210
|
+
onMessage(
|
|
211
|
+
listener: (
|
|
212
|
+
data: string
|
|
213
|
+
) => void | Promise<void>
|
|
214
|
+
): () => void;
|
|
215
|
+
|
|
216
|
+
onClose(
|
|
217
|
+
listener: () =>
|
|
218
|
+
void | Promise<void>
|
|
219
|
+
): () => void;
|
|
220
|
+
|
|
221
|
+
onError?(
|
|
222
|
+
listener: (
|
|
223
|
+
error: unknown
|
|
224
|
+
) => void | Promise<void>
|
|
225
|
+
): () => void;
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Attach it:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
const connection =
|
|
233
|
+
await realtime.attachSocket(
|
|
234
|
+
socketAdapter,
|
|
235
|
+
{
|
|
236
|
+
request,
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Socket message protocol
|
|
242
|
+
|
|
243
|
+
Inbound JSON messages use these shapes:
|
|
244
|
+
|
|
245
|
+
```json
|
|
246
|
+
{
|
|
247
|
+
"type": "join",
|
|
248
|
+
"channel": "chat:general",
|
|
249
|
+
"presence": {
|
|
250
|
+
"status": "online"
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```json
|
|
256
|
+
{
|
|
257
|
+
"type": "leave",
|
|
258
|
+
"channel": "chat:general"
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
```json
|
|
263
|
+
{
|
|
264
|
+
"type": "event",
|
|
265
|
+
"channel": "chat:general",
|
|
266
|
+
"event": "chat.message",
|
|
267
|
+
"payload": {
|
|
268
|
+
"text": "hello"
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Heartbeat input:
|
|
274
|
+
|
|
275
|
+
```json
|
|
276
|
+
{
|
|
277
|
+
"type": "ping"
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The server answers with a `realtime.pong` event.
|
|
282
|
+
|
|
283
|
+
## Server-Sent Events
|
|
284
|
+
|
|
285
|
+
SSE does not require a WebSocket provider.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
export function GET(
|
|
289
|
+
request: Request
|
|
290
|
+
) {
|
|
291
|
+
return realtime.sse(
|
|
292
|
+
"jobs:42",
|
|
293
|
+
{
|
|
294
|
+
signal:
|
|
295
|
+
request.signal,
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Equivalent standalone helper:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
import {
|
|
305
|
+
createRealtimeSseResponse,
|
|
306
|
+
} from "bcp/realtime";
|
|
307
|
+
|
|
308
|
+
return createRealtimeSseResponse(
|
|
309
|
+
realtime,
|
|
310
|
+
"jobs:42",
|
|
311
|
+
{
|
|
312
|
+
signal:
|
|
313
|
+
request.signal,
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
The response includes:
|
|
319
|
+
|
|
320
|
+
```text
|
|
321
|
+
Content-Type: text/event-stream
|
|
322
|
+
Cache-Control: no-cache, no-transform
|
|
323
|
+
Connection: keep-alive
|
|
324
|
+
X-Accel-Buffering: no
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Optional settings include `retryMs`, `keepAliveMs`, an event-name filter and additional response headers.
|
|
328
|
+
|
|
329
|
+
## Heartbeat and stale connections
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
const realtime =
|
|
333
|
+
createRealtime({
|
|
334
|
+
heartbeatTimeoutMs:
|
|
335
|
+
60_000,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const heartbeat =
|
|
339
|
+
realtime.startHeartbeat({
|
|
340
|
+
intervalMs: 20_000,
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The heartbeat runner sends `realtime.ping` and calls `sweepStale()` periodically.
|
|
345
|
+
|
|
346
|
+
A client `ping` message or any valid socket message updates the connection's `lastSeenAt` timestamp.
|
|
347
|
+
|
|
348
|
+
Shutdown:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
await heartbeat.stop();
|
|
352
|
+
await realtime.close();
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
## Cross-instance broker
|
|
356
|
+
|
|
357
|
+
`RealtimeBroker` is the pub/sub boundary:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
interface RealtimeBroker {
|
|
361
|
+
publish(
|
|
362
|
+
message: RealtimeEnvelope
|
|
363
|
+
): Promise<void>;
|
|
364
|
+
|
|
365
|
+
subscribe(
|
|
366
|
+
listener: (
|
|
367
|
+
message: RealtimeEnvelope
|
|
368
|
+
) => void | Promise<void>
|
|
369
|
+
): () => void;
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
The built-in memory broker can be shared by multiple hubs in one process and is useful for tests.
|
|
374
|
+
|
|
375
|
+
For multiple Node processes or containers, implement the broker using shared infrastructure such as Redis Pub/Sub, NATS or another application-selected service.
|
|
376
|
+
|
|
377
|
+
A production topology may look like:
|
|
378
|
+
|
|
379
|
+
```text
|
|
380
|
+
Browser A -> App A ----\
|
|
381
|
+
Shared Broker
|
|
382
|
+
Browser B -> App B ----/
|
|
383
|
+
| |
|
|
384
|
+
+---- Shared Presence Store
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
BCP intentionally does not install or own a Redis/NATS connection.
|
|
388
|
+
|
|
389
|
+
## Jobs, workflows and events
|
|
390
|
+
|
|
391
|
+
Realtime is designed to be the delivery edge for existing backend systems.
|
|
392
|
+
|
|
393
|
+
Job progress:
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
await realtime.broadcast(
|
|
397
|
+
`jobs:${job.id}`,
|
|
398
|
+
"job.progress",
|
|
399
|
+
{
|
|
400
|
+
progress: 60,
|
|
401
|
+
}
|
|
402
|
+
);
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Workflow progress:
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
await realtime.broadcast(
|
|
409
|
+
`workflow:${run.id}`,
|
|
410
|
+
"workflow.updated",
|
|
411
|
+
run
|
|
412
|
+
);
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Outbox/event delivery can broadcast after durable publication/consumption according to application semantics.
|
|
416
|
+
|
|
417
|
+
Do not treat a transient realtime broadcast as a durable event store. Important business events should continue to use the transactional outbox/durable queue path.
|
|
418
|
+
|
|
419
|
+
## Delivery semantics
|
|
420
|
+
|
|
421
|
+
Realtime delivery is transient. A disconnected client can miss a broadcast.
|
|
422
|
+
|
|
423
|
+
Use:
|
|
424
|
+
|
|
425
|
+
- database/outbox for durable business facts,
|
|
426
|
+
- jobs/workflows for durable background execution,
|
|
427
|
+
- realtime for live client delivery.
|
|
428
|
+
|
|
429
|
+
For reconnect catch-up, expose application state or an event history endpoint and let the client refetch after reconnecting.
|
|
430
|
+
|
|
431
|
+
## Security
|
|
432
|
+
|
|
433
|
+
`bcp/realtime` is server-only.
|
|
434
|
+
|
|
435
|
+
Applications should:
|
|
436
|
+
|
|
437
|
+
- authenticate connections where required,
|
|
438
|
+
- authorize every private channel join,
|
|
439
|
+
- validate client event payloads,
|
|
440
|
+
- rate-limit untrusted client messages when appropriate,
|
|
441
|
+
- avoid placing secrets in broadcast payloads,
|
|
442
|
+
- use TLS (`wss:` / HTTPS) in production,
|
|
443
|
+
- apply origin checks at the WebSocket upgrade boundary when browser-origin restrictions are required.
|
|
444
|
+
|
|
445
|
+
## 0.2.13 scope
|
|
446
|
+
|
|
447
|
+
`0.2.13` provides the provider-neutral realtime runtime contract. It does not add a built-in Redis broker, WebSocket server dependency, durable message replay or browser client SDK.
|
|
@@ -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.
|