@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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +620 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/event-bus/defaults.d.ts +5 -0
  5. package/dist/event-bus/defaults.d.ts.map +1 -0
  6. package/dist/event-bus/defaults.js +5 -0
  7. package/dist/event-bus/defaults.js.map +1 -0
  8. package/dist/event-bus/event-bus-consumer.interface.d.ts +8 -0
  9. package/dist/event-bus/event-bus-consumer.interface.d.ts.map +1 -0
  10. package/dist/event-bus/event-bus-consumer.interface.js +2 -0
  11. package/dist/event-bus/event-bus-consumer.interface.js.map +1 -0
  12. package/dist/event-bus/event-bus-entry.type.d.ts +19 -0
  13. package/dist/event-bus/event-bus-entry.type.d.ts.map +1 -0
  14. package/dist/event-bus/event-bus-entry.type.js +2 -0
  15. package/dist/event-bus/event-bus-entry.type.js.map +1 -0
  16. package/dist/event-bus/event-bus-publisher.interface.d.ts +14 -0
  17. package/dist/event-bus/event-bus-publisher.interface.d.ts.map +1 -0
  18. package/dist/event-bus/event-bus-publisher.interface.js +2 -0
  19. package/dist/event-bus/event-bus-publisher.interface.js.map +1 -0
  20. package/dist/event-bus/event-subscription.interface.d.ts +7 -0
  21. package/dist/event-bus/event-subscription.interface.d.ts.map +1 -0
  22. package/dist/event-bus/event-subscription.interface.js +2 -0
  23. package/dist/event-bus/event-subscription.interface.js.map +1 -0
  24. package/dist/event-bus/event.consumer.d.ts +66 -0
  25. package/dist/event-bus/event.consumer.d.ts.map +1 -0
  26. package/dist/event-bus/event.consumer.js +251 -0
  27. package/dist/event-bus/event.consumer.js.map +1 -0
  28. package/dist/event-bus/event.descriptor.d.ts +9 -0
  29. package/dist/event-bus/event.descriptor.d.ts.map +1 -0
  30. package/dist/event-bus/event.descriptor.js +7 -0
  31. package/dist/event-bus/event.descriptor.js.map +1 -0
  32. package/dist/event-bus/handler-entry.type.d.ts +8 -0
  33. package/dist/event-bus/handler-entry.type.d.ts.map +1 -0
  34. package/dist/event-bus/handler-entry.type.js +2 -0
  35. package/dist/event-bus/handler-entry.type.js.map +1 -0
  36. package/dist/event-bus/index.d.ts +10 -0
  37. package/dist/event-bus/index.d.ts.map +1 -0
  38. package/dist/event-bus/index.js +4 -0
  39. package/dist/event-bus/index.js.map +1 -0
  40. package/dist/event-bus/standard-schema.type.d.ts +33 -0
  41. package/dist/event-bus/standard-schema.type.d.ts.map +1 -0
  42. package/dist/event-bus/standard-schema.type.js +2 -0
  43. package/dist/event-bus/standard-schema.type.js.map +1 -0
  44. package/dist/index.d.ts +3 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +3 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/local-outbox/index.d.ts +6 -0
  49. package/dist/local-outbox/index.d.ts.map +1 -0
  50. package/dist/local-outbox/index.js +2 -0
  51. package/dist/local-outbox/index.js.map +1 -0
  52. package/dist/local-outbox/local-outbox-entry.type.d.ts +28 -0
  53. package/dist/local-outbox/local-outbox-entry.type.d.ts.map +1 -0
  54. package/dist/local-outbox/local-outbox-entry.type.js +2 -0
  55. package/dist/local-outbox/local-outbox-entry.type.js.map +1 -0
  56. package/dist/local-outbox/local-outbox-reader.interface.d.ts +8 -0
  57. package/dist/local-outbox/local-outbox-reader.interface.d.ts.map +1 -0
  58. package/dist/local-outbox/local-outbox-reader.interface.js +2 -0
  59. package/dist/local-outbox/local-outbox-reader.interface.js.map +1 -0
  60. package/dist/local-outbox/local-outbox-writer.interface.d.ts +6 -0
  61. package/dist/local-outbox/local-outbox-writer.interface.d.ts.map +1 -0
  62. package/dist/local-outbox/local-outbox-writer.interface.js +2 -0
  63. package/dist/local-outbox/local-outbox-writer.interface.js.map +1 -0
  64. package/dist/local-outbox/outbox.forwarder.d.ts +34 -0
  65. package/dist/local-outbox/outbox.forwarder.d.ts.map +1 -0
  66. package/dist/local-outbox/outbox.forwarder.js +109 -0
  67. package/dist/local-outbox/outbox.forwarder.js.map +1 -0
  68. package/dist/local-outbox/transaction-handle.interface.d.ts +6 -0
  69. package/dist/local-outbox/transaction-handle.interface.d.ts.map +1 -0
  70. package/dist/local-outbox/transaction-handle.interface.js +2 -0
  71. package/dist/local-outbox/transaction-handle.interface.js.map +1 -0
  72. package/dist/postgres/get-postgres-schema.d.ts +6 -0
  73. package/dist/postgres/get-postgres-schema.d.ts.map +1 -0
  74. package/dist/postgres/get-postgres-schema.js +56 -0
  75. package/dist/postgres/get-postgres-schema.js.map +1 -0
  76. package/dist/postgres/index.d.ts +4 -0
  77. package/dist/postgres/index.d.ts.map +1 -0
  78. package/dist/postgres/index.js +4 -0
  79. package/dist/postgres/index.js.map +1 -0
  80. package/dist/postgres/pg-event-bus.d.ts +25 -0
  81. package/dist/postgres/pg-event-bus.d.ts.map +1 -0
  82. package/dist/postgres/pg-event-bus.js +115 -0
  83. package/dist/postgres/pg-event-bus.js.map +1 -0
  84. package/dist/postgres/pg-local-outbox.d.ts +26 -0
  85. package/dist/postgres/pg-local-outbox.d.ts.map +1 -0
  86. package/dist/postgres/pg-local-outbox.js +102 -0
  87. package/dist/postgres/pg-local-outbox.js.map +1 -0
  88. 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