@quilla-be-kit/messaging 0.2.0
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/LICENSE +21 -0
- package/README.md +620 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/event-bus/defaults.d.ts +5 -0
- package/dist/event-bus/defaults.d.ts.map +1 -0
- package/dist/event-bus/defaults.js +5 -0
- package/dist/event-bus/defaults.js.map +1 -0
- package/dist/event-bus/event-bus-consumer.interface.d.ts +8 -0
- package/dist/event-bus/event-bus-consumer.interface.d.ts.map +1 -0
- package/dist/event-bus/event-bus-consumer.interface.js +2 -0
- package/dist/event-bus/event-bus-consumer.interface.js.map +1 -0
- package/dist/event-bus/event-bus-entry.type.d.ts +19 -0
- package/dist/event-bus/event-bus-entry.type.d.ts.map +1 -0
- package/dist/event-bus/event-bus-entry.type.js +2 -0
- package/dist/event-bus/event-bus-entry.type.js.map +1 -0
- package/dist/event-bus/event-bus-publisher.interface.d.ts +14 -0
- package/dist/event-bus/event-bus-publisher.interface.d.ts.map +1 -0
- package/dist/event-bus/event-bus-publisher.interface.js +2 -0
- package/dist/event-bus/event-bus-publisher.interface.js.map +1 -0
- package/dist/event-bus/event-subscription.interface.d.ts +7 -0
- package/dist/event-bus/event-subscription.interface.d.ts.map +1 -0
- package/dist/event-bus/event-subscription.interface.js +2 -0
- package/dist/event-bus/event-subscription.interface.js.map +1 -0
- package/dist/event-bus/event.consumer.d.ts +66 -0
- package/dist/event-bus/event.consumer.d.ts.map +1 -0
- package/dist/event-bus/event.consumer.js +251 -0
- package/dist/event-bus/event.consumer.js.map +1 -0
- package/dist/event-bus/event.descriptor.d.ts +9 -0
- package/dist/event-bus/event.descriptor.d.ts.map +1 -0
- package/dist/event-bus/event.descriptor.js +7 -0
- package/dist/event-bus/event.descriptor.js.map +1 -0
- package/dist/event-bus/handler-entry.type.d.ts +8 -0
- package/dist/event-bus/handler-entry.type.d.ts.map +1 -0
- package/dist/event-bus/handler-entry.type.js +2 -0
- package/dist/event-bus/handler-entry.type.js.map +1 -0
- package/dist/event-bus/index.d.ts +10 -0
- package/dist/event-bus/index.d.ts.map +1 -0
- package/dist/event-bus/index.js +4 -0
- package/dist/event-bus/index.js.map +1 -0
- package/dist/event-bus/standard-schema.type.d.ts +33 -0
- package/dist/event-bus/standard-schema.type.d.ts.map +1 -0
- package/dist/event-bus/standard-schema.type.js +2 -0
- package/dist/event-bus/standard-schema.type.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/local-outbox/index.d.ts +6 -0
- package/dist/local-outbox/index.d.ts.map +1 -0
- package/dist/local-outbox/index.js +2 -0
- package/dist/local-outbox/index.js.map +1 -0
- package/dist/local-outbox/local-outbox-entry.type.d.ts +28 -0
- package/dist/local-outbox/local-outbox-entry.type.d.ts.map +1 -0
- package/dist/local-outbox/local-outbox-entry.type.js +2 -0
- package/dist/local-outbox/local-outbox-entry.type.js.map +1 -0
- package/dist/local-outbox/local-outbox-reader.interface.d.ts +8 -0
- package/dist/local-outbox/local-outbox-reader.interface.d.ts.map +1 -0
- package/dist/local-outbox/local-outbox-reader.interface.js +2 -0
- package/dist/local-outbox/local-outbox-reader.interface.js.map +1 -0
- package/dist/local-outbox/local-outbox-writer.interface.d.ts +6 -0
- package/dist/local-outbox/local-outbox-writer.interface.d.ts.map +1 -0
- package/dist/local-outbox/local-outbox-writer.interface.js +2 -0
- package/dist/local-outbox/local-outbox-writer.interface.js.map +1 -0
- package/dist/local-outbox/outbox.forwarder.d.ts +34 -0
- package/dist/local-outbox/outbox.forwarder.d.ts.map +1 -0
- package/dist/local-outbox/outbox.forwarder.js +109 -0
- package/dist/local-outbox/outbox.forwarder.js.map +1 -0
- package/dist/local-outbox/transaction-handle.interface.d.ts +6 -0
- package/dist/local-outbox/transaction-handle.interface.d.ts.map +1 -0
- package/dist/local-outbox/transaction-handle.interface.js +2 -0
- package/dist/local-outbox/transaction-handle.interface.js.map +1 -0
- package/dist/postgres/get-postgres-schema.d.ts +6 -0
- package/dist/postgres/get-postgres-schema.d.ts.map +1 -0
- package/dist/postgres/get-postgres-schema.js +56 -0
- package/dist/postgres/get-postgres-schema.js.map +1 -0
- package/dist/postgres/index.d.ts +4 -0
- package/dist/postgres/index.d.ts.map +1 -0
- package/dist/postgres/index.js +4 -0
- package/dist/postgres/index.js.map +1 -0
- package/dist/postgres/pg-event-bus.d.ts +25 -0
- package/dist/postgres/pg-event-bus.d.ts.map +1 -0
- package/dist/postgres/pg-event-bus.js +115 -0
- package/dist/postgres/pg-event-bus.js.map +1 -0
- package/dist/postgres/pg-local-outbox.d.ts +26 -0
- package/dist/postgres/pg-local-outbox.d.ts.map +1 -0
- package/dist/postgres/pg-local-outbox.js +102 -0
- package/dist/postgres/pg-local-outbox.js.map +1 -0
- package/package.json +73 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Max Martinez and quilla-kit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
# @quilla-be-kit/messaging
|
|
2
|
+
|
|
3
|
+
Broker-agnostic messaging for substrate-grade TypeScript services. Ships the
|
|
4
|
+
durable-event flow you reach for in production — **local outbox → event bus →
|
|
5
|
+
consumer with retries** — using an atomic claim pattern so multi-replica
|
|
6
|
+
deployments scale without coordination or configuration.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pnpm add @quilla-be-kit/messaging
|
|
10
|
+
# For the Postgres reference:
|
|
11
|
+
pnpm add pg
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Node 22+, ESM-only.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Single-subscriber constraint
|
|
19
|
+
|
|
20
|
+
The bus is a **worker queue**: each event is claimed and handled by **exactly
|
|
21
|
+
one consumer-replica across the entire deployment**. After a successful
|
|
22
|
+
handler chain, the row is deleted.
|
|
23
|
+
|
|
24
|
+
**In-process fan-out works normally.** A single consumer can register
|
|
25
|
+
multiple handlers for the same event type via `consumer.on(...)` — all of
|
|
26
|
+
them run when that replica claims the event.
|
|
27
|
+
|
|
28
|
+
**What this rules out**: two *independent* services subscribing to the same
|
|
29
|
+
event on the same bus. If service A consumes `order.placed`, service B does
|
|
30
|
+
not see it. If you need that, use a dedicated broker:
|
|
31
|
+
|
|
32
|
+
| Use case | Use this broker |
|
|
33
|
+
|---|---|
|
|
34
|
+
| Fan-out across services | Apache Kafka (consumer groups) |
|
|
35
|
+
| Topic subscriptions, exchanges | RabbitMQ |
|
|
36
|
+
| AWS-native fan-out | SNS → SQS |
|
|
37
|
+
| Simple single-service durable queue | This package |
|
|
38
|
+
|
|
39
|
+
`EventPublisher` / `EventBusConsumer` are broker-agnostic interfaces — you
|
|
40
|
+
can implement them against any of the above.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Architecture
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
┌────────────────────┐ ┌────────────────────┐
|
|
48
|
+
│ Your service │ │ Consumer service │
|
|
49
|
+
│ │ │ │
|
|
50
|
+
│ Aggregate emits │ │ EventConsumer │
|
|
51
|
+
│ domain event │ │ (claim, dispatch │
|
|
52
|
+
│ │ │ │ to handlers, │
|
|
53
|
+
│ ▼ │ │ retry, markDone │
|
|
54
|
+
│ UnitOfWork │ │ or markFailed) │
|
|
55
|
+
│ serializes + │ │ ▲ │
|
|
56
|
+
│ writes PENDING │ │ │ │
|
|
57
|
+
│ to LocalOutbox │ │ │ │
|
|
58
|
+
│ (SAME trx) │ │ │ claim │
|
|
59
|
+
│ │ │ │ │ │
|
|
60
|
+
└────────┼───────────┘ └────────┼───────────┘
|
|
61
|
+
│ │
|
|
62
|
+
▼ │
|
|
63
|
+
┌───────────────┐ ┌─────────────────────┐
|
|
64
|
+
│ outbox_events │ │ events │
|
|
65
|
+
│ PENDING │ │ PENDING/CLAIMED │
|
|
66
|
+
│ ↓ │ │ /FAILED │
|
|
67
|
+
│ CLAIMED │ │ │
|
|
68
|
+
│ ↓ │ └─────────▲───────────┘
|
|
69
|
+
│ SENT │ │
|
|
70
|
+
└──────┬────────┘ │
|
|
71
|
+
│ │
|
|
72
|
+
▼ │
|
|
73
|
+
┌─────────────┐ │
|
|
74
|
+
│ Outbox │───────────────┘
|
|
75
|
+
│ Forwarder │ publishes PENDING
|
|
76
|
+
│ (claim │
|
|
77
|
+
│ from │
|
|
78
|
+
│ outbox) │
|
|
79
|
+
└─────────────┘
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Both tables use the same **atomic claim lifecycle**:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
PENDING → CLAIMED (by one replica via atomic CTE+UPDATE)
|
|
86
|
+
→ [terminal]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Where terminal means:
|
|
90
|
+
- **Outbox**: `SENT` (on publish success) or `FAILED` (retries exhausted).
|
|
91
|
+
- **Events**: *deleted* (handler success) or `FAILED` (retries exhausted).
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Quick start (Postgres end-to-end)
|
|
96
|
+
|
|
97
|
+
### 1. Provision the schema
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { getPostgresSchema } from '@quilla-be-kit/messaging/postgres';
|
|
101
|
+
import { Pool } from 'pg';
|
|
102
|
+
|
|
103
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
104
|
+
await pool.query(getPostgresSchema());
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Or pipe the string into drizzle-kit / knex / your migration tool.
|
|
108
|
+
|
|
109
|
+
### 2. Wire the outbox into your UnitOfWork
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { UnitOfWork } from '@quilla-be-kit/persistence';
|
|
113
|
+
import { PgDatabase } from '@quilla-be-kit/persistence/postgres';
|
|
114
|
+
import { PgLocalOutbox } from '@quilla-be-kit/messaging/postgres';
|
|
115
|
+
import { Pool } from 'pg';
|
|
116
|
+
|
|
117
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
118
|
+
const db = new PgDatabase({ pool });
|
|
119
|
+
const outbox = new PgLocalOutbox({ pool });
|
|
120
|
+
|
|
121
|
+
const uow = new UnitOfWork({
|
|
122
|
+
db,
|
|
123
|
+
events: {
|
|
124
|
+
sink: outbox,
|
|
125
|
+
serialize: (event) => ({
|
|
126
|
+
eventType: event.name,
|
|
127
|
+
eventVersion: 1,
|
|
128
|
+
eventKind: 'domain',
|
|
129
|
+
payload: { payload: event.toJSON(), metadata: { /* correlationId, ... */ } },
|
|
130
|
+
aggregateId: 'aggregateId' in event ? event.aggregateId : undefined,
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// In a command handler:
|
|
136
|
+
await uow.transaction(async (ctx) => {
|
|
137
|
+
const order = Order.create(/* ... */);
|
|
138
|
+
ctx.registerAggregate(order);
|
|
139
|
+
await repo.save(order, ctx.trx);
|
|
140
|
+
// Aggregate rows + outbox_events rows commit atomically.
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 3. Run the forwarder in the background
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { OutboxForwarder } from '@quilla-be-kit/messaging';
|
|
148
|
+
import { PgEventBus } from '@quilla-be-kit/messaging/postgres';
|
|
149
|
+
|
|
150
|
+
const bus = new PgEventBus({ pool });
|
|
151
|
+
const forwarder = new OutboxForwarder({
|
|
152
|
+
reader: outbox,
|
|
153
|
+
publisher: bus,
|
|
154
|
+
sourceService: 'orders',
|
|
155
|
+
logger,
|
|
156
|
+
});
|
|
157
|
+
forwarder.start();
|
|
158
|
+
|
|
159
|
+
// On shutdown (via @quilla-be-kit/runtime ShutdownManager):
|
|
160
|
+
await forwarder.dispose();
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Deploy multiple replicas of your service — all of them running the
|
|
164
|
+
forwarder — and they automatically coordinate via atomic claim. Each
|
|
165
|
+
PENDING row is claimed by exactly one replica. No partitioning, no
|
|
166
|
+
configuration.
|
|
167
|
+
|
|
168
|
+
### 4. Consume events
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { z } from 'zod';
|
|
172
|
+
import { EventConsumer, defineEvent } from '@quilla-be-kit/messaging';
|
|
173
|
+
|
|
174
|
+
const OrderPlacedSchema = z.object({
|
|
175
|
+
orderId: z.string().uuid(),
|
|
176
|
+
total: z.number().positive(),
|
|
177
|
+
});
|
|
178
|
+
const OrderPlaced = defineEvent('order.placed', OrderPlacedSchema);
|
|
179
|
+
// payload is inferred as z.infer<typeof OrderPlacedSchema>
|
|
180
|
+
|
|
181
|
+
const consumer = new EventConsumer({
|
|
182
|
+
bus,
|
|
183
|
+
consumerName: 'notifications',
|
|
184
|
+
sourceService: 'notifications',
|
|
185
|
+
logger,
|
|
186
|
+
skipOwnEventKinds: ['integration'], // skip self-emitted integration events
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
consumer.on(OrderPlaced, async ({ payload, correlationId }) => {
|
|
190
|
+
// payload is already validated by OrderPlacedSchema before this line runs
|
|
191
|
+
await sendReceiptEmail(payload.orderId);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
consumer.start();
|
|
195
|
+
|
|
196
|
+
// On shutdown:
|
|
197
|
+
await consumer.dispose();
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Schema-less descriptors still work when you don't need runtime validation:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const OrderPlaced = defineEvent<{ orderId: string; total: number }>('order.placed');
|
|
204
|
+
// payload is typed but not validated
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Same story: deploy multiple consumer replicas, each claims disjoint
|
|
208
|
+
batches, no partition config, per-aggregate ordering preserved.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Core concepts
|
|
213
|
+
|
|
214
|
+
### Atomic claim
|
|
215
|
+
|
|
216
|
+
Both `PgLocalOutbox.claim()` and `PgEventBus.claim()` run a single CTE:
|
|
217
|
+
|
|
218
|
+
```sql
|
|
219
|
+
WITH claimed AS (
|
|
220
|
+
SELECT id FROM events
|
|
221
|
+
WHERE status = 'PENDING'
|
|
222
|
+
AND NOT EXISTS (...) -- aggregate-ordering guard (bus only)
|
|
223
|
+
AND pg_try_advisory_xact_lock(...) -- concurrent-claim guard (bus only)
|
|
224
|
+
ORDER BY created_at
|
|
225
|
+
LIMIT $1
|
|
226
|
+
FOR UPDATE SKIP LOCKED
|
|
227
|
+
)
|
|
228
|
+
UPDATE events
|
|
229
|
+
SET status = 'CLAIMED', claimed_by = $2, claimed_at = $3
|
|
230
|
+
FROM claimed
|
|
231
|
+
WHERE events.id = claimed.id
|
|
232
|
+
RETURNING *;
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`FOR UPDATE SKIP LOCKED` + atomic SET makes this race-free: multiple
|
|
236
|
+
replicas hitting the same query get disjoint batches. No replica ever
|
|
237
|
+
double-claims a row.
|
|
238
|
+
|
|
239
|
+
### Per-aggregate ordering (bus side)
|
|
240
|
+
|
|
241
|
+
Two guards together preserve "events for aggregate A go to the same
|
|
242
|
+
replica in order":
|
|
243
|
+
|
|
244
|
+
1. **`NOT EXISTS`** — don't claim an event for aggregate A if another
|
|
245
|
+
CLAIMED row for A exists (handler is still running elsewhere).
|
|
246
|
+
2. **`pg_try_advisory_xact_lock(hashtext(aggregate_id))`** — closes the
|
|
247
|
+
race window where two replicas read "no CLAIMED for A" concurrently.
|
|
248
|
+
Only one of the concurrent transactions acquires the advisory lock;
|
|
249
|
+
the other skips events for A.
|
|
250
|
+
|
|
251
|
+
Events with `aggregate_id IS NULL` are processed freely — no ordering
|
|
252
|
+
constraint to preserve.
|
|
253
|
+
|
|
254
|
+
### Stale-claim recovery
|
|
255
|
+
|
|
256
|
+
A replica that crashes mid-batch leaves rows stuck in CLAIMED. Both
|
|
257
|
+
`OutboxForwarder` and `EventConsumer` run a periodic `resetStale(cutoff)`
|
|
258
|
+
sweep (default: rows CLAIMED longer than 5 minutes). The stale rows flip
|
|
259
|
+
back to PENDING and any replica picks them up on the next tick.
|
|
260
|
+
|
|
261
|
+
### No DLQ table
|
|
262
|
+
|
|
263
|
+
FAILED rows stay in the main table with full context — `status='FAILED'`,
|
|
264
|
+
`retry_count`, `last_error`, `claimed_by`. Query them to inspect, replay
|
|
265
|
+
by flipping `status='PENDING'` and `retry_count=0`. One less table to
|
|
266
|
+
reason about.
|
|
267
|
+
|
|
268
|
+
### `EventDescriptor` — typed event identity
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
const OrderPlaced = defineEvent<{ orderId: string; total: number }>('order.placed');
|
|
272
|
+
|
|
273
|
+
consumer.on(OrderPlaced, async ({ payload }) => {
|
|
274
|
+
// payload typed as { orderId: string; total: number }
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
A `{ name, schema? }` shape with a phantom payload type. Use at module
|
|
279
|
+
scope to keep event identity and payload type in one declaration.
|
|
280
|
+
|
|
281
|
+
When you pass a [Standard Schema v1][standard-schema] instance as the
|
|
282
|
+
second argument, the payload type is inferred from the schema and
|
|
283
|
+
`EventConsumer` validates it before dispatch (see below):
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { z } from 'zod';
|
|
287
|
+
const OrderPlaced = defineEvent('order.placed', z.object({ orderId: z.string() }));
|
|
288
|
+
// payload is inferred — no generic needed
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
[standard-schema]: https://standardschema.dev/
|
|
292
|
+
|
|
293
|
+
### `EventSubscription` — `(descriptor, handle)` pairs for composition
|
|
294
|
+
|
|
295
|
+
Module factories can return an array of subscriptions instead of wiring
|
|
296
|
+
handlers themselves. The composition root passes the combined array to
|
|
297
|
+
`EventConsumer`:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import type { EventSubscription } from '@quilla-be-kit/messaging';
|
|
301
|
+
|
|
302
|
+
// orders/subscriptions.ts
|
|
303
|
+
export const orderSubscriptions = (): EventSubscription[] => [
|
|
304
|
+
{ descriptor: OrderPlaced, handle: onOrderPlaced },
|
|
305
|
+
{ descriptor: OrderCancelled, handle: onOrderCancelled },
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
// composition-root.ts
|
|
309
|
+
const consumer = new EventConsumer({
|
|
310
|
+
bus,
|
|
311
|
+
consumerName: 'notifications',
|
|
312
|
+
sourceService: 'notifications',
|
|
313
|
+
logger,
|
|
314
|
+
subscriptions: [
|
|
315
|
+
...userSubscriptions(),
|
|
316
|
+
...orderSubscriptions(),
|
|
317
|
+
],
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`consumer.subscribe(subscriptions)` does the same thing post-construction,
|
|
322
|
+
for DI containers that resolve handlers after the consumer is built.
|
|
323
|
+
`consumer.on(descriptor, handler)` remains for ad-hoc wiring.
|
|
324
|
+
|
|
325
|
+
### Automatic payload validation (Standard Schema v1)
|
|
326
|
+
|
|
327
|
+
When an `EventDescriptor` carries a schema, `EventConsumer.on` wraps the
|
|
328
|
+
handler so `entry.payload` is validated before dispatch. Any Standard
|
|
329
|
+
Schema v1 vendor works — Zod (≥ 4), Valibot, ArkType — without a hard
|
|
330
|
+
dependency in `@quilla-be-kit/messaging`:
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
import { z } from 'zod';
|
|
334
|
+
import { defineEvent, SchemaValidationError } from '@quilla-be-kit/messaging';
|
|
335
|
+
|
|
336
|
+
const UserCreated = defineEvent(
|
|
337
|
+
'user.created',
|
|
338
|
+
z.object({
|
|
339
|
+
userId: z.string().uuid(),
|
|
340
|
+
email: z.string().email(),
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
consumer.on(UserCreated, async ({ payload }) => {
|
|
345
|
+
// payload has already been validated
|
|
346
|
+
await sendWelcomeEmail(payload.email);
|
|
347
|
+
});
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Validation failures are **not** retried — the schema result is
|
|
351
|
+
deterministic. The event is marked `FAILED` directly, with the issue
|
|
352
|
+
summary in the row's `last_error` column. Log/metrics pipelines can
|
|
353
|
+
`instanceof`-check the exported `SchemaValidationError` to alert on
|
|
354
|
+
contract drift between producer and consumer.
|
|
355
|
+
|
|
356
|
+
### Automatic ExecutionContext reconstruction
|
|
357
|
+
|
|
358
|
+
When `EventConsumer` is wired with an `executionContext.provider`, each
|
|
359
|
+
handler dispatch is wrapped in `provider.runWithContext(...)` using a
|
|
360
|
+
context **reconstructed from the event's `EventMetadata`** — same
|
|
361
|
+
`correlationId`, `actorType`, `scopeId`, `userId` as the operation that
|
|
362
|
+
produced the event. The same `correlationId` that flowed through the
|
|
363
|
+
originating HTTP request surfaces on log lines emitted by the handler,
|
|
364
|
+
and `ExecutionContextProvider.getContext()` returns a valid context
|
|
365
|
+
inside handler code without the consumer wiring any middleware.
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
new EventConsumer({
|
|
369
|
+
bus,
|
|
370
|
+
consumerName: 'notifications',
|
|
371
|
+
sourceService: 'notifications',
|
|
372
|
+
logger,
|
|
373
|
+
executionContext: { provider }, // reconstruct per-handler context from event metadata
|
|
374
|
+
});
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Reconstruction uses `provider.factory.createFromEventMetadata(...)`;
|
|
378
|
+
override the provider's factory to reconstruct into an extended context
|
|
379
|
+
shape (see
|
|
380
|
+
[`@quilla-be-kit/execution-context` extension pattern](../execution-context/README.md#extension-pattern)).
|
|
381
|
+
Without an `executionContext` option, handlers still run — they just
|
|
382
|
+
don't have a context scope, and `getContext()` will throw if called.
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Customization
|
|
387
|
+
|
|
388
|
+
All knobs with sensible defaults. Defaults are exported for composition.
|
|
389
|
+
|
|
390
|
+
### `PgLocalOutbox`
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
new PgLocalOutbox({
|
|
394
|
+
pool,
|
|
395
|
+
tableName: 'outbox_events', // override if you prefix tables per-service
|
|
396
|
+
maxRetries: 3, // flips status to FAILED after N failed markFailed calls
|
|
397
|
+
});
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### `PgEventBus`
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
new PgEventBus({
|
|
404
|
+
pool,
|
|
405
|
+
eventsTableName: 'events',
|
|
406
|
+
maxRetries: 3,
|
|
407
|
+
});
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### `OutboxForwarder`
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
new OutboxForwarder({
|
|
414
|
+
reader,
|
|
415
|
+
publisher,
|
|
416
|
+
sourceService: 'orders',
|
|
417
|
+
logger,
|
|
418
|
+
pollIntervalMs: 1000, // DEFAULT_POLL_INTERVAL_MS
|
|
419
|
+
batchSize: 100, // DEFAULT_BATCH_SIZE
|
|
420
|
+
staleClaimAfterMs: 5 * 60_000, // reset CLAIMED rows older than this
|
|
421
|
+
instanceId: 'replica-<id>', // defaults to randomUUID()
|
|
422
|
+
});
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### `EventConsumer`
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
import {
|
|
429
|
+
DEFAULT_RETRY_DELAYS_MS,
|
|
430
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
431
|
+
DEFAULT_BATCH_SIZE,
|
|
432
|
+
} from '@quilla-be-kit/messaging';
|
|
433
|
+
|
|
434
|
+
new EventConsumer({
|
|
435
|
+
bus,
|
|
436
|
+
consumerName: 'notifications',
|
|
437
|
+
sourceService: 'notifications',
|
|
438
|
+
logger,
|
|
439
|
+
pollIntervalMs: 1000,
|
|
440
|
+
batchSize: 100,
|
|
441
|
+
retryDelaysMs: [1000, 5000, 15000], // length = max retry attempts
|
|
442
|
+
skipOwnEventKinds: ['integration'],
|
|
443
|
+
staleClaimAfterMs: 5 * 60_000,
|
|
444
|
+
instanceId: 'replica-<id>',
|
|
445
|
+
executionContext: { provider }, // optional: reconstruct + scope per handler (factory is reached via provider.factory)
|
|
446
|
+
onProcessed: (entry) => bumpMetric(entry),
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Schema
|
|
453
|
+
|
|
454
|
+
`getPostgresSchema()` returns DDL for the two tables:
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
import { getPostgresSchema } from '@quilla-be-kit/messaging/postgres';
|
|
458
|
+
|
|
459
|
+
const sql = getPostgresSchema({
|
|
460
|
+
outboxTable: 'orders_outbox', // defaults: 'outbox_events'
|
|
461
|
+
eventsTable: 'svc_events', // defaults: 'events'
|
|
462
|
+
});
|
|
463
|
+
await pool.query(sql);
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Dumb-DB principle
|
|
467
|
+
|
|
468
|
+
No `DEFAULT` clauses. Every value (`id`, `status`, `retry_count`,
|
|
469
|
+
timestamps) is written explicitly by library code. Benefits:
|
|
470
|
+
|
|
471
|
+
- Portable — any DB with `NOT NULL` support works.
|
|
472
|
+
- Testable — no DB defaults to simulate in fakes.
|
|
473
|
+
- Errors surface in TypeScript, not SQL.
|
|
474
|
+
- Migrations stay simple — no `ALTER COLUMN SET DEFAULT` drift.
|
|
475
|
+
|
|
476
|
+
UUIDs generated via `node:crypto.randomUUID()`. Monotonic sequence IDs
|
|
477
|
+
would normally need `BIGSERIAL`, but in the claim model there's no
|
|
478
|
+
ordering cursor — events are FIFO-drained by `created_at`.
|
|
479
|
+
|
|
480
|
+
### Indexes created
|
|
481
|
+
|
|
482
|
+
- Partial index on `(status, created_at) WHERE status = 'PENDING'` — hot
|
|
483
|
+
path for claim queries.
|
|
484
|
+
- Partial index on `(aggregate_id, status) WHERE aggregate_id IS NOT NULL
|
|
485
|
+
AND status = 'CLAIMED'` — supports the `NOT EXISTS` aggregate guard.
|
|
486
|
+
- Partial index on `(claimed_at) WHERE status = 'CLAIMED'` — supports
|
|
487
|
+
`resetStale` scans.
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## Multi-replica scaling
|
|
492
|
+
|
|
493
|
+
Just deploy more replicas. Each instantiates its own `OutboxForwarder` /
|
|
494
|
+
`EventConsumer` with a unique `instanceId` (auto-generated by default).
|
|
495
|
+
All replicas compete for the same PENDING rows via atomic claim.
|
|
496
|
+
|
|
497
|
+
- **Outbox side**: N forwarders drain N× as fast. No configuration.
|
|
498
|
+
- **Bus side**: N consumers handle N× as fast. Per-aggregate ordering is
|
|
499
|
+
preserved regardless of N — any given aggregate's events serialize
|
|
500
|
+
naturally through the advisory-lock guard.
|
|
501
|
+
|
|
502
|
+
Scale up: add replicas. Scale down: remove them — in-flight CLAIMED rows
|
|
503
|
+
get reset to PENDING on the next `resetStale` sweep (default 5 min
|
|
504
|
+
cutoff).
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
## Retention and cleanup
|
|
509
|
+
|
|
510
|
+
### Event bus (`events` table)
|
|
511
|
+
|
|
512
|
+
- PENDING rows are cleared when consumers claim + succeed (rows deleted).
|
|
513
|
+
- FAILED rows stay put for operator inspection. Clean up on your cadence:
|
|
514
|
+
```ts
|
|
515
|
+
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
516
|
+
await bus.cleanupFailed(cutoff, { limit: 1000 });
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Local outbox (`outbox_events` table)
|
|
520
|
+
|
|
521
|
+
- PENDING rows move to CLAIMED → SENT on success.
|
|
522
|
+
- SENT rows stay for audit. Clean up on your cadence:
|
|
523
|
+
```ts
|
|
524
|
+
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
525
|
+
await outbox.cleanup(cutoff, { limit: 1000 });
|
|
526
|
+
```
|
|
527
|
+
- FAILED rows (outbox) stay put — handled the same way as the bus side:
|
|
528
|
+
inspect manually, reset to PENDING to retry.
|
|
529
|
+
|
|
530
|
+
---
|
|
531
|
+
|
|
532
|
+
## Lifecycle integration
|
|
533
|
+
|
|
534
|
+
Both `OutboxForwarder` and `EventConsumer` implement `Disposable`:
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
import { ShutdownManager } from '@quilla-be-kit/runtime';
|
|
538
|
+
|
|
539
|
+
const shutdown = new ShutdownManager({ logger });
|
|
540
|
+
shutdown.registerPhase('consumers', [consumer]);
|
|
541
|
+
shutdown.registerPhase('forwarders', [forwarder]);
|
|
542
|
+
|
|
543
|
+
// Each dispose() awaits in-flight tick — no partial batches, no
|
|
544
|
+
// stranded CLAIMED rows (the next replica's sweep recovers them).
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
## Observability
|
|
550
|
+
|
|
551
|
+
`OutboxForwarder` and `EventConsumer` follow a consistent log-level
|
|
552
|
+
convention so production aggregators aren't flooded with routine
|
|
553
|
+
processing chatter:
|
|
554
|
+
|
|
555
|
+
| Level | When it fires |
|
|
556
|
+
| --- | --- |
|
|
557
|
+
| `info` | Lifecycle transitions: `starting`, `stopped`. One-shot per component per replica. |
|
|
558
|
+
| `debug` | Per-tick / per-batch processing: `forwarding N event(s)`, `registered event types`. High frequency. |
|
|
559
|
+
| `warn` | Recoverable anomalies: handler retry, stale-claim sweep hits, transient handler failures. |
|
|
560
|
+
| `error` | Terminal failures: handler exhausted retries, schema validation failed, tick crashed. |
|
|
561
|
+
|
|
562
|
+
`EventConsumer` splits its startup log to avoid unbounded `meta`
|
|
563
|
+
payloads in the info stream: the info entry carries
|
|
564
|
+
`registeredTypeCount` (a scalar); the full list is emitted separately at
|
|
565
|
+
debug. Programmatic callers can inspect registrations via the
|
|
566
|
+
`consumer.registeredEventTypes` accessor instead of log scraping:
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
const consumer = new EventConsumer({ bus, consumerName, sourceService, logger });
|
|
570
|
+
consumer.subscribe([
|
|
571
|
+
...userSubscriptions(),
|
|
572
|
+
...orderSubscriptions(),
|
|
573
|
+
]);
|
|
574
|
+
// Health check / test assertion:
|
|
575
|
+
expect(consumer.registeredEventTypes).toContain('user.created');
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
In production, set the logger level to `info` (default in most
|
|
579
|
+
deployments). For debugging event flow, bump a specific replica to
|
|
580
|
+
`debug` to see per-tick detail without changing code.
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## Writing a custom broker adapter
|
|
585
|
+
|
|
586
|
+
Implement `EventBusPublisher` + `EventBusConsumer`:
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
import type {
|
|
590
|
+
EventBusPublisher,
|
|
591
|
+
EventBusConsumer,
|
|
592
|
+
EventBusEntry,
|
|
593
|
+
} from '@quilla-be-kit/messaging';
|
|
594
|
+
|
|
595
|
+
class KafkaBus implements EventBusPublisher, EventBusConsumer {
|
|
596
|
+
async publish(event) { /* produce to topic */ }
|
|
597
|
+
async claim(instanceId, batchSize) { /* pull + transition */ }
|
|
598
|
+
async markDone(id) { /* commit offset */ }
|
|
599
|
+
async markFailed(id, reason) { /* retry or DLQ per broker semantics */ }
|
|
600
|
+
async resetStale(olderThan) { /* broker-specific recovery */ }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Pair with the outbox forwarder:
|
|
604
|
+
const forwarder = new OutboxForwarder({
|
|
605
|
+
reader: pgLocalOutbox, // keep the Postgres outbox
|
|
606
|
+
publisher: kafkaBus, // swap the bus
|
|
607
|
+
sourceService: 'orders',
|
|
608
|
+
logger,
|
|
609
|
+
});
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
The claim pattern decouples "persist the event atomically" from "deliver
|
|
613
|
+
it reliably." You can mix — Postgres outbox on the emit side, Kafka bus
|
|
614
|
+
on the consume side — as infra evolves.
|
|
615
|
+
|
|
616
|
+
---
|
|
617
|
+
|
|
618
|
+
## License
|
|
619
|
+
|
|
620
|
+
MIT
|