@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.
- package/README.md +199 -327
- package/docs/README.md +53 -56
- package/docs/api-manifest.json +17 -2
- package/docs/api-reference.md +94 -118
- package/docs/docs-web-manifest.json +7 -5
- package/docs/platform-manifest.json +16 -4
- package/docs/releases/0.2.12.md +147 -0
- package/docs/transactional-outbox-events.md +465 -0
- package/package.json +6 -1
- package/packages/bundler/src/client-boundary.ts +1 -0
- package/packages/client/src/events.mjs +889 -0
- package/packages/client/src/events.ts +31 -0
- package/packages/server/src/events.ts +1416 -0
|
@@ -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.
|
|
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",
|