@alveolus/arch 0.2.0 → 0.4.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/README.md +11 -0
- package/dist/bin.mjs +4 -2
- package/dist/bin.mjs.map +1 -1
- package/dist/{cli-P5PwH9OE.mjs → docs-DcFgskuN.mjs} +214 -43
- package/dist/docs-DcFgskuN.mjs.map +1 -0
- package/dist/index.d.mts +53 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/docs/core/application/command-handlers.md +617 -0
- package/docs/core/application/event-publishers.md +234 -0
- package/docs/core/application/event-translators.md +329 -0
- package/docs/core/application/index.md +99 -0
- package/docs/core/application/integration-events.md +277 -0
- package/docs/core/application/outbox.md +416 -0
- package/docs/core/application/query-handlers.md +292 -0
- package/docs/core/application/unit-of-work.md +352 -0
- package/docs/core/domain/aggregates.md +822 -0
- package/docs/core/domain/domain-errors.md +251 -0
- package/docs/core/domain/domain-events.md +292 -0
- package/docs/core/domain/domain-services.md +249 -0
- package/docs/core/domain/entities.md +431 -0
- package/docs/core/domain/index.md +93 -0
- package/docs/core/domain/ports.md +284 -0
- package/docs/core/domain/repositories.md +335 -0
- package/docs/core/domain/value-objects.md +425 -0
- package/docs/core/domain/views.md +265 -0
- package/docs/core/index.md +108 -0
- package/docs/core/strategic/anti-corruption-layers.md +349 -0
- package/docs/core/strategic/index.md +83 -0
- package/docs/core/strategic/open-host-services.md +287 -0
- package/docs/core/strategic/published-language.md +265 -0
- package/docs/core/utilities/result.md +413 -0
- package/docs/guide/agents.md +68 -0
- package/docs/guide/existing-project.md +108 -0
- package/docs/guide/getting-started.md +286 -0
- package/docs/guide/learning-path.md +123 -0
- package/docs/guide/project-layout.md +324 -0
- package/docs/guide/versioning.md +42 -0
- package/docs/integrations/index.md +112 -0
- package/docs/integrations/nestjs.md +169 -0
- package/docs/rules/index.md +185 -0
- package/docs/rules/layers/no-driving-shortcut.md +119 -0
- package/docs/rules/layers/no-impure-domain.md +191 -0
- package/docs/rules/layers/no-outward-import.md +186 -0
- package/docs/rules/layers/no-portless-adapter.md +123 -0
- package/docs/rules/strategic/no-cross-context-import.md +140 -0
- package/docs/rules/strategic/no-fat-shared-kernel.md +81 -0
- package/docs/rules/strategic/no-leaky-host-service.md +107 -0
- package/docs/rules/strategic/no-unmapped-context.md +114 -0
- package/docs/rules/tactical/no-aggregate-reference.md +139 -0
- package/docs/rules/tactical/no-foreign-command-dependency.md +119 -0
- package/docs/rules/tactical/no-foreign-query-dependency.md +201 -0
- package/docs/rules/tactical/no-loose-code.md +171 -0
- package/docs/rules/tactical/no-misplaced-class.md +146 -0
- package/docs/rules/tactical/no-public-field.md +113 -0
- package/docs/rules/tactical/no-stateful-service.md +102 -0
- package/docs/rules/tactical/no-thrown-failure.md +162 -0
- package/docs/rules/tooling/no-loose-disable.md +98 -0
- package/package.json +4 -3
- package/dist/cli-P5PwH9OE.mjs.map +0 -1
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "The transactional outbox pattern in TypeScript: store integration events in the same transaction as the change, then relay them so none is lost."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Outbox
|
|
6
|
+
|
|
7
|
+
An outbox stores the integration events of a change in the same transaction as the change, then a
|
|
8
|
+
relay publishes them, so none is lost.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Layer</dt><dd>Application (a port, and the <code>OutboxRelay</code> class)</dd>
|
|
12
|
+
<dt>File</dt><dd><code>shared-kernel/driven/pg/adapters/pg-outbox.adapter.ts</code> (your adapter)</dd>
|
|
13
|
+
<dt>Extends</dt><dd><a href="#api"><code>Outbox</code></a></dd>
|
|
14
|
+
<dt>Called by</dt><dd><a href="/core/application/command-handlers">Command handlers</a> (<code>add</code>), <code>OutboxRelay</code> (<code>pending</code>, <code>markPublished</code>)</dd>
|
|
15
|
+
<dt>Checked by</dt><dd><a href="/rules/layers/no-portless-adapter"><code>layers/no-portless-adapter</code></a>, <a href="/rules/tactical/no-misplaced-class"><code>tactical/no-misplaced-class</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
When an order is placed, shipping and billing must hear about it. If the handler saves the order
|
|
21
|
+
then publishes `OrderPlaced` to the broker, two things go wrong. The process stops between the two:
|
|
22
|
+
the order is placed and nobody hears about it. The broker is down: the command fails although the
|
|
23
|
+
order is valid.
|
|
24
|
+
|
|
25
|
+
::: tip The fix
|
|
26
|
+
The handler adds the events to the outbox, in the same [unit of work](./unit-of-work.md) as the
|
|
27
|
+
order: both are saved, or neither. Later, the `OutboxRelay` reads the pending events and hands them
|
|
28
|
+
to the [event publisher](./event-publishers.md), retrying until it succeeds.
|
|
29
|
+
:::
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
The outbox splits publishing in two moments: storing, in the transaction of the change, and
|
|
34
|
+
relaying, in the background.
|
|
35
|
+
|
|
36
|
+
<div class="al-diagram">
|
|
37
|
+
<svg viewBox="0 0 700 250" role="img" aria-label="Inside one unit of work, the command handler saves the aggregate and adds its integration events to the outbox. Later, the outbox relay reads pending events, calls the event publisher, which sends them to other contexts, then marks them as published.">
|
|
38
|
+
<defs>
|
|
39
|
+
<marker id="outbox-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
40
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
41
|
+
</marker>
|
|
42
|
+
</defs>
|
|
43
|
+
<rect class="boundary" x="8" y="8" width="300" height="234" rx="14" />
|
|
44
|
+
<text class="note" x="24" y="32">one unit of work</text>
|
|
45
|
+
<rect class="box" x="28" y="48" width="260" height="48" rx="8" />
|
|
46
|
+
<text class="label" x="158" y="70" text-anchor="middle">PlaceOrderHandler</text>
|
|
47
|
+
<text class="note" x="158" y="87" text-anchor="middle">places the order</text>
|
|
48
|
+
<rect class="box" x="28" y="150" width="120" height="70" rx="8" />
|
|
49
|
+
<text class="label" x="88" y="180" text-anchor="middle">orders</text>
|
|
50
|
+
<text class="note" x="88" y="200" text-anchor="middle">save(order)</text>
|
|
51
|
+
<rect class="box" x="168" y="150" width="120" height="70" rx="8" />
|
|
52
|
+
<text class="label" x="228" y="180" text-anchor="middle">outbox</text>
|
|
53
|
+
<text class="note" x="228" y="200" text-anchor="middle">add(events)</text>
|
|
54
|
+
<path class="link" d="M 88 96 L 88 148" marker-end="url(#outbox-arrow)" />
|
|
55
|
+
<path class="link" d="M 228 96 L 228 148" marker-end="url(#outbox-arrow)" />
|
|
56
|
+
<rect class="box" x="372" y="150" width="140" height="70" rx="8" />
|
|
57
|
+
<text class="label" x="442" y="180" text-anchor="middle">OutboxRelay</text>
|
|
58
|
+
<text class="note" x="442" y="200" text-anchor="middle">relay()</text>
|
|
59
|
+
<path class="link" d="M 370 185 L 290 185" marker-end="url(#outbox-arrow)" />
|
|
60
|
+
<text class="note" x="340" y="176" text-anchor="middle">pending</text>
|
|
61
|
+
<rect class="box" x="372" y="48" width="140" height="48" rx="8" />
|
|
62
|
+
<text class="label" x="442" y="70" text-anchor="middle">EventPublisher</text>
|
|
63
|
+
<text class="note" x="442" y="87" text-anchor="middle">publish(events)</text>
|
|
64
|
+
<path class="link" d="M 442 150 L 442 98" marker-end="url(#outbox-arrow)" />
|
|
65
|
+
<rect class="box" x="546" y="48" width="146" height="48" rx="8" />
|
|
66
|
+
<text class="label" x="619" y="70" text-anchor="middle">other contexts</text>
|
|
67
|
+
<text class="note" x="619" y="87" text-anchor="middle">broker, consumers</text>
|
|
68
|
+
<path class="link" d="M 512 72 L 544 72" marker-end="url(#outbox-arrow)" />
|
|
69
|
+
</svg>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
Each call to `relay()` does three things:
|
|
73
|
+
|
|
74
|
+
<div class="al-cards">
|
|
75
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Read a batch</span><code>outbox.pending(n)</code> returns up to <code>n</code> unpublished events, oldest first.</div>
|
|
76
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Publish it</span><code>publisher.publish(events)</code> sends them. If it throws, the events stay pending for the next call.</div>
|
|
77
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Mark it</span><code>outbox.markPublished(ids)</code> records that they left. The next call skips them.</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
## Where it fits
|
|
81
|
+
|
|
82
|
+
In the PlaceOrder flow, the outbox is the last step of the command handler, inside its unit of work.
|
|
83
|
+
|
|
84
|
+
<div class="al-diagram">
|
|
85
|
+
<svg viewBox="0 0 680 300" role="img" aria-label="A request goes from a controller to the PlaceOrderHandler, which in one unit of work loads the Order from the repository, calls order.place, saves the order and adds its events to the outbox.">
|
|
86
|
+
<defs>
|
|
87
|
+
<marker id="outbox-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
88
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
89
|
+
</marker>
|
|
90
|
+
</defs>
|
|
91
|
+
<rect class="box" x="8" y="122" width="130" height="56" rx="8" />
|
|
92
|
+
<text class="label" x="73" y="146" text-anchor="middle">Controller</text>
|
|
93
|
+
<text class="note" x="73" y="166" text-anchor="middle">driving adapter</text>
|
|
94
|
+
<path class="link" d="M 138 150 L 178 150" marker-end="url(#outbox-flow-arrow)" />
|
|
95
|
+
<rect class="box" x="180" y="122" width="180" height="56" rx="8" />
|
|
96
|
+
<text class="label" x="270" y="146" text-anchor="middle">PlaceOrderHandler</text>
|
|
97
|
+
<text class="note" x="270" y="166" text-anchor="middle">command handler</text>
|
|
98
|
+
<text class="note" x="270" y="204" text-anchor="middle">one unit of work</text>
|
|
99
|
+
<rect class="box" x="440" y="24" width="232" height="48" rx="8" />
|
|
100
|
+
<text class="label" x="556" y="44" text-anchor="middle">1 · orders.findById(id)</text>
|
|
101
|
+
<text class="note" x="556" y="62" text-anchor="middle">loads the Order</text>
|
|
102
|
+
<rect class="box" x="440" y="92" width="232" height="48" rx="8" />
|
|
103
|
+
<text class="label" x="556" y="112" text-anchor="middle">2 · order.place(…)</text>
|
|
104
|
+
<text class="note" x="556" y="130" text-anchor="middle">rules + event</text>
|
|
105
|
+
<rect class="box" x="440" y="160" width="232" height="48" rx="8" />
|
|
106
|
+
<text class="label" x="556" y="180" text-anchor="middle">3 · orders.save(order)</text>
|
|
107
|
+
<text class="note" x="556" y="198" text-anchor="middle">stores its snapshot</text>
|
|
108
|
+
<rect class="boundary" x="440" y="228" width="232" height="48" rx="8" />
|
|
109
|
+
<text class="label" x="556" y="248" text-anchor="middle">4 · outbox.add(events)</text>
|
|
110
|
+
<text class="note" x="556" y="266" text-anchor="middle">this page: stored, not sent</text>
|
|
111
|
+
<path class="link" d="M 360 150 L 438 48" marker-end="url(#outbox-flow-arrow)" />
|
|
112
|
+
<path class="link" d="M 360 150 L 438 116" marker-end="url(#outbox-flow-arrow)" />
|
|
113
|
+
<path class="link" d="M 360 150 L 438 184" marker-end="url(#outbox-flow-arrow)" />
|
|
114
|
+
<path class="link" d="M 360 150 L 438 252" marker-end="url(#outbox-flow-arrow)" />
|
|
115
|
+
</svg>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
The domain events are first turned into integration events by an
|
|
119
|
+
[event translator](./event-translators.md): the outbox stores JSON, never domain classes.
|
|
120
|
+
|
|
121
|
+
::: tip
|
|
122
|
+
A command handler never publishes. It stores; the relay publishes.
|
|
123
|
+
:::
|
|
124
|
+
|
|
125
|
+
## API
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { Outbox, OutboxRelay } from "@alveolus/core";
|
|
129
|
+
// or: import { Outbox, OutboxRelay } from "@alveolus/core/outbox";
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`Outbox` is the port you implement; `OutboxRelay` is a class provided by core that moves its
|
|
133
|
+
events to the [event publisher](./event-publishers.md).
|
|
134
|
+
|
|
135
|
+
### `add(events)` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" /> <Badge type="tip" text="called by the command handler" />
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
abstract add(events: readonly AnyIntegrationEvent[]): Promise<void>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Stores the events of the change, in the current transaction.
|
|
142
|
+
|
|
143
|
+
### `pending(limit)` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" /> <Badge type="tip" text="called by the OutboxRelay" />
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
abstract pending(
|
|
147
|
+
limit: number,
|
|
148
|
+
): Promise<readonly AnyIntegrationEvent[]>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Returns up to `limit` unpublished events, oldest first.
|
|
152
|
+
|
|
153
|
+
### `markPublished(ids)` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" /> <Badge type="tip" text="called by the OutboxRelay" />
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
abstract markPublished(ids: readonly string[]): Promise<void>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Marks the events with these ids as published.
|
|
160
|
+
|
|
161
|
+
### `new OutboxRelay(outbox, publisher, batchSize?)` <Badge type="tip" text="called by the composition root" />
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
constructor(
|
|
165
|
+
outbox: Outbox,
|
|
166
|
+
publisher: EventPublisher,
|
|
167
|
+
batchSize: number = 100,
|
|
168
|
+
)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Builds the relay from the outbox to the publisher. `batchSize` is how many events one call to
|
|
172
|
+
`relay` reads.
|
|
173
|
+
|
|
174
|
+
### `relay()` <Badge type="tip" text="called by a timer, a cron job or a worker" />
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
relay(): Promise<number>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Reads one batch from `pending`, publishes it in one call, marks it published, and returns how many
|
|
181
|
+
events it published: `0` when none is pending.
|
|
182
|
+
|
|
183
|
+
::: warning Caveats
|
|
184
|
+
- Delivery is at least once: if the relay stops between publishing and marking, the events are
|
|
185
|
+
published again. Consumers ignore duplicates by `id`.
|
|
186
|
+
- Several relays running at once may publish the same batch. Lock the rows in `pending` (for
|
|
187
|
+
instance `FOR UPDATE SKIP LOCKED`) if you run more than one.
|
|
188
|
+
- `relay()` publishes one batch per call. Call it on a schedule, not once.
|
|
189
|
+
:::
|
|
190
|
+
|
|
191
|
+
## Usage
|
|
192
|
+
|
|
193
|
+
Build an outbox in PostgreSQL. Each step shows the whole file: added lines are highlighted, replaced lines are struck out.
|
|
194
|
+
|
|
195
|
+
<div class="al-cards">
|
|
196
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-share-the-transaction">Share the transaction</a></span>Write where the order is saved.</div>
|
|
197
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-the-adapter">Declare the adapter</a></span>Extend the port.</div>
|
|
198
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-add-inside-the-transaction">Add inside the transaction</a></span>Saved with the order, or not at all.</div>
|
|
199
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-read-what-is-pending">Read what is pending</a></span>Oldest first, a batch at a time.</div>
|
|
200
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-mark-them-published">Mark them published</a></span>Never sent twice on purpose.</div>
|
|
201
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">6</span><a href="#_6-run-the-relay">Run the relay</a></span>On a schedule, in a driving adapter.</div>
|
|
202
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">7</span><a href="#_7-check-it">Check it</a></span>Let the rules keep it that way.</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
### 1. Share the transaction
|
|
206
|
+
|
|
207
|
+
The outbox writes in the transaction of the [unit of work](./unit-of-work.md#usage), which shares its connection through an `AsyncLocalStorage`: build that first.
|
|
208
|
+
|
|
209
|
+
### 2. Declare the adapter
|
|
210
|
+
|
|
211
|
+
The adapter extends `Outbox` and receives the pool, for the relay, and the storage of the current connection, for the handler.
|
|
212
|
+
|
|
213
|
+
```ts [src/shared-kernel/driven/pg/adapters/pg-outbox.adapter.ts]
|
|
214
|
+
import type { AsyncLocalStorage } from "node:async_hooks";
|
|
215
|
+
|
|
216
|
+
import { type AnyIntegrationEvent, Outbox } from "@alveolus/core";
|
|
217
|
+
import type { Pool, PoolClient } from "pg";
|
|
218
|
+
|
|
219
|
+
export class PgOutbox extends Outbox {
|
|
220
|
+
constructor(
|
|
221
|
+
private readonly pool: Pool,
|
|
222
|
+
private readonly current: AsyncLocalStorage<PoolClient>,
|
|
223
|
+
) {
|
|
224
|
+
super();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
TypeScript now asks for `add`, `pending` and `markPublished`: the next steps add them.
|
|
230
|
+
|
|
231
|
+
### 3. Add inside the transaction
|
|
232
|
+
|
|
233
|
+
So that an event is never stored for an order that was not saved, `add` writes through the connection of the current unit of work, and refuses to run outside one. Each event is stored as it is, in a `jsonb` column.
|
|
234
|
+
|
|
235
|
+
```ts [src/shared-kernel/driven/pg/adapters/pg-outbox.adapter.ts]
|
|
236
|
+
import type { AsyncLocalStorage } from "node:async_hooks";
|
|
237
|
+
|
|
238
|
+
import { type AnyIntegrationEvent, Outbox } from "@alveolus/core";
|
|
239
|
+
import type { Pool, PoolClient } from "pg";
|
|
240
|
+
|
|
241
|
+
export class PgOutbox extends Outbox {
|
|
242
|
+
constructor(
|
|
243
|
+
private readonly pool: Pool,
|
|
244
|
+
private readonly current: AsyncLocalStorage<PoolClient>,
|
|
245
|
+
) {
|
|
246
|
+
super();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async add(events: readonly AnyIntegrationEvent[]): Promise<void> { // [!code ++]
|
|
250
|
+
const client = this.current.getStore(); // [!code ++]
|
|
251
|
+
if (client === undefined) { // [!code ++]
|
|
252
|
+
throw new Error("PgOutbox.add runs inside a unit of work."); // [!code ++]
|
|
253
|
+
} // [!code ++]
|
|
254
|
+
for (const event of events) { // [!code ++]
|
|
255
|
+
await client.query( // [!code ++]
|
|
256
|
+
"INSERT INTO outbox (id, event) VALUES ($1, $2)", // [!code ++]
|
|
257
|
+
[event.id, event], // [!code ++]
|
|
258
|
+
); // [!code ++]
|
|
259
|
+
} // [!code ++]
|
|
260
|
+
} // [!code ++]
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### 4. Read what is pending
|
|
265
|
+
|
|
266
|
+
The relay reads the events not yet published, in the order they were added, through the pool: it runs outside any transaction.
|
|
267
|
+
|
|
268
|
+
```ts [src/shared-kernel/driven/pg/adapters/pg-outbox.adapter.ts]
|
|
269
|
+
import type { AsyncLocalStorage } from "node:async_hooks";
|
|
270
|
+
|
|
271
|
+
import { type AnyIntegrationEvent, Outbox } from "@alveolus/core";
|
|
272
|
+
import type { Pool, PoolClient } from "pg";
|
|
273
|
+
|
|
274
|
+
export class PgOutbox extends Outbox {
|
|
275
|
+
constructor(
|
|
276
|
+
private readonly pool: Pool,
|
|
277
|
+
private readonly current: AsyncLocalStorage<PoolClient>,
|
|
278
|
+
) {
|
|
279
|
+
super();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async add(events: readonly AnyIntegrationEvent[]): Promise<void> {
|
|
283
|
+
const client = this.current.getStore();
|
|
284
|
+
if (client === undefined) {
|
|
285
|
+
throw new Error("PgOutbox.add runs inside a unit of work.");
|
|
286
|
+
}
|
|
287
|
+
for (const event of events) {
|
|
288
|
+
await client.query(
|
|
289
|
+
"INSERT INTO outbox (id, event) VALUES ($1, $2)",
|
|
290
|
+
[event.id, event],
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async pending( // [!code ++]
|
|
296
|
+
limit: number, // [!code ++]
|
|
297
|
+
): Promise<readonly AnyIntegrationEvent[]> { // [!code ++]
|
|
298
|
+
const { rows } = await this.pool.query( // [!code ++]
|
|
299
|
+
`SELECT event FROM outbox // [!code ++]
|
|
300
|
+
WHERE published_at IS NULL // [!code ++]
|
|
301
|
+
ORDER BY created_at // [!code ++]
|
|
302
|
+
LIMIT $1`, // [!code ++]
|
|
303
|
+
[limit], // [!code ++]
|
|
304
|
+
); // [!code ++]
|
|
305
|
+
return rows.map((row) => row.event); // [!code ++]
|
|
306
|
+
} // [!code ++]
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### 5. Mark them published
|
|
311
|
+
|
|
312
|
+
Once the publisher has sent a batch, the relay marks it published, so the next run skips it.
|
|
313
|
+
|
|
314
|
+
```ts [src/shared-kernel/driven/pg/adapters/pg-outbox.adapter.ts]
|
|
315
|
+
import type { AsyncLocalStorage } from "node:async_hooks";
|
|
316
|
+
|
|
317
|
+
import { type AnyIntegrationEvent, Outbox } from "@alveolus/core";
|
|
318
|
+
import type { Pool, PoolClient } from "pg";
|
|
319
|
+
|
|
320
|
+
export class PgOutbox extends Outbox {
|
|
321
|
+
constructor(
|
|
322
|
+
private readonly pool: Pool,
|
|
323
|
+
private readonly current: AsyncLocalStorage<PoolClient>,
|
|
324
|
+
) {
|
|
325
|
+
super();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async add(events: readonly AnyIntegrationEvent[]): Promise<void> {
|
|
329
|
+
const client = this.current.getStore();
|
|
330
|
+
if (client === undefined) {
|
|
331
|
+
throw new Error("PgOutbox.add runs inside a unit of work.");
|
|
332
|
+
}
|
|
333
|
+
for (const event of events) {
|
|
334
|
+
await client.query(
|
|
335
|
+
"INSERT INTO outbox (id, event) VALUES ($1, $2)",
|
|
336
|
+
[event.id, event],
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async pending(
|
|
342
|
+
limit: number,
|
|
343
|
+
): Promise<readonly AnyIntegrationEvent[]> {
|
|
344
|
+
const { rows } = await this.pool.query(
|
|
345
|
+
`SELECT event FROM outbox
|
|
346
|
+
WHERE published_at IS NULL
|
|
347
|
+
ORDER BY created_at
|
|
348
|
+
LIMIT $1`,
|
|
349
|
+
[limit],
|
|
350
|
+
);
|
|
351
|
+
return rows.map((row) => row.event);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async markPublished(ids: readonly string[]): Promise<void> { // [!code ++]
|
|
355
|
+
await this.pool.query( // [!code ++]
|
|
356
|
+
"UPDATE outbox SET published_at = now() WHERE id = ANY($1)", // [!code ++]
|
|
357
|
+
[ids], // [!code ++]
|
|
358
|
+
); // [!code ++]
|
|
359
|
+
} // [!code ++]
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
This is the complete outbox.
|
|
364
|
+
|
|
365
|
+
### 6. Run the relay
|
|
366
|
+
|
|
367
|
+
The composition root builds the relay, `new OutboxRelay(outbox, publisher, 100)`, and a driving adapter runs it on a schedule. A failure is logged: the events stay pending for the next tick.
|
|
368
|
+
|
|
369
|
+
```ts [src/ordering/driving/timer/jobs/outbox-relay.job.ts]
|
|
370
|
+
import type { OutboxRelay } from "@alveolus/core";
|
|
371
|
+
|
|
372
|
+
export class OutboxRelayJob {
|
|
373
|
+
constructor(private readonly relay: OutboxRelay) {}
|
|
374
|
+
|
|
375
|
+
start(): NodeJS.Timeout {
|
|
376
|
+
return setInterval(() => this.tick(), 1000);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private async tick(): Promise<void> {
|
|
380
|
+
try {
|
|
381
|
+
await this.relay.relay();
|
|
382
|
+
} catch (error) {
|
|
383
|
+
console.error("Relay failed; events stay pending.", error);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### 7. Check it
|
|
390
|
+
|
|
391
|
+
Run the checks. Two rules keep the outbox the way it is now:
|
|
392
|
+
|
|
393
|
+
```sh
|
|
394
|
+
npx alveolus arch check
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
<div class="al-cards">
|
|
398
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/layers/no-portless-adapter"><code>no-portless-adapter</code></a></span>It extends the port it implements.</div>
|
|
399
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-misplaced-class"><code>no-misplaced-class</code></a></span>It stays alone in <code>driven/pg/adapters/*.adapter.ts</code>.</div>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
The same class without <code>extends Outbox</code> is reported:
|
|
403
|
+
|
|
404
|
+
```
|
|
405
|
+
src/shared-kernel/driven/pg/adapters/pg-outbox.adapter.ts
|
|
406
|
+
6 error layers/no-portless-adapter: PgOutbox is a driven adapter
|
|
407
|
+
but extends no Port: extend the port it implements.
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
## See also
|
|
411
|
+
|
|
412
|
+
- [Unit of Work](./unit-of-work.md), the transaction the outbox is written in
|
|
413
|
+
- [Event translators](./event-translators.md), which build what the outbox stores
|
|
414
|
+
- [Integration events](./integration-events.md), what it stores
|
|
415
|
+
- [Event publishers](./event-publishers.md), what the relay calls
|
|
416
|
+
- Rules: [`layers/no-portless-adapter`](../../rules/layers/no-portless-adapter.md)
|