@entifix/amqp 0.1.0-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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 r10c Technologies
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.
@@ -0,0 +1,63 @@
1
+ import type { WiringRegistry } from '@entifix/business';
2
+ import { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';
3
+ import { type EventBus, EventBusTag, type Subscription } from '@entifix/transactions';
4
+ import { Effect, Layer } from 'effect';
5
+ import { AmqpChannelTag, type AmqpConnector } from '../amqp-connection/amqp-connection.js';
6
+ /**
7
+ * The queue a subscription binds.
8
+ *
9
+ * `<subscribing slice>.<pattern>`, so the name says who is owed the messages in
10
+ * it — readable in the broker's own UI, which is where someone looks when a
11
+ * queue has depth. The slice is the *subscriber's*, never the publisher's:
12
+ * `EventSourceTag` names the emitter, and one deployment hosts several slices
13
+ * (ADR 0021 co-deploys `transaction` inside marketplace-admin-service), so
14
+ * deriving from it would file a consumer's queue under whoever happened to
15
+ * publish from the same process.
16
+ *
17
+ * `*` and `#` are spelled out because AMQP 0-9-1 restricts a queue name to
18
+ * letters, digits, hyphen, underscore, period and colon. RabbitMQ is lenient in
19
+ * practice, but the name also has to survive being a URL path segment in the
20
+ * management API, and `%2A` in an operator's address bar is a poor trade for
21
+ * three saved characters.
22
+ */
23
+ export declare const queueNameFor: (subscription: Subscription) => string;
24
+ /**
25
+ * RabbitMQ-backed {@link EventBus}. Events go out as `event` envelopes on the
26
+ * shared topic exchange — the adapter owns the wire framing, so the port stays
27
+ * event-typed.
28
+ *
29
+ * **The event's own name is the routing key**, which is what lets a subscriber
30
+ * bind the exact pattern it declared in `tools/slices/*.slice.ts`.
31
+ *
32
+ * The queue a subscriber binds is its own choice of shape (ADR 0030). A `work`
33
+ * subscription gets a named durable queue that replicas share and that keeps
34
+ * accumulating while the consumer is down; a `broadcast` one gets the exclusive
35
+ * queue ADR 0029 built, where every replica sees every event and nothing
36
+ * survives a restart. Choosing broadcast for a workload that is work is how an
37
+ * event published during a rollout is dropped by the broker while the outbox
38
+ * has already recorded it sent.
39
+ *
40
+ * Both methods go through the {@link AmqpConnector} rather than holding a
41
+ * channel, because a channel does not survive the broker restarting. `publish`
42
+ * asks for a live one per call, and `subscribe` registers its setup so the
43
+ * connector can re-run it against the new channel after a reconnect. A durable
44
+ * queue outlives that reconnect; the consumer bound to it does not.
45
+ *
46
+ * It returns the bus **and** its `drainDeliveries`, rather than only the port:
47
+ * waiting for in-flight handlers is an adapter fact — it is this module that
48
+ * runs them — and putting it on `EventBus` would make every implementation of
49
+ * a framework-free port carry a shutdown concern it has no deliveries to drain.
50
+ */
51
+ export declare const makeAmqpEventBus: (connector: AmqpConnector, wiring: WiringRegistry) => {
52
+ bus: EventBus;
53
+ drainDeliveries: Effect.Effect<void>;
54
+ };
55
+ /**
56
+ * Provides {@link EventBusTag} from an {@link AmqpChannelTag}.
57
+ *
58
+ * It also takes {@link WiringRegistryTag}, which `makeServerLayer` provides once
59
+ * per service — so the bus and `GET /api/$service` share one instance, and the
60
+ * document reports the bindings this very process made.
61
+ */
62
+ export declare const AmqpEventBusLayer: Layer.Layer<EventBusTag, never, AmqpChannelTag | ShutdownRegistryTag | WiringRegistryTag>;
63
+ //# sourceMappingURL=amqp-event-bus.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"amqp-event-bus.d.ts","sourceRoot":"","sources":["../../src/adapters/amqp-event-bus.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAO3E,OAAO,EACL,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,YAAY,EAElB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAY,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEjD,OAAO,EACL,cAAc,EACd,KAAK,aAAa,EAGnB,MAAM,uCAAuC,CAAC;AAqC/C;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,YAAY,GAAI,cAAc,YAAY,KAAG,MAG1B,CAAC;AAiEjC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,gBAAgB,GAC3B,WAAW,aAAa,EACxB,QAAQ,cAAc,KACrB;IAAE,GAAG,EAAE,QAAQ,CAAC;IAAC,eAAe,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;CAuLvD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,2FAuB7B,CAAC"}
@@ -0,0 +1,257 @@
1
+ import { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';
2
+ import { EntifixBuildError, EntifixConnError, makeEventEnvelope, readEventEnvelope } from '@entifix/core';
3
+ import { EventBusTag } from '@entifix/transactions';
4
+ import { Duration, Effect, Layer } from 'effect';
5
+ import { AmqpChannelTag, EVENTS_DLX, EVENTS_EXCHANGE } from '../amqp-connection/amqp-connection.js';
6
+ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } from './bus-metrics.js';
7
+ /** Appended to a work queue's name to make the queue that holds its failures. */ const QUARANTINE_SUFFIX = '.quarantine';
8
+ /**
9
+ * How often the shutdown drain re-checks whether the handlers have finished.
10
+ *
11
+ * A poll rather than a latch because a delivery is started from amqplib's own
12
+ * callback, outside any fiber this module holds; the drain's own bound is the
13
+ * shutdown hook's, so this interval only decides how promptly the process
14
+ * notices it is free to go.
15
+ */ const DELIVERY_POLL = Duration.millis(25);
16
+ /**
17
+ * The queue a subscription binds.
18
+ *
19
+ * `<subscribing slice>.<pattern>`, so the name says who is owed the messages in
20
+ * it — readable in the broker's own UI, which is where someone looks when a
21
+ * queue has depth. The slice is the *subscriber's*, never the publisher's:
22
+ * `EventSourceTag` names the emitter, and one deployment hosts several slices
23
+ * (ADR 0021 co-deploys `transaction` inside marketplace-admin-service), so
24
+ * deriving from it would file a consumer's queue under whoever happened to
25
+ * publish from the same process.
26
+ *
27
+ * `*` and `#` are spelled out because AMQP 0-9-1 restricts a queue name to
28
+ * letters, digits, hyphen, underscore, period and colon. RabbitMQ is lenient in
29
+ * practice, but the name also has to survive being a URL path segment in the
30
+ * management API, and `%2A` in an operator's address bar is a poor trade for
31
+ * three saved characters.
32
+ */ export const queueNameFor = (subscription)=>`${subscription.slice}.${subscription.pattern.replaceAll('*', '_star_').replaceAll('#', '_hash_')}`;
33
+ /**
34
+ * Declares the queue a subscription consumes and returns its name.
35
+ *
36
+ * A **work** queue is durable and quorum-backed, so it survives the consumer's
37
+ * restart and accumulates while it is gone — the hop ADR 0028's durability
38
+ * chain was missing. Its retry ceiling is `x-delivery-limit`, a queue argument
39
+ * rather than logic every consumer reimplements, so it also counts a
40
+ * redelivery after a crash.
41
+ *
42
+ * The quarantine is declared **first**: a message dead-lettered the instant the
43
+ * work queue exists must have somewhere to land, and a `direct` exchange with
44
+ * no matching binding drops what it routes.
45
+ *
46
+ * `x-delivery-limit` is immutable once a queue exists. Re-declaring with a
47
+ * different `maxAttempts` fails `PRECONDITION_FAILED` and closes the channel,
48
+ * and there is no safe automatic recovery — so changing the ceiling means
49
+ * deleting the queue, which locally is `pnpm run <app>:dev:reset`.
50
+ */ const declareQueue = async (channel, subscription)=>{
51
+ if (subscription.mode === 'broadcast') {
52
+ // Connection-scoped and retained by nobody, which is right only when the
53
+ // replicas differ from one another (#136's socket push). Unchanged from
54
+ // what ADR 0029 built.
55
+ const { queue } = await channel.assertQueue('', {
56
+ exclusive: true
57
+ });
58
+ return queue;
59
+ }
60
+ const queue = queueNameFor(subscription);
61
+ const quarantine = `${queue}${QUARANTINE_SUFFIX}`;
62
+ await channel.assertQueue(quarantine, {
63
+ durable: true,
64
+ arguments: {
65
+ 'x-queue-type': 'quorum'
66
+ }
67
+ });
68
+ await channel.bindQueue(quarantine, EVENTS_DLX, queue);
69
+ await channel.assertQueue(queue, {
70
+ durable: true,
71
+ arguments: {
72
+ 'x-queue-type': 'quorum',
73
+ 'x-delivery-limit': subscription.maxAttempts,
74
+ 'x-dead-letter-exchange': EVENTS_DLX,
75
+ 'x-dead-letter-routing-key': queue
76
+ }
77
+ });
78
+ return queue;
79
+ };
80
+ /**
81
+ * Whether a failed delivery should go back on the queue.
82
+ *
83
+ * Only a transient failure on a **work** queue: there the broker counts the
84
+ * redelivery against `x-delivery-limit` and dead-letters at the ceiling, so a
85
+ * requeue is bounded. A broadcast queue has neither a ceiling nor a dead-letter
86
+ * path, so requeueing there is an unbounded loop — the fault ADR 0029 named and
87
+ * the reason its comment said not to.
88
+ */ const requeues = (failure, mode)=>failure === 'transient' && mode === 'work';
89
+ /**
90
+ * RabbitMQ-backed {@link EventBus}. Events go out as `event` envelopes on the
91
+ * shared topic exchange — the adapter owns the wire framing, so the port stays
92
+ * event-typed.
93
+ *
94
+ * **The event's own name is the routing key**, which is what lets a subscriber
95
+ * bind the exact pattern it declared in `tools/slices/*.slice.ts`.
96
+ *
97
+ * The queue a subscriber binds is its own choice of shape (ADR 0030). A `work`
98
+ * subscription gets a named durable queue that replicas share and that keeps
99
+ * accumulating while the consumer is down; a `broadcast` one gets the exclusive
100
+ * queue ADR 0029 built, where every replica sees every event and nothing
101
+ * survives a restart. Choosing broadcast for a workload that is work is how an
102
+ * event published during a rollout is dropped by the broker while the outbox
103
+ * has already recorded it sent.
104
+ *
105
+ * Both methods go through the {@link AmqpConnector} rather than holding a
106
+ * channel, because a channel does not survive the broker restarting. `publish`
107
+ * asks for a live one per call, and `subscribe` registers its setup so the
108
+ * connector can re-run it against the new channel after a reconnect. A durable
109
+ * queue outlives that reconnect; the consumer bound to it does not.
110
+ *
111
+ * It returns the bus **and** its `drainDeliveries`, rather than only the port:
112
+ * waiting for in-flight handlers is an adapter fact — it is this module that
113
+ * runs them — and putting it on `EventBus` would make every implementation of
114
+ * a framework-free port carry a shutdown concern it has no deliveries to drain.
115
+ */ export const makeAmqpEventBus = (connector, wiring)=>{
116
+ /**
117
+ * Handlers started and not yet settled.
118
+ *
119
+ * Nothing tracked them before, so a SIGTERM killed them mid-flight and the
120
+ * broker redelivered whatever was unacked — safe only while every consumer
121
+ * happens to be idempotent, which is the assumption #178 stops making.
122
+ */ let inFlight = 0;
123
+ const bus = {
124
+ publish: (event)=>Effect.tryPromise({
125
+ try: ()=>connector.withChannel(async (channel)=>{
126
+ channel.publish(EVENTS_EXCHANGE, event.name, Buffer.from(JSON.stringify(makeEventEnvelope(event))), {
127
+ persistent: true
128
+ });
129
+ }),
130
+ catch: (error)=>new EntifixConnError('AMQP publish failed', error, {
131
+ eventId: event.id,
132
+ name: event.name
133
+ })
134
+ }).pipe(Effect.tapBoth({
135
+ onSuccess: ()=>wiring.recordPublish(event.name).pipe(Effect.andThen(recordPublished(event.name))),
136
+ // Counted here and not in the `catch` above, which only builds the
137
+ // error: a rising publish-failure rate against a flat published rate
138
+ // is what a broker outage looks like from the publisher's side, and
139
+ // the outbox's own backlog is the confirmation.
140
+ onFailure: ()=>recordPublishFailure(event.name)
141
+ })),
142
+ subscribe: (subscription, handler)=>Effect.tryPromise({
143
+ try: ()=>connector.addConsumer(async (channel)=>{
144
+ // One unacked message at a time: events for a transaction are then
145
+ // folded serially (never concurrently), so an `accepted`/`completed`
146
+ // pair can't race into two upserts.
147
+ await channel.prefetch(1);
148
+ const queue = await declareQueue(channel, subscription);
149
+ await channel.bindQueue(queue, EVENTS_EXCHANGE, subscription.pattern);
150
+ // What the process **bound**, recorded at the point it bound it. The
151
+ // registry deduplicates, so re-running this setup after a reconnect
152
+ // does not double the entry.
153
+ await Effect.runPromise(wiring.recordSubscription({
154
+ slice: subscription.slice,
155
+ pattern: subscription.pattern,
156
+ mode: subscription.mode,
157
+ queue
158
+ }));
159
+ // Resolved once per binding rather than per delivery: `queue` is the
160
+ // durable name for a `work` subscription and the broker's generated
161
+ // one for a `broadcast` queue, so it is only knowable here.
162
+ const tags = {
163
+ queue,
164
+ slice: subscription.slice,
165
+ mode: subscription.mode
166
+ };
167
+ const { consumerTag } = await channel.consume(queue, (message)=>{
168
+ if (message === null) {
169
+ return;
170
+ }
171
+ // Reading the payload and running the handler are separated on
172
+ // purpose: they fail for different reasons and earn different
173
+ // treatment. `JSON.parse` is inside the Effect because it throws
174
+ // *synchronously* — outside, it escaped into amqplib's callback and
175
+ // the message was never even nacked.
176
+ const settle = Effect.try({
177
+ try: ()=>JSON.parse(message.content.toString()),
178
+ catch: ()=>'poison'
179
+ }).pipe(Effect.flatMap((parsed)=>readEventEnvelope(parsed).pipe(Effect.mapError(()=>'poison'))), // The handler carries no requirements (the subscriber closes over
180
+ // its store), so it runs standalone.
181
+ //
182
+ // ⚠️ A handler failure is transient **except** an
183
+ // `EntifixBuildError`, which means malformed input — the
184
+ // client's fault, by that class's own definition. The
185
+ // transport validates `meta` and deliberately not `data`
186
+ // (`readEventEnvelope` says so: inventing an opinion about a
187
+ // payload is how the bus would start knowing about domains),
188
+ // so a consumer that decodes its own payload is the *only*
189
+ // thing that can tell a malformed one from a bad afternoon at
190
+ // the database. Without this arm such a message is requeued
191
+ // and retried to `x-delivery-limit` before it reaches the
192
+ // quarantine it belonged in immediately — spending the budget
193
+ // of every message behind it, which is exactly what ADR 0030
194
+ // gives poison zero retries to avoid. Measured on
195
+ // `catalog.published`: a payload the contract rejects took
196
+ // five deliveries to quarantine.
197
+ Effect.flatMap((event)=>handler(event).pipe(Effect.mapError((error)=>error instanceof EntifixBuildError ? 'poison' : 'transient'))));
198
+ // Counted around the whole settle, so a shutdown waits for the ack
199
+ // as well as the handler: cancelling a consumer stops *new*
200
+ // deliveries and says nothing about this one.
201
+ inFlight += 1;
202
+ void Effect.runPromise(settle.pipe(// Effect's metric registry is a process-wide singleton, so an
203
+ // increment inside this detached `runPromise` reaches the same
204
+ // producer `NodeSdk` exports from. That is the only reason a
205
+ // metric works here at all — nothing about this fiber is
206
+ // inside the observability layer.
207
+ Effect.matchEffect({
208
+ onSuccess: ()=>recordConsumed(tags).pipe(Effect.andThen(Effect.sync(()=>channel.ack(message)))),
209
+ onFailure: (failure)=>recordFailed(tags, failure).pipe(Effect.andThen(Effect.sync(()=>channel.nack(message, false, requeues(failure, subscription.mode)))))
210
+ }))).finally(()=>{
211
+ inFlight -= 1;
212
+ });
213
+ });
214
+ return [
215
+ consumerTag
216
+ ];
217
+ }),
218
+ catch: (error)=>new EntifixConnError('AMQP subscribe failed', error, {
219
+ slice: subscription.slice,
220
+ pattern: subscription.pattern
221
+ })
222
+ })
223
+ };
224
+ return {
225
+ bus,
226
+ drainDeliveries: Effect.gen(function*() {
227
+ while(inFlight > 0){
228
+ yield* Effect.sleep(DELIVERY_POLL);
229
+ }
230
+ })
231
+ };
232
+ };
233
+ /**
234
+ * Provides {@link EventBusTag} from an {@link AmqpChannelTag}.
235
+ *
236
+ * It also takes {@link WiringRegistryTag}, which `makeServerLayer` provides once
237
+ * per service — so the bus and `GET /api/$service` share one instance, and the
238
+ * document reports the bindings this very process made.
239
+ */ export const AmqpEventBusLayer = Layer.effect(EventBusTag, Effect.gen(function*() {
240
+ const connector = yield* AmqpChannelTag;
241
+ const wiring = yield* WiringRegistryTag;
242
+ const shutdown = yield* ShutdownRegistryTag;
243
+ const { bus, drainDeliveries } = makeAmqpEventBus(connector, wiring);
244
+ // `stop-intake`, and both halves are it: cancelling stops the broker
245
+ // handing over anything new, then the drain waits for the deliveries
246
+ // already in progress. Registered here rather than in a service's `main.ts`
247
+ // for the reason every registry in this repo exists — a service that gains
248
+ // a bus gains its drain, with nothing to remember.
249
+ yield* shutdown.register({
250
+ name: 'amqp-consumers',
251
+ phase: 'stop-intake',
252
+ run: Effect.promise(()=>connector.cancelConsumers()).pipe(Effect.andThen(drainDeliveries))
253
+ });
254
+ return bus;
255
+ }));
256
+
257
+ //# sourceMappingURL=amqp-event-bus.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/amqp-event-bus.ts"],"sourcesContent":["import type { WiringRegistry } from '@entifix/business';\nimport { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport {\n EntifixBuildError,\n EntifixConnError,\n makeEventEnvelope,\n readEventEnvelope,\n} from '@entifix/core';\nimport {\n type EventBus,\n EventBusTag,\n type Subscription,\n type SubscriptionMode,\n} from '@entifix/transactions';\nimport type { Channel, ConsumeMessage } from 'amqplib';\nimport { Duration, Effect, Layer } from 'effect';\n\nimport {\n AmqpChannelTag,\n type AmqpConnector,\n EVENTS_DLX,\n EVENTS_EXCHANGE,\n} from '../amqp-connection/amqp-connection.js';\nimport {\n recordConsumed,\n recordFailed,\n recordPublished,\n recordPublishFailure,\n type SubscriptionTags,\n} from './bus-metrics.js';\n\n/** Appended to a work queue's name to make the queue that holds its failures. */\nconst QUARANTINE_SUFFIX = '.quarantine';\n\n/**\n * How often the shutdown drain re-checks whether the handlers have finished.\n *\n * A poll rather than a latch because a delivery is started from amqplib's own\n * callback, outside any fiber this module holds; the drain's own bound is the\n * shutdown hook's, so this interval only decides how promptly the process\n * notices it is free to go.\n */\nconst DELIVERY_POLL = Duration.millis(25);\n\n/**\n * Why a delivery did not succeed, which is the whole of the nack decision.\n *\n * `transient` is a handler that threw — a dependency briefly gone, a timeout —\n * and is worth another delivery. `poison` is a payload this process cannot\n * read, and is worth **none**: a message that cannot be deserialized never\n * becomes deserializable, so retrying it only spends the delivery budget of the\n * messages queued behind it. Conflating the two is what the adapter did before\n * ADR 0030, on a queue that discarded both.\n *\n * A *business* failure is neither, and never reaches here: that message was\n * handled successfully and produced its own `failed` event.\n */\ntype DeliveryFailure = 'transient' | 'poison';\n\n/**\n * The queue a subscription binds.\n *\n * `<subscribing slice>.<pattern>`, so the name says who is owed the messages in\n * it — readable in the broker's own UI, which is where someone looks when a\n * queue has depth. The slice is the *subscriber's*, never the publisher's:\n * `EventSourceTag` names the emitter, and one deployment hosts several slices\n * (ADR 0021 co-deploys `transaction` inside marketplace-admin-service), so\n * deriving from it would file a consumer's queue under whoever happened to\n * publish from the same process.\n *\n * `*` and `#` are spelled out because AMQP 0-9-1 restricts a queue name to\n * letters, digits, hyphen, underscore, period and colon. RabbitMQ is lenient in\n * practice, but the name also has to survive being a URL path segment in the\n * management API, and `%2A` in an operator's address bar is a poor trade for\n * three saved characters.\n */\nexport const queueNameFor = (subscription: Subscription): string =>\n `${subscription.slice}.${subscription.pattern\n .replaceAll('*', '_star_')\n .replaceAll('#', '_hash_')}`;\n\n/**\n * Declares the queue a subscription consumes and returns its name.\n *\n * A **work** queue is durable and quorum-backed, so it survives the consumer's\n * restart and accumulates while it is gone — the hop ADR 0028's durability\n * chain was missing. Its retry ceiling is `x-delivery-limit`, a queue argument\n * rather than logic every consumer reimplements, so it also counts a\n * redelivery after a crash.\n *\n * The quarantine is declared **first**: a message dead-lettered the instant the\n * work queue exists must have somewhere to land, and a `direct` exchange with\n * no matching binding drops what it routes.\n *\n * `x-delivery-limit` is immutable once a queue exists. Re-declaring with a\n * different `maxAttempts` fails `PRECONDITION_FAILED` and closes the channel,\n * and there is no safe automatic recovery — so changing the ceiling means\n * deleting the queue, which locally is `pnpm run <app>:dev:reset`.\n */\nconst declareQueue = async (\n channel: Channel,\n subscription: Subscription,\n): Promise<string> => {\n if (subscription.mode === 'broadcast') {\n // Connection-scoped and retained by nobody, which is right only when the\n // replicas differ from one another (#136's socket push). Unchanged from\n // what ADR 0029 built.\n const { queue } = await channel.assertQueue('', { exclusive: true });\n return queue;\n }\n\n const queue = queueNameFor(subscription);\n const quarantine = `${queue}${QUARANTINE_SUFFIX}`;\n\n await channel.assertQueue(quarantine, {\n durable: true,\n arguments: { 'x-queue-type': 'quorum' },\n });\n await channel.bindQueue(quarantine, EVENTS_DLX, queue);\n await channel.assertQueue(queue, {\n durable: true,\n arguments: {\n 'x-queue-type': 'quorum',\n 'x-delivery-limit': subscription.maxAttempts,\n 'x-dead-letter-exchange': EVENTS_DLX,\n 'x-dead-letter-routing-key': queue,\n },\n });\n\n return queue;\n};\n\n/**\n * Whether a failed delivery should go back on the queue.\n *\n * Only a transient failure on a **work** queue: there the broker counts the\n * redelivery against `x-delivery-limit` and dead-letters at the ceiling, so a\n * requeue is bounded. A broadcast queue has neither a ceiling nor a dead-letter\n * path, so requeueing there is an unbounded loop — the fault ADR 0029 named and\n * the reason its comment said not to.\n */\nconst requeues = (failure: DeliveryFailure, mode: SubscriptionMode): boolean =>\n failure === 'transient' && mode === 'work';\n\n/**\n * RabbitMQ-backed {@link EventBus}. Events go out as `event` envelopes on the\n * shared topic exchange — the adapter owns the wire framing, so the port stays\n * event-typed.\n *\n * **The event's own name is the routing key**, which is what lets a subscriber\n * bind the exact pattern it declared in `tools/slices/*.slice.ts`.\n *\n * The queue a subscriber binds is its own choice of shape (ADR 0030). A `work`\n * subscription gets a named durable queue that replicas share and that keeps\n * accumulating while the consumer is down; a `broadcast` one gets the exclusive\n * queue ADR 0029 built, where every replica sees every event and nothing\n * survives a restart. Choosing broadcast for a workload that is work is how an\n * event published during a rollout is dropped by the broker while the outbox\n * has already recorded it sent.\n *\n * Both methods go through the {@link AmqpConnector} rather than holding a\n * channel, because a channel does not survive the broker restarting. `publish`\n * asks for a live one per call, and `subscribe` registers its setup so the\n * connector can re-run it against the new channel after a reconnect. A durable\n * queue outlives that reconnect; the consumer bound to it does not.\n *\n * It returns the bus **and** its `drainDeliveries`, rather than only the port:\n * waiting for in-flight handlers is an adapter fact — it is this module that\n * runs them — and putting it on `EventBus` would make every implementation of\n * a framework-free port carry a shutdown concern it has no deliveries to drain.\n */\nexport const makeAmqpEventBus = (\n connector: AmqpConnector,\n wiring: WiringRegistry,\n): { bus: EventBus; drainDeliveries: Effect.Effect<void> } => {\n /**\n * Handlers started and not yet settled.\n *\n * Nothing tracked them before, so a SIGTERM killed them mid-flight and the\n * broker redelivered whatever was unacked — safe only while every consumer\n * happens to be idempotent, which is the assumption #178 stops making.\n */\n let inFlight = 0;\n\n const bus: EventBus = {\n publish: event =>\n Effect.tryPromise({\n try: () =>\n connector.withChannel(async channel => {\n channel.publish(\n EVENTS_EXCHANGE,\n event.name,\n Buffer.from(JSON.stringify(makeEventEnvelope(event))),\n { persistent: true },\n );\n }),\n catch: error =>\n new EntifixConnError('AMQP publish failed', error, {\n eventId: event.id,\n name: event.name,\n }),\n // Recorded **after** the publish succeeds, so `GET /api/$service` reports\n // what this process actually put on the exchange. Recording the intent\n // instead would make the declared-vs-observed diff pass on a service whose\n // every publish is failing (ADR 0031).\n }).pipe(\n Effect.tapBoth({\n onSuccess: () =>\n wiring\n .recordPublish(event.name)\n .pipe(Effect.andThen(recordPublished(event.name))),\n // Counted here and not in the `catch` above, which only builds the\n // error: a rising publish-failure rate against a flat published rate\n // is what a broker outage looks like from the publisher's side, and\n // the outbox's own backlog is the confirmation.\n onFailure: () => recordPublishFailure(event.name),\n }),\n ),\n\n subscribe: (subscription, handler) =>\n Effect.tryPromise({\n try: () =>\n connector.addConsumer(async channel => {\n // One unacked message at a time: events for a transaction are then\n // folded serially (never concurrently), so an `accepted`/`completed`\n // pair can't race into two upserts.\n await channel.prefetch(1);\n const queue = await declareQueue(channel, subscription);\n await channel.bindQueue(\n queue,\n EVENTS_EXCHANGE,\n subscription.pattern,\n );\n // What the process **bound**, recorded at the point it bound it. The\n // registry deduplicates, so re-running this setup after a reconnect\n // does not double the entry.\n await Effect.runPromise(\n wiring.recordSubscription({\n slice: subscription.slice,\n pattern: subscription.pattern,\n mode: subscription.mode,\n queue,\n }),\n );\n // Resolved once per binding rather than per delivery: `queue` is the\n // durable name for a `work` subscription and the broker's generated\n // one for a `broadcast` queue, so it is only knowable here.\n const tags: SubscriptionTags = {\n queue,\n slice: subscription.slice,\n mode: subscription.mode,\n };\n const { consumerTag } = await channel.consume(\n queue,\n (message: ConsumeMessage | null) => {\n if (message === null) {\n return;\n }\n\n // Reading the payload and running the handler are separated on\n // purpose: they fail for different reasons and earn different\n // treatment. `JSON.parse` is inside the Effect because it throws\n // *synchronously* — outside, it escaped into amqplib's callback and\n // the message was never even nacked.\n const settle = Effect.try({\n try: () => JSON.parse(message.content.toString()) as unknown,\n catch: (): DeliveryFailure => 'poison',\n }).pipe(\n Effect.flatMap(parsed =>\n readEventEnvelope(parsed).pipe(\n Effect.mapError((): DeliveryFailure => 'poison'),\n ),\n ),\n // The handler carries no requirements (the subscriber closes over\n // its store), so it runs standalone.\n //\n // ⚠️ A handler failure is transient **except** an\n // `EntifixBuildError`, which means malformed input — the\n // client's fault, by that class's own definition. The\n // transport validates `meta` and deliberately not `data`\n // (`readEventEnvelope` says so: inventing an opinion about a\n // payload is how the bus would start knowing about domains),\n // so a consumer that decodes its own payload is the *only*\n // thing that can tell a malformed one from a bad afternoon at\n // the database. Without this arm such a message is requeued\n // and retried to `x-delivery-limit` before it reaches the\n // quarantine it belonged in immediately — spending the budget\n // of every message behind it, which is exactly what ADR 0030\n // gives poison zero retries to avoid. Measured on\n // `catalog.published`: a payload the contract rejects took\n // five deliveries to quarantine.\n Effect.flatMap(event =>\n handler(event).pipe(\n Effect.mapError((error): DeliveryFailure =>\n error instanceof EntifixBuildError\n ? 'poison'\n : 'transient',\n ),\n ),\n ),\n );\n\n // Counted around the whole settle, so a shutdown waits for the ack\n // as well as the handler: cancelling a consumer stops *new*\n // deliveries and says nothing about this one.\n inFlight += 1;\n void Effect.runPromise(\n settle.pipe(\n // Effect's metric registry is a process-wide singleton, so an\n // increment inside this detached `runPromise` reaches the same\n // producer `NodeSdk` exports from. That is the only reason a\n // metric works here at all — nothing about this fiber is\n // inside the observability layer.\n Effect.matchEffect({\n onSuccess: () =>\n recordConsumed(tags).pipe(\n Effect.andThen(\n Effect.sync(() => channel.ack(message)),\n ),\n ),\n onFailure: failure =>\n recordFailed(tags, failure).pipe(\n Effect.andThen(\n Effect.sync(() =>\n channel.nack(\n message,\n false,\n requeues(failure, subscription.mode),\n ),\n ),\n ),\n ),\n }),\n ),\n ).finally(() => {\n inFlight -= 1;\n });\n },\n );\n return [consumerTag];\n }),\n catch: error =>\n new EntifixConnError('AMQP subscribe failed', error, {\n slice: subscription.slice,\n pattern: subscription.pattern,\n }),\n }),\n };\n\n return {\n bus,\n drainDeliveries: Effect.gen(function* () {\n while (inFlight > 0) {\n yield* Effect.sleep(DELIVERY_POLL);\n }\n }),\n };\n};\n\n/**\n * Provides {@link EventBusTag} from an {@link AmqpChannelTag}.\n *\n * It also takes {@link WiringRegistryTag}, which `makeServerLayer` provides once\n * per service — so the bus and `GET /api/$service` share one instance, and the\n * document reports the bindings this very process made.\n */\nexport const AmqpEventBusLayer = Layer.effect(\n EventBusTag,\n Effect.gen(function* () {\n const connector = yield* AmqpChannelTag;\n const wiring = yield* WiringRegistryTag;\n const shutdown = yield* ShutdownRegistryTag;\n const { bus, drainDeliveries } = makeAmqpEventBus(connector, wiring);\n\n // `stop-intake`, and both halves are it: cancelling stops the broker\n // handing over anything new, then the drain waits for the deliveries\n // already in progress. Registered here rather than in a service's `main.ts`\n // for the reason every registry in this repo exists — a service that gains\n // a bus gains its drain, with nothing to remember.\n yield* shutdown.register({\n name: 'amqp-consumers',\n phase: 'stop-intake',\n run: Effect.promise(() => connector.cancelConsumers()).pipe(\n Effect.andThen(drainDeliveries),\n ),\n });\n\n return bus;\n }),\n);\n"],"names":["ShutdownRegistryTag","WiringRegistryTag","EntifixBuildError","EntifixConnError","makeEventEnvelope","readEventEnvelope","EventBusTag","Duration","Effect","Layer","AmqpChannelTag","EVENTS_DLX","EVENTS_EXCHANGE","recordConsumed","recordFailed","recordPublished","recordPublishFailure","QUARANTINE_SUFFIX","DELIVERY_POLL","millis","queueNameFor","subscription","slice","pattern","replaceAll","declareQueue","channel","mode","queue","assertQueue","exclusive","quarantine","durable","arguments","bindQueue","maxAttempts","requeues","failure","makeAmqpEventBus","connector","wiring","inFlight","bus","publish","event","tryPromise","try","withChannel","name","Buffer","from","JSON","stringify","persistent","catch","error","eventId","id","pipe","tapBoth","onSuccess","recordPublish","andThen","onFailure","subscribe","handler","addConsumer","prefetch","runPromise","recordSubscription","tags","consumerTag","consume","message","settle","parse","content","toString","flatMap","parsed","mapError","matchEffect","sync","ack","nack","finally","drainDeliveries","gen","sleep","AmqpEventBusLayer","effect","shutdown","register","phase","run","promise","cancelConsumers"],"mappings":"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"}
@@ -0,0 +1,72 @@
1
+ import type { SubscriptionMode } from '@entifix/transactions';
2
+ import { Metric } from 'effect';
3
+ /**
4
+ * The bus half of ADR 0001's first metric set.
5
+ *
6
+ * Counters rather than logs because a quarantined message that is only ever a
7
+ * log line is indistinguishable from a dropped one when the question is *how
8
+ * many*. Nothing here needs plumbing: `NodeSdk` wires the metric reader through
9
+ * `@effect/opentelemetry`'s `Metrics.layer`, whose producer reads **Effect's own
10
+ * global registry**, so an increment in any fiber — including the detached one
11
+ * the AMQP consumer settles a delivery in — is exported.
12
+ */
13
+ /**
14
+ * ⚠️ These metric objects are **exported so a reader uses the same instance**.
15
+ * An Effect metric's registry key includes its description, so
16
+ * `Metric.counter('bus_events_published_total')` written out again — without the
17
+ * description — addresses a *different* series that is always zero. That is a
18
+ * silent way to write a dashboard query against nothing.
19
+ */
20
+ /** Events handed to the broker, by event name. */
21
+ export declare const busEventsPublished: Metric.Metric.Counter<number>;
22
+ /**
23
+ * Publishes the broker refused, by event name.
24
+ *
25
+ * Separate from the outbox's own failure count on purpose: this one says the
26
+ * broker would not take it, the outbox's says how many attempts that entry has
27
+ * spent. One is a rate, the other is a backlog.
28
+ */
29
+ export declare const busPublishFailures: Metric.Metric.Counter<number>;
30
+ /** Deliveries whose handler completed and were acked, by subscription. */
31
+ export declare const busEventsConsumed: Metric.Metric.Counter<number>;
32
+ /**
33
+ * Deliveries that were nacked, by subscription and failure class.
34
+ *
35
+ * ⚠️ **This is not a dead-letter count, and cannot be.** `x-delivery-limit` is
36
+ * what moves a message to `<queue>.quarantine`, and the broker does that without
37
+ * telling the adapter — there is no in-process call site for it. A transient
38
+ * failure on its fifth delivery is counted here exactly like its first. The
39
+ * honest quarantine count on the publisher side is the outbox's; on this side,
40
+ * a rising `failure="transient"` rate against a fixed `maxAttempts` is the
41
+ * signal, and the quarantine queue's own depth is the confirmation.
42
+ */
43
+ export declare const busEventsFailed: Metric.Metric.Counter<number>;
44
+ /**
45
+ * Identity of a subscription, as metric tags.
46
+ *
47
+ * `queue` is the durable work-queue name and so the only stable identity a
48
+ * broadcast subscription does *not* have — its queue is anonymous and
49
+ * regenerated per connection — which is why `slice` and `mode` are carried
50
+ * beside it rather than derived from it.
51
+ */
52
+ export interface SubscriptionTags {
53
+ queue: string;
54
+ slice: string;
55
+ mode: SubscriptionMode;
56
+ }
57
+ /** Record one event accepted by the broker. */
58
+ export declare const recordPublished: (eventName: string) => import("effect/Effect").Effect<void, never, never>;
59
+ /** Record one publish the broker refused. */
60
+ export declare const recordPublishFailure: (eventName: string) => import("effect/Effect").Effect<void, never, never>;
61
+ /** Record one delivery acked after its handler completed. */
62
+ export declare const recordConsumed: (tags: SubscriptionTags) => import("effect/Effect").Effect<void, never, never>;
63
+ /**
64
+ * Record one nacked delivery.
65
+ *
66
+ * `failure` is the class, never the event name: a poison message by definition
67
+ * could not be parsed, so it has no `event.name` to attribute it to, and
68
+ * dimensioning only the arm that has one would make the two counts
69
+ * incomparable.
70
+ */
71
+ export declare const recordFailed: (tags: SubscriptionTags, failure: "transient" | "poison") => import("effect/Effect").Effect<void, never, never>;
72
+ //# sourceMappingURL=bus-metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bus-metrics.d.ts","sourceRoot":"","sources":["../../src/adapters/bus-metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC;;;;;;;;;GASG;AAEH;;;;;;GAMG;AAEH,kDAAkD;AAClD,eAAO,MAAM,kBAAkB,+BAE7B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,+BAE7B,CAAC;AAEH,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,+BAE5B,CAAC;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,+BAI1B,CAAC;AAEH;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAYD,+CAA+C;AAC/C,eAAO,MAAM,eAAe,GAAI,WAAW,MAAM,uDACwB,CAAC;AAE1E,6CAA6C;AAC7C,eAAO,MAAM,oBAAoB,GAAI,WAAW,MAAM,uDACmB,CAAC;AAE1E,6DAA6D;AAC7D,eAAO,MAAM,cAAc,GAAI,MAAM,gBAAgB,uDACQ,CAAC;AAE9D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,GACvB,MAAM,gBAAgB,EACtB,SAAS,WAAW,GAAG,QAAQ,uDAI9B,CAAC"}
@@ -0,0 +1,58 @@
1
+ import { Metric } from 'effect';
2
+ /**
3
+ * The bus half of ADR 0001's first metric set.
4
+ *
5
+ * Counters rather than logs because a quarantined message that is only ever a
6
+ * log line is indistinguishable from a dropped one when the question is *how
7
+ * many*. Nothing here needs plumbing: `NodeSdk` wires the metric reader through
8
+ * `@effect/opentelemetry`'s `Metrics.layer`, whose producer reads **Effect's own
9
+ * global registry**, so an increment in any fiber — including the detached one
10
+ * the AMQP consumer settles a delivery in — is exported.
11
+ */ /**
12
+ * ⚠️ These metric objects are **exported so a reader uses the same instance**.
13
+ * An Effect metric's registry key includes its description, so
14
+ * `Metric.counter('bus_events_published_total')` written out again — without the
15
+ * description — addresses a *different* series that is always zero. That is a
16
+ * silent way to write a dashboard query against nothing.
17
+ */ /** Events handed to the broker, by event name. */ export const busEventsPublished = Metric.counter('bus_events_published_total', {
18
+ description: 'Events accepted by the broker, by event name.'
19
+ });
20
+ /**
21
+ * Publishes the broker refused, by event name.
22
+ *
23
+ * Separate from the outbox's own failure count on purpose: this one says the
24
+ * broker would not take it, the outbox's says how many attempts that entry has
25
+ * spent. One is a rate, the other is a backlog.
26
+ */ export const busPublishFailures = Metric.counter('bus_publish_failures_total', {
27
+ description: 'Publishes the broker refused, by event name.'
28
+ });
29
+ /** Deliveries whose handler completed and were acked, by subscription. */ export const busEventsConsumed = Metric.counter('bus_events_consumed_total', {
30
+ description: 'Deliveries acked after their handler completed.'
31
+ });
32
+ /**
33
+ * Deliveries that were nacked, by subscription and failure class.
34
+ *
35
+ * ⚠️ **This is not a dead-letter count, and cannot be.** `x-delivery-limit` is
36
+ * what moves a message to `<queue>.quarantine`, and the broker does that without
37
+ * telling the adapter — there is no in-process call site for it. A transient
38
+ * failure on its fifth delivery is counted here exactly like its first. The
39
+ * honest quarantine count on the publisher side is the outbox's; on this side,
40
+ * a rising `failure="transient"` rate against a fixed `maxAttempts` is the
41
+ * signal, and the quarantine queue's own depth is the confirmation.
42
+ */ export const busEventsFailed = Metric.counter('bus_events_failed_total', {
43
+ description: 'Deliveries nacked, by subscription and failure class. Not a ' + 'dead-letter count — the broker dead-letters without telling the adapter.'
44
+ });
45
+ const withSubscription = (metric, tags)=>metric.pipe(Metric.tagged('queue', tags.queue), Metric.tagged('slice', tags.slice), Metric.tagged('mode', tags.mode));
46
+ /** Record one event accepted by the broker. */ export const recordPublished = (eventName)=>Metric.increment(Metric.tagged(busEventsPublished, 'event', eventName));
47
+ /** Record one publish the broker refused. */ export const recordPublishFailure = (eventName)=>Metric.increment(Metric.tagged(busPublishFailures, 'event', eventName));
48
+ /** Record one delivery acked after its handler completed. */ export const recordConsumed = (tags)=>Metric.increment(withSubscription(busEventsConsumed, tags));
49
+ /**
50
+ * Record one nacked delivery.
51
+ *
52
+ * `failure` is the class, never the event name: a poison message by definition
53
+ * could not be parsed, so it has no `event.name` to attribute it to, and
54
+ * dimensioning only the arm that has one would make the two counts
55
+ * incomparable.
56
+ */ export const recordFailed = (tags, failure)=>Metric.increment(Metric.tagged(withSubscription(busEventsFailed, tags), 'failure', failure));
57
+
58
+ //# sourceMappingURL=bus-metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/bus-metrics.ts"],"sourcesContent":["import type { SubscriptionMode } from '@entifix/transactions';\nimport { Metric } from 'effect';\n\n/**\n * The bus half of ADR 0001's first metric set.\n *\n * Counters rather than logs because a quarantined message that is only ever a\n * log line is indistinguishable from a dropped one when the question is *how\n * many*. Nothing here needs plumbing: `NodeSdk` wires the metric reader through\n * `@effect/opentelemetry`'s `Metrics.layer`, whose producer reads **Effect's own\n * global registry**, so an increment in any fiber — including the detached one\n * the AMQP consumer settles a delivery in — is exported.\n */\n\n/**\n * ⚠️ These metric objects are **exported so a reader uses the same instance**.\n * An Effect metric's registry key includes its description, so\n * `Metric.counter('bus_events_published_total')` written out again — without the\n * description — addresses a *different* series that is always zero. That is a\n * silent way to write a dashboard query against nothing.\n */\n\n/** Events handed to the broker, by event name. */\nexport const busEventsPublished = Metric.counter('bus_events_published_total', {\n description: 'Events accepted by the broker, by event name.',\n});\n\n/**\n * Publishes the broker refused, by event name.\n *\n * Separate from the outbox's own failure count on purpose: this one says the\n * broker would not take it, the outbox's says how many attempts that entry has\n * spent. One is a rate, the other is a backlog.\n */\nexport const busPublishFailures = Metric.counter('bus_publish_failures_total', {\n description: 'Publishes the broker refused, by event name.',\n});\n\n/** Deliveries whose handler completed and were acked, by subscription. */\nexport const busEventsConsumed = Metric.counter('bus_events_consumed_total', {\n description: 'Deliveries acked after their handler completed.',\n});\n\n/**\n * Deliveries that were nacked, by subscription and failure class.\n *\n * ⚠️ **This is not a dead-letter count, and cannot be.** `x-delivery-limit` is\n * what moves a message to `<queue>.quarantine`, and the broker does that without\n * telling the adapter — there is no in-process call site for it. A transient\n * failure on its fifth delivery is counted here exactly like its first. The\n * honest quarantine count on the publisher side is the outbox's; on this side,\n * a rising `failure=\"transient\"` rate against a fixed `maxAttempts` is the\n * signal, and the quarantine queue's own depth is the confirmation.\n */\nexport const busEventsFailed = Metric.counter('bus_events_failed_total', {\n description:\n 'Deliveries nacked, by subscription and failure class. Not a ' +\n 'dead-letter count — the broker dead-letters without telling the adapter.',\n});\n\n/**\n * Identity of a subscription, as metric tags.\n *\n * `queue` is the durable work-queue name and so the only stable identity a\n * broadcast subscription does *not* have — its queue is anonymous and\n * regenerated per connection — which is why `slice` and `mode` are carried\n * beside it rather than derived from it.\n */\nexport interface SubscriptionTags {\n queue: string;\n slice: string;\n mode: SubscriptionMode;\n}\n\nconst withSubscription = (\n metric: Metric.Metric.Counter<number>,\n tags: SubscriptionTags,\n) =>\n metric.pipe(\n Metric.tagged('queue', tags.queue),\n Metric.tagged('slice', tags.slice),\n Metric.tagged('mode', tags.mode),\n );\n\n/** Record one event accepted by the broker. */\nexport const recordPublished = (eventName: string) =>\n Metric.increment(Metric.tagged(busEventsPublished, 'event', eventName));\n\n/** Record one publish the broker refused. */\nexport const recordPublishFailure = (eventName: string) =>\n Metric.increment(Metric.tagged(busPublishFailures, 'event', eventName));\n\n/** Record one delivery acked after its handler completed. */\nexport const recordConsumed = (tags: SubscriptionTags) =>\n Metric.increment(withSubscription(busEventsConsumed, tags));\n\n/**\n * Record one nacked delivery.\n *\n * `failure` is the class, never the event name: a poison message by definition\n * could not be parsed, so it has no `event.name` to attribute it to, and\n * dimensioning only the arm that has one would make the two counts\n * incomparable.\n */\nexport const recordFailed = (\n tags: SubscriptionTags,\n failure: 'transient' | 'poison',\n) =>\n Metric.increment(\n Metric.tagged(withSubscription(busEventsFailed, tags), 'failure', failure),\n );\n"],"names":["Metric","busEventsPublished","counter","description","busPublishFailures","busEventsConsumed","busEventsFailed","withSubscription","metric","tags","pipe","tagged","queue","slice","mode","recordPublished","eventName","increment","recordPublishFailure","recordConsumed","recordFailed","failure"],"mappings":"AACA,SAASA,MAAM,QAAQ,SAAS;AAEhC;;;;;;;;;CASC,GAED;;;;;;CAMC,GAED,gDAAgD,GAChD,OAAO,MAAMC,qBAAqBD,OAAOE,OAAO,CAAC,8BAA8B;IAC7EC,aAAa;AACf,GAAG;AAEH;;;;;;CAMC,GACD,OAAO,MAAMC,qBAAqBJ,OAAOE,OAAO,CAAC,8BAA8B;IAC7EC,aAAa;AACf,GAAG;AAEH,wEAAwE,GACxE,OAAO,MAAME,oBAAoBL,OAAOE,OAAO,CAAC,6BAA6B;IAC3EC,aAAa;AACf,GAAG;AAEH;;;;;;;;;;CAUC,GACD,OAAO,MAAMG,kBAAkBN,OAAOE,OAAO,CAAC,2BAA2B;IACvEC,aACE,iEACA;AACJ,GAAG;AAgBH,MAAMI,mBAAmB,CACvBC,QACAC,OAEAD,OAAOE,IAAI,CACTV,OAAOW,MAAM,CAAC,SAASF,KAAKG,KAAK,GACjCZ,OAAOW,MAAM,CAAC,SAASF,KAAKI,KAAK,GACjCb,OAAOW,MAAM,CAAC,QAAQF,KAAKK,IAAI;AAGnC,6CAA6C,GAC7C,OAAO,MAAMC,kBAAkB,CAACC,YAC9BhB,OAAOiB,SAAS,CAACjB,OAAOW,MAAM,CAACV,oBAAoB,SAASe,YAAY;AAE1E,2CAA2C,GAC3C,OAAO,MAAME,uBAAuB,CAACF,YACnChB,OAAOiB,SAAS,CAACjB,OAAOW,MAAM,CAACP,oBAAoB,SAASY,YAAY;AAE1E,2DAA2D,GAC3D,OAAO,MAAMG,iBAAiB,CAACV,OAC7BT,OAAOiB,SAAS,CAACV,iBAAiBF,mBAAmBI,OAAO;AAE9D;;;;;;;CAOC,GACD,OAAO,MAAMW,eAAe,CAC1BX,MACAY,UAEArB,OAAOiB,SAAS,CACdjB,OAAOW,MAAM,CAACJ,iBAAiBD,iBAAiBG,OAAO,WAAWY,UAClE"}
@@ -0,0 +1,104 @@
1
+ import { EntifixConnError } from '@entifix/core';
2
+ import * as amqp from 'amqplib';
3
+ import { Context, Layer } from 'effect';
4
+ /**
5
+ * The topic exchange every domain event is published to, keyed on the event's
6
+ * own name (`transaction.completed`, `catalog.published`).
7
+ *
8
+ * Topic rather than fanout, and a **new name** rather than a redeclaration: a
9
+ * broker will not change an existing exchange's type, so the old
10
+ * `entifix.transactions` fanout had to be left behind rather than migrated. It
11
+ * lingers, unbound and unused, in any dev broker that predates this — a
12
+ * `dev:reset` is what clears it.
13
+ *
14
+ * Fanout meant every subscriber received every publisher's traffic and threw
15
+ * away what it did not want, in its own handler. That put the routing rule in
16
+ * the one place no register can check it, and it does not survive a second
17
+ * publisher: ADR 0009's catalog projection would have been folding transaction
18
+ * events to find its own.
19
+ */
20
+ export declare const EVENTS_EXCHANGE = "entifix.events";
21
+ /**
22
+ * Where a message goes when its queue gives up on it.
23
+ *
24
+ * **`direct`**, not topic: it routes on the *queue's* name rather than on the
25
+ * event's, because a quarantine is per queue. One shared quarantine would mean
26
+ * a replay redelivers another subscriber's messages — a recovery step that
27
+ * causes a second incident (ADR 0030).
28
+ *
29
+ * Declared beside the event exchange rather than inside `subscribe`, so it is
30
+ * restored on every reconnect and a publisher-only service still agrees with
31
+ * the fleet about the topology it is part of.
32
+ */
33
+ export declare const EVENTS_DLX = "entifix.events.dlx";
34
+ /**
35
+ * Re-establishes a consumer against a freshly opened channel.
36
+ *
37
+ * It returns the consumer tags it created, which is what makes cancelling
38
+ * possible: `channel.consume` hands its tag back to the caller and nowhere
39
+ * else, so a setup that swallowed it would leave the connector able to open
40
+ * consumers and never to stop them. `void` is accepted for a setup that starts
41
+ * none (a declaration-only one).
42
+ */
43
+ export type AmqpConsumerSetup = (channel: amqp.Channel) => Promise<void | readonly string[]>;
44
+ /**
45
+ * A RabbitMQ connection that survives the broker going away.
46
+ *
47
+ * This is not a convenience. `amqplib` does not reconnect: a connection opened
48
+ * at boot and held in a `Layer` is dead permanently once the broker restarts,
49
+ * and both halves of the bus die with it — publishes fail forever, and the saga
50
+ * tracker's consumer stops folding events without ever erroring again. The
51
+ * transactional outbox makes an event durable, but durability is worthless if
52
+ * nothing can ever carry it, so the transport has to heal itself.
53
+ */
54
+ export interface AmqpConnector {
55
+ /**
56
+ * Run `use` against a live channel, opening or reopening one as needed. Retries
57
+ * once on a broken channel so a publish that races a broker restart succeeds
58
+ * rather than surfacing an error the caller cannot act on.
59
+ */
60
+ withChannel<A>(use: (channel: amqp.Channel) => Promise<A>): Promise<A>;
61
+ /**
62
+ * Register a consumer, and re-register it after every reconnect. The setup is
63
+ * re-run against the new channel, which is what makes a subscriber survive an
64
+ * outage — its exclusive queue died with the old connection.
65
+ */
66
+ addConsumer(setup: AmqpConsumerSetup): Promise<void>;
67
+ /**
68
+ * Stop delivering. Cancels every registered consumer and latches the
69
+ * connector so a reconnect does not quietly re-arm them.
70
+ *
71
+ * The first phase of a graceful shutdown (ADR 0030): the messages already
72
+ * being handled are still being handled — cancelling only stops *new*
73
+ * deliveries — so the caller waits for those separately before the connection
74
+ * closes. Without this a rollout kills handlers mid-flight and the broker
75
+ * redelivers whatever was unacked.
76
+ */
77
+ cancelConsumers(): Promise<void>;
78
+ }
79
+ declare const AmqpChannelTag_base: Context.TagClass<AmqpChannelTag, "AmqpChannelTag", AmqpConnector>;
80
+ /** DI tag carrying the self-healing connection (closed on release). */
81
+ export declare class AmqpChannelTag extends AmqpChannelTag_base {
82
+ }
83
+ export interface AmqpSettings {
84
+ readonly uri: string;
85
+ }
86
+ /**
87
+ * Builds the connector. Exported for the specs; services take it from the layer.
88
+ */
89
+ export declare const makeAmqpConnector: (uri: string) => {
90
+ connector: AmqpConnector;
91
+ close: () => Promise<void>;
92
+ };
93
+ /**
94
+ * A scoped {@link Layer} that provides a self-healing RabbitMQ connection and
95
+ * closes it on release. The exchange is asserted on every (re)connect, so
96
+ * publishers and subscribers can always assume it exists.
97
+ *
98
+ * Connecting is **eager**, so a broker that is unreachable at boot still fails
99
+ * the service's startup exactly as it did before — a service silently starting
100
+ * without its bus would be a worse trade than the reconnection buys.
101
+ */
102
+ export declare const AmqpLayer: (settings: AmqpSettings) => Layer.Layer<AmqpChannelTag, EntifixConnError>;
103
+ export {};
104
+ //# sourceMappingURL=amqp-connection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"amqp-connection.d.ts","sourceRoot":"","sources":["../../src/amqp-connection/amqp-connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,KAAK,IAAI,MAAM,SAAS,CAAC;AAChC,OAAO,EAAE,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEhD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAC;AAEhD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,uBAAuB,CAAC;AAE/C;;;;;;;;GAQG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAC9B,OAAO,EAAE,IAAI,CAAC,OAAO,KAClB,OAAO,CAAC,IAAI,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC;AAEvC;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,WAAW,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACvE;;;;OAIG;IACH,WAAW,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD;;;;;;;;;OASG;IACH,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAClC;;AAED,uEAAuE;AACvE,qBAAa,cAAe,SAAQ,mBAGjC;CAAG;AAEN,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAsBD;;GAEG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM;;;CAsH5C,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,GACpB,UAAU,YAAY,KACrB,KAAK,CAAC,KAAK,CAAC,cAAc,EAAE,gBAAgB,CAe5C,CAAC"}
@@ -0,0 +1,174 @@
1
+ import { EntifixConnError } from '@entifix/core';
2
+ import * as amqp from 'amqplib';
3
+ import { Context, Effect, Layer } from 'effect';
4
+ /**
5
+ * The topic exchange every domain event is published to, keyed on the event's
6
+ * own name (`transaction.completed`, `catalog.published`).
7
+ *
8
+ * Topic rather than fanout, and a **new name** rather than a redeclaration: a
9
+ * broker will not change an existing exchange's type, so the old
10
+ * `entifix.transactions` fanout had to be left behind rather than migrated. It
11
+ * lingers, unbound and unused, in any dev broker that predates this — a
12
+ * `dev:reset` is what clears it.
13
+ *
14
+ * Fanout meant every subscriber received every publisher's traffic and threw
15
+ * away what it did not want, in its own handler. That put the routing rule in
16
+ * the one place no register can check it, and it does not survive a second
17
+ * publisher: ADR 0009's catalog projection would have been folding transaction
18
+ * events to find its own.
19
+ */ export const EVENTS_EXCHANGE = 'entifix.events';
20
+ /**
21
+ * Where a message goes when its queue gives up on it.
22
+ *
23
+ * **`direct`**, not topic: it routes on the *queue's* name rather than on the
24
+ * event's, because a quarantine is per queue. One shared quarantine would mean
25
+ * a replay redelivers another subscriber's messages — a recovery step that
26
+ * causes a second incident (ADR 0030).
27
+ *
28
+ * Declared beside the event exchange rather than inside `subscribe`, so it is
29
+ * restored on every reconnect and a publisher-only service still agrees with
30
+ * the fleet about the topology it is part of.
31
+ */ export const EVENTS_DLX = 'entifix.events.dlx';
32
+ /** DI tag carrying the self-healing connection (closed on release). */ export class AmqpChannelTag extends Context.Tag('AmqpChannelTag')() {
33
+ }
34
+ /**
35
+ * Builds the connector. Exported for the specs; services take it from the layer.
36
+ */ export const makeAmqpConnector = (uri)=>{
37
+ let live;
38
+ let opening;
39
+ let closed = false;
40
+ let cancelled = false;
41
+ const consumers = [];
42
+ /**
43
+ * Bind every consumer that is not already bound to this channel.
44
+ *
45
+ * A no-op once the consumers have been cancelled: a shutdown that races a
46
+ * broker reconnect would otherwise re-arm the very consumers the drain just
47
+ * stopped, and start taking deliveries the process has no time left to
48
+ * finish.
49
+ */ const bindAll = async (channel)=>{
50
+ if (cancelled) {
51
+ return;
52
+ }
53
+ for (const consumer of consumers){
54
+ if (consumer.boundTo !== channel) {
55
+ var _ref;
56
+ consumer.tags = (_ref = await consumer.setup(channel)) != null ? _ref : [];
57
+ consumer.boundTo = channel;
58
+ }
59
+ }
60
+ };
61
+ const open = async ()=>{
62
+ const connection = await amqp.connect(uri);
63
+ const channel = await connection.createChannel();
64
+ await channel.assertExchange(EVENTS_EXCHANGE, 'topic', {
65
+ durable: true
66
+ });
67
+ await channel.assertExchange(EVENTS_DLX, 'direct', {
68
+ durable: true
69
+ });
70
+ // Forget this connection the moment it breaks, so the next call reopens
71
+ // instead of writing into a dead socket. The handlers are also what keep an
72
+ // 'error' event from reaching Node's default handler and killing the
73
+ // process — an unhandled 'error' on an amqplib connection is fatal.
74
+ const drop = ()=>{
75
+ if ((live == null ? void 0 : live.connection) === connection) {
76
+ live = undefined;
77
+ }
78
+ };
79
+ connection.on('close', drop);
80
+ connection.on('error', drop);
81
+ channel.on('close', drop);
82
+ channel.on('error', drop);
83
+ live = {
84
+ connection,
85
+ channel
86
+ };
87
+ // Re-arm every subscriber against the new channel. Nothing else does this:
88
+ // the queues they were consuming from went away with the old connection.
89
+ await bindAll(channel);
90
+ return live;
91
+ };
92
+ const ensure = async ()=>{
93
+ if (closed) {
94
+ throw new Error('AMQP connector is closed');
95
+ }
96
+ if (live) {
97
+ return live;
98
+ }
99
+ // One open at a time: a burst of publishes after an outage would otherwise
100
+ // race and leave orphaned connections behind, each with its own consumers.
101
+ opening != null ? opening : opening = open().finally(()=>{
102
+ opening = undefined;
103
+ });
104
+ return opening;
105
+ };
106
+ const connector = {
107
+ withChannel: async (use)=>{
108
+ try {
109
+ return await use((await ensure()).channel);
110
+ } catch (error) {
111
+ if (closed) {
112
+ throw error;
113
+ }
114
+ // The channel may have died between `ensure` and `use`. Drop it and try
115
+ // once more; a second failure is a real failure.
116
+ live = undefined;
117
+ return use((await ensure()).channel);
118
+ }
119
+ },
120
+ addConsumer: async (setup)=>{
121
+ consumers.push({
122
+ setup,
123
+ tags: []
124
+ });
125
+ // `ensure` may have bound it already, if it opened a connection for this
126
+ // very call — `bindAll` is what makes running it twice impossible.
127
+ await bindAll((await ensure()).channel);
128
+ },
129
+ cancelConsumers: async ()=>{
130
+ cancelled = true;
131
+ const channel = live == null ? void 0 : live.channel;
132
+ if (channel === undefined) {
133
+ return;
134
+ }
135
+ for (const consumer of consumers){
136
+ for (const tag of consumer.tags){
137
+ // A cancel that fails has already achieved what it was for — the
138
+ // channel is gone, so nothing is being delivered on it — and a
139
+ // shutdown must not stall on the way out.
140
+ await channel.cancel(tag).catch(()=>undefined);
141
+ }
142
+ consumer.tags = [];
143
+ }
144
+ }
145
+ };
146
+ const close = async ()=>{
147
+ closed = true;
148
+ const current = live;
149
+ live = undefined;
150
+ await (current == null ? void 0 : current.connection.close().catch(()=>undefined));
151
+ };
152
+ return {
153
+ connector,
154
+ close
155
+ };
156
+ };
157
+ /**
158
+ * A scoped {@link Layer} that provides a self-healing RabbitMQ connection and
159
+ * closes it on release. The exchange is asserted on every (re)connect, so
160
+ * publishers and subscribers can always assume it exists.
161
+ *
162
+ * Connecting is **eager**, so a broker that is unreachable at boot still fails
163
+ * the service's startup exactly as it did before — a service silently starting
164
+ * without its bus would be a worse trade than the reconnection buys.
165
+ */ export const AmqpLayer = (settings)=>Layer.scoped(AmqpChannelTag, Effect.acquireRelease(Effect.tryPromise({
166
+ try: async ()=>{
167
+ const built = makeAmqpConnector(settings.uri);
168
+ await built.connector.withChannel(async ()=>undefined);
169
+ return built;
170
+ },
171
+ catch: (error)=>new EntifixConnError('Failed to connect to RabbitMQ', error)
172
+ }), (built)=>Effect.promise(()=>built.close())).pipe(Effect.map((built)=>built.connector)));
173
+
174
+ //# sourceMappingURL=amqp-connection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/amqp-connection/amqp-connection.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport * as amqp from 'amqplib';\nimport { Context, Effect, Layer } from 'effect';\n\n/**\n * The topic exchange every domain event is published to, keyed on the event's\n * own name (`transaction.completed`, `catalog.published`).\n *\n * Topic rather than fanout, and a **new name** rather than a redeclaration: a\n * broker will not change an existing exchange's type, so the old\n * `entifix.transactions` fanout had to be left behind rather than migrated. It\n * lingers, unbound and unused, in any dev broker that predates this — a\n * `dev:reset` is what clears it.\n *\n * Fanout meant every subscriber received every publisher's traffic and threw\n * away what it did not want, in its own handler. That put the routing rule in\n * the one place no register can check it, and it does not survive a second\n * publisher: ADR 0009's catalog projection would have been folding transaction\n * events to find its own.\n */\nexport const EVENTS_EXCHANGE = 'entifix.events';\n\n/**\n * Where a message goes when its queue gives up on it.\n *\n * **`direct`**, not topic: it routes on the *queue's* name rather than on the\n * event's, because a quarantine is per queue. One shared quarantine would mean\n * a replay redelivers another subscriber's messages — a recovery step that\n * causes a second incident (ADR 0030).\n *\n * Declared beside the event exchange rather than inside `subscribe`, so it is\n * restored on every reconnect and a publisher-only service still agrees with\n * the fleet about the topology it is part of.\n */\nexport const EVENTS_DLX = 'entifix.events.dlx';\n\n/**\n * Re-establishes a consumer against a freshly opened channel.\n *\n * It returns the consumer tags it created, which is what makes cancelling\n * possible: `channel.consume` hands its tag back to the caller and nowhere\n * else, so a setup that swallowed it would leave the connector able to open\n * consumers and never to stop them. `void` is accepted for a setup that starts\n * none (a declaration-only one).\n */\nexport type AmqpConsumerSetup = (\n channel: amqp.Channel,\n) => Promise<void | readonly string[]>;\n\n/**\n * A RabbitMQ connection that survives the broker going away.\n *\n * This is not a convenience. `amqplib` does not reconnect: a connection opened\n * at boot and held in a `Layer` is dead permanently once the broker restarts,\n * and both halves of the bus die with it — publishes fail forever, and the saga\n * tracker's consumer stops folding events without ever erroring again. The\n * transactional outbox makes an event durable, but durability is worthless if\n * nothing can ever carry it, so the transport has to heal itself.\n */\nexport interface AmqpConnector {\n /**\n * Run `use` against a live channel, opening or reopening one as needed. Retries\n * once on a broken channel so a publish that races a broker restart succeeds\n * rather than surfacing an error the caller cannot act on.\n */\n withChannel<A>(use: (channel: amqp.Channel) => Promise<A>): Promise<A>;\n /**\n * Register a consumer, and re-register it after every reconnect. The setup is\n * re-run against the new channel, which is what makes a subscriber survive an\n * outage — its exclusive queue died with the old connection.\n */\n addConsumer(setup: AmqpConsumerSetup): Promise<void>;\n /**\n * Stop delivering. Cancels every registered consumer and latches the\n * connector so a reconnect does not quietly re-arm them.\n *\n * The first phase of a graceful shutdown (ADR 0030): the messages already\n * being handled are still being handled — cancelling only stops *new*\n * deliveries — so the caller waits for those separately before the connection\n * closes. Without this a rollout kills handlers mid-flight and the broker\n * redelivers whatever was unacked.\n */\n cancelConsumers(): Promise<void>;\n}\n\n/** DI tag carrying the self-healing connection (closed on release). */\nexport class AmqpChannelTag extends Context.Tag('AmqpChannelTag')<\n AmqpChannelTag,\n AmqpConnector\n>() {}\n\nexport interface AmqpSettings {\n readonly uri: string;\n}\n\ninterface Live {\n connection: amqp.ChannelModel;\n channel: amqp.Channel;\n}\n\n/**\n * A registered consumer and the channel it is currently bound to.\n *\n * Tracking the channel is what makes binding idempotent. Without it, a consumer\n * registered while the first connection is opening is run twice — once by the\n * open itself and once by `addConsumer` — and a doubly-bound subscriber folds\n * every event twice.\n */\ninterface Consumer {\n setup: AmqpConsumerSetup;\n boundTo?: amqp.Channel;\n /** The tags this consumer holds on {@link Consumer.boundTo}, for cancelling. */\n tags: readonly string[];\n}\n\n/**\n * Builds the connector. Exported for the specs; services take it from the layer.\n */\nexport const makeAmqpConnector = (uri: string) => {\n let live: Live | undefined;\n let opening: Promise<Live> | undefined;\n let closed = false;\n let cancelled = false;\n const consumers: Consumer[] = [];\n\n /**\n * Bind every consumer that is not already bound to this channel.\n *\n * A no-op once the consumers have been cancelled: a shutdown that races a\n * broker reconnect would otherwise re-arm the very consumers the drain just\n * stopped, and start taking deliveries the process has no time left to\n * finish.\n */\n const bindAll = async (channel: amqp.Channel) => {\n if (cancelled) {\n return;\n }\n for (const consumer of consumers) {\n if (consumer.boundTo !== channel) {\n consumer.tags = (await consumer.setup(channel)) ?? [];\n consumer.boundTo = channel;\n }\n }\n };\n\n const open = async (): Promise<Live> => {\n const connection = await amqp.connect(uri);\n const channel = await connection.createChannel();\n await channel.assertExchange(EVENTS_EXCHANGE, 'topic', {\n durable: true,\n });\n await channel.assertExchange(EVENTS_DLX, 'direct', { durable: true });\n\n // Forget this connection the moment it breaks, so the next call reopens\n // instead of writing into a dead socket. The handlers are also what keep an\n // 'error' event from reaching Node's default handler and killing the\n // process — an unhandled 'error' on an amqplib connection is fatal.\n const drop = () => {\n if (live?.connection === connection) {\n live = undefined;\n }\n };\n connection.on('close', drop);\n connection.on('error', drop);\n channel.on('close', drop);\n channel.on('error', drop);\n\n live = { connection, channel };\n\n // Re-arm every subscriber against the new channel. Nothing else does this:\n // the queues they were consuming from went away with the old connection.\n await bindAll(channel);\n return live;\n };\n\n const ensure = async (): Promise<Live> => {\n if (closed) {\n throw new Error('AMQP connector is closed');\n }\n if (live) {\n return live;\n }\n // One open at a time: a burst of publishes after an outage would otherwise\n // race and leave orphaned connections behind, each with its own consumers.\n opening ??= open().finally(() => {\n opening = undefined;\n });\n return opening;\n };\n\n const connector: AmqpConnector = {\n withChannel: async use => {\n try {\n return await use((await ensure()).channel);\n } catch (error) {\n if (closed) {\n throw error;\n }\n // The channel may have died between `ensure` and `use`. Drop it and try\n // once more; a second failure is a real failure.\n live = undefined;\n return use((await ensure()).channel);\n }\n },\n addConsumer: async setup => {\n consumers.push({ setup, tags: [] });\n // `ensure` may have bound it already, if it opened a connection for this\n // very call — `bindAll` is what makes running it twice impossible.\n await bindAll((await ensure()).channel);\n },\n cancelConsumers: async () => {\n cancelled = true;\n const channel = live?.channel;\n if (channel === undefined) {\n return;\n }\n for (const consumer of consumers) {\n for (const tag of consumer.tags) {\n // A cancel that fails has already achieved what it was for — the\n // channel is gone, so nothing is being delivered on it — and a\n // shutdown must not stall on the way out.\n await channel.cancel(tag).catch(() => undefined);\n }\n consumer.tags = [];\n }\n },\n };\n\n const close = async () => {\n closed = true;\n const current = live;\n live = undefined;\n await current?.connection.close().catch(() => undefined);\n };\n\n return { connector, close };\n};\n\n/**\n * A scoped {@link Layer} that provides a self-healing RabbitMQ connection and\n * closes it on release. The exchange is asserted on every (re)connect, so\n * publishers and subscribers can always assume it exists.\n *\n * Connecting is **eager**, so a broker that is unreachable at boot still fails\n * the service's startup exactly as it did before — a service silently starting\n * without its bus would be a worse trade than the reconnection buys.\n */\nexport const AmqpLayer = (\n settings: AmqpSettings,\n): Layer.Layer<AmqpChannelTag, EntifixConnError> =>\n Layer.scoped(\n AmqpChannelTag,\n Effect.acquireRelease(\n Effect.tryPromise({\n try: async () => {\n const built = makeAmqpConnector(settings.uri);\n await built.connector.withChannel(async () => undefined);\n return built;\n },\n catch: error =>\n new EntifixConnError('Failed to connect to RabbitMQ', error),\n }),\n built => Effect.promise(() => built.close()),\n ).pipe(Effect.map(built => built.connector)),\n );\n"],"names":["EntifixConnError","amqp","Context","Effect","Layer","EVENTS_EXCHANGE","EVENTS_DLX","AmqpChannelTag","Tag","makeAmqpConnector","uri","live","opening","closed","cancelled","consumers","bindAll","channel","consumer","boundTo","tags","setup","open","connection","connect","createChannel","assertExchange","durable","drop","undefined","on","ensure","Error","finally","connector","withChannel","use","error","addConsumer","push","cancelConsumers","tag","cancel","catch","close","current","AmqpLayer","settings","scoped","acquireRelease","tryPromise","try","built","promise","pipe","map"],"mappings":"AAAA,SAASA,gBAAgB,QAAQ,gBAAgB;AACjD,YAAYC,UAAU,UAAU;AAChC,SAASC,OAAO,EAAEC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAEhD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,iBAAiB;AAEhD;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,aAAa,qBAAqB;AAmD/C,qEAAqE,GACrE,OAAO,MAAMC,uBAAuBL,QAAQM,GAAG,CAAC;AAG3C;AA0BL;;CAEC,GACD,OAAO,MAAMC,oBAAoB,CAACC;IAChC,IAAIC;IACJ,IAAIC;IACJ,IAAIC,SAAS;IACb,IAAIC,YAAY;IAChB,MAAMC,YAAwB,EAAE;IAEhC;;;;;;;GAOC,GACD,MAAMC,UAAU,OAAOC;QACrB,IAAIH,WAAW;YACb;QACF;QACA,KAAK,MAAMI,YAAYH,UAAW;YAChC,IAAIG,SAASC,OAAO,KAAKF,SAAS;oBACf;gBAAjBC,SAASE,IAAI,IAAI,OAAA,MAAMF,SAASG,KAAK,CAACJ,oBAArB,OAAkC,EAAE;gBACrDC,SAASC,OAAO,GAAGF;YACrB;QACF;IACF;IAEA,MAAMK,OAAO;QACX,MAAMC,aAAa,MAAMtB,KAAKuB,OAAO,CAACd;QACtC,MAAMO,UAAU,MAAMM,WAAWE,aAAa;QAC9C,MAAMR,QAAQS,cAAc,CAACrB,iBAAiB,SAAS;YACrDsB,SAAS;QACX;QACA,MAAMV,QAAQS,cAAc,CAACpB,YAAY,UAAU;YAAEqB,SAAS;QAAK;QAEnE,wEAAwE;QACxE,4EAA4E;QAC5E,qEAAqE;QACrE,oEAAoE;QACpE,MAAMC,OAAO;YACX,IAAIjB,CAAAA,wBAAAA,KAAMY,UAAU,MAAKA,YAAY;gBACnCZ,OAAOkB;YACT;QACF;QACAN,WAAWO,EAAE,CAAC,SAASF;QACvBL,WAAWO,EAAE,CAAC,SAASF;QACvBX,QAAQa,EAAE,CAAC,SAASF;QACpBX,QAAQa,EAAE,CAAC,SAASF;QAEpBjB,OAAO;YAAEY;YAAYN;QAAQ;QAE7B,2EAA2E;QAC3E,yEAAyE;QACzE,MAAMD,QAAQC;QACd,OAAON;IACT;IAEA,MAAMoB,SAAS;QACb,IAAIlB,QAAQ;YACV,MAAM,IAAImB,MAAM;QAClB;QACA,IAAIrB,MAAM;YACR,OAAOA;QACT;QACA,2EAA2E;QAC3E,2EAA2E;QAC3EC,kBAAAA,UAAAA,UAAYU,OAAOW,OAAO,CAAC;YACzBrB,UAAUiB;QACZ;QACA,OAAOjB;IACT;IAEA,MAAMsB,YAA2B;QAC/BC,aAAa,OAAMC;YACjB,IAAI;gBACF,OAAO,MAAMA,IAAI,AAAC,CAAA,MAAML,QAAO,EAAGd,OAAO;YAC3C,EAAE,OAAOoB,OAAO;gBACd,IAAIxB,QAAQ;oBACV,MAAMwB;gBACR;gBACA,wEAAwE;gBACxE,iDAAiD;gBACjD1B,OAAOkB;gBACP,OAAOO,IAAI,AAAC,CAAA,MAAML,QAAO,EAAGd,OAAO;YACrC;QACF;QACAqB,aAAa,OAAMjB;YACjBN,UAAUwB,IAAI,CAAC;gBAAElB;gBAAOD,MAAM,EAAE;YAAC;YACjC,yEAAyE;YACzE,mEAAmE;YACnE,MAAMJ,QAAQ,AAAC,CAAA,MAAMe,QAAO,EAAGd,OAAO;QACxC;QACAuB,iBAAiB;YACf1B,YAAY;YACZ,MAAMG,UAAUN,wBAAAA,KAAMM,OAAO;YAC7B,IAAIA,YAAYY,WAAW;gBACzB;YACF;YACA,KAAK,MAAMX,YAAYH,UAAW;gBAChC,KAAK,MAAM0B,OAAOvB,SAASE,IAAI,CAAE;oBAC/B,iEAAiE;oBACjE,+DAA+D;oBAC/D,0CAA0C;oBAC1C,MAAMH,QAAQyB,MAAM,CAACD,KAAKE,KAAK,CAAC,IAAMd;gBACxC;gBACAX,SAASE,IAAI,GAAG,EAAE;YACpB;QACF;IACF;IAEA,MAAMwB,QAAQ;QACZ/B,SAAS;QACT,MAAMgC,UAAUlC;QAChBA,OAAOkB;QACP,OAAMgB,2BAAAA,QAAStB,UAAU,CAACqB,KAAK,GAAGD,KAAK,CAAC,IAAMd;IAChD;IAEA,OAAO;QAAEK;QAAWU;IAAM;AAC5B,EAAE;AAEF;;;;;;;;CAQC,GACD,OAAO,MAAME,YAAY,CACvBC,WAEA3C,MAAM4C,MAAM,CACVzC,gBACAJ,OAAO8C,cAAc,CACnB9C,OAAO+C,UAAU,CAAC;QAChBC,KAAK;YACH,MAAMC,QAAQ3C,kBAAkBsC,SAASrC,GAAG;YAC5C,MAAM0C,MAAMlB,SAAS,CAACC,WAAW,CAAC,UAAYN;YAC9C,OAAOuB;QACT;QACAT,OAAON,CAAAA,QACL,IAAIrC,iBAAiB,iCAAiCqC;IAC1D,IACAe,CAAAA,QAASjD,OAAOkD,OAAO,CAAC,IAAMD,MAAMR,KAAK,KACzCU,IAAI,CAACnD,OAAOoD,GAAG,CAACH,CAAAA,QAASA,MAAMlB,SAAS,IAC1C"}
@@ -0,0 +1,20 @@
1
+ import { HealthRegistryTag } from '@entifix/business';
2
+ import { Layer } from 'effect';
3
+ import { AmqpChannelTag } from './amqp-connection.js';
4
+ /** Probe name reported by `/api/health/ready` when RabbitMQ is unreachable. */
5
+ export declare const AMQP_PROBE_NAME = "amqp";
6
+ /**
7
+ * Registers a readiness probe for the AMQP connection: a passive assert of the
8
+ * shared events exchange. Passive means it never creates anything — it
9
+ * asks the broker whether the exchange is there, which fails fast on both a
10
+ * dead connection and a broker that lost the topology.
11
+ *
12
+ * It runs through the connector rather than against a held channel, and that
13
+ * matters more than it looks: amqplib **closes the channel** when a passive
14
+ * check fails. Against a single boot-time channel one failed probe therefore
15
+ * killed publishing and consuming permanently — the readiness check was itself
16
+ * a way to break the bus. Now the dead channel is simply dropped and the next
17
+ * call reopens one.
18
+ */
19
+ export declare const AmqpHealthProbeLayer: Layer.Layer<never, never, AmqpChannelTag | HealthRegistryTag>;
20
+ //# sourceMappingURL=amqp-health-probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"amqp-health-probe.d.ts","sourceRoot":"","sources":["../../src/amqp-connection/amqp-health-probe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEvC,OAAO,EAAE,cAAc,EAAmB,MAAM,sBAAsB,CAAC;AAEvE,+EAA+E;AAC/E,eAAO,MAAM,eAAe,SAAS,CAAC;AAEtC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,EAAE,KAAK,CAAC,KAAK,CAC5C,KAAK,EACL,KAAK,EACL,cAAc,GAAG,iBAAiB,CAuBnC,CAAC"}
@@ -0,0 +1,33 @@
1
+ import { HealthRegistryTag } from '@entifix/business';
2
+ import { Effect, Layer } from 'effect';
3
+ import { AmqpChannelTag, EVENTS_EXCHANGE } from './amqp-connection.js';
4
+ /** Probe name reported by `/api/health/ready` when RabbitMQ is unreachable. */ export const AMQP_PROBE_NAME = 'amqp';
5
+ /**
6
+ * Registers a readiness probe for the AMQP connection: a passive assert of the
7
+ * shared events exchange. Passive means it never creates anything — it
8
+ * asks the broker whether the exchange is there, which fails fast on both a
9
+ * dead connection and a broker that lost the topology.
10
+ *
11
+ * It runs through the connector rather than against a held channel, and that
12
+ * matters more than it looks: amqplib **closes the channel** when a passive
13
+ * check fails. Against a single boot-time channel one failed probe therefore
14
+ * killed publishing and consuming permanently — the readiness check was itself
15
+ * a way to break the bus. Now the dead channel is simply dropped and the next
16
+ * call reopens one.
17
+ */ export const AmqpHealthProbeLayer = Layer.effectDiscard(Effect.gen(function*() {
18
+ const connector = yield* AmqpChannelTag;
19
+ const registry = yield* HealthRegistryTag;
20
+ yield* registry.register({
21
+ name: AMQP_PROBE_NAME,
22
+ kind: 'broker',
23
+ // The exchange itself is the logical name, and it is the same for every
24
+ // service — so unlike a store this one needs no argument from the
25
+ // composition root (ADR 0031).
26
+ targets: [
27
+ EVENTS_EXCHANGE
28
+ ],
29
+ check: Effect.tryPromise(()=>connector.withChannel((channel)=>channel.checkExchange(EVENTS_EXCHANGE))).pipe(Effect.as(true), Effect.catchAll(()=>Effect.succeed(false)))
30
+ });
31
+ }));
32
+
33
+ //# sourceMappingURL=amqp-health-probe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/amqp-connection/amqp-health-probe.ts"],"sourcesContent":["import { HealthRegistryTag } from '@entifix/business';\nimport { Effect, Layer } from 'effect';\n\nimport { AmqpChannelTag, EVENTS_EXCHANGE } from './amqp-connection.js';\n\n/** Probe name reported by `/api/health/ready` when RabbitMQ is unreachable. */\nexport const AMQP_PROBE_NAME = 'amqp';\n\n/**\n * Registers a readiness probe for the AMQP connection: a passive assert of the\n * shared events exchange. Passive means it never creates anything — it\n * asks the broker whether the exchange is there, which fails fast on both a\n * dead connection and a broker that lost the topology.\n *\n * It runs through the connector rather than against a held channel, and that\n * matters more than it looks: amqplib **closes the channel** when a passive\n * check fails. Against a single boot-time channel one failed probe therefore\n * killed publishing and consuming permanently — the readiness check was itself\n * a way to break the bus. Now the dead channel is simply dropped and the next\n * call reopens one.\n */\nexport const AmqpHealthProbeLayer: Layer.Layer<\n never,\n never,\n AmqpChannelTag | HealthRegistryTag\n> = Layer.effectDiscard(\n Effect.gen(function* () {\n const connector = yield* AmqpChannelTag;\n const registry = yield* HealthRegistryTag;\n\n yield* registry.register({\n name: AMQP_PROBE_NAME,\n kind: 'broker',\n // The exchange itself is the logical name, and it is the same for every\n // service — so unlike a store this one needs no argument from the\n // composition root (ADR 0031).\n targets: [EVENTS_EXCHANGE],\n check: Effect.tryPromise(() =>\n connector.withChannel(channel =>\n channel.checkExchange(EVENTS_EXCHANGE),\n ),\n ).pipe(\n Effect.as(true),\n Effect.catchAll(() => Effect.succeed(false)),\n ),\n });\n }),\n);\n"],"names":["HealthRegistryTag","Effect","Layer","AmqpChannelTag","EVENTS_EXCHANGE","AMQP_PROBE_NAME","AmqpHealthProbeLayer","effectDiscard","gen","connector","registry","register","name","kind","targets","check","tryPromise","withChannel","channel","checkExchange","pipe","as","catchAll","succeed"],"mappings":"AAAA,SAASA,iBAAiB,QAAQ,oBAAoB;AACtD,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAEvC,SAASC,cAAc,EAAEC,eAAe,QAAQ,uBAAuB;AAEvE,6EAA6E,GAC7E,OAAO,MAAMC,kBAAkB,OAAO;AAEtC;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,uBAITJ,MAAMK,aAAa,CACrBN,OAAOO,GAAG,CAAC;IACT,MAAMC,YAAY,OAAON;IACzB,MAAMO,WAAW,OAAOV;IAExB,OAAOU,SAASC,QAAQ,CAAC;QACvBC,MAAMP;QACNQ,MAAM;QACN,wEAAwE;QACxE,kEAAkE;QAClE,+BAA+B;QAC/BC,SAAS;YAACV;SAAgB;QAC1BW,OAAOd,OAAOe,UAAU,CAAC,IACvBP,UAAUQ,WAAW,CAACC,CAAAA,UACpBA,QAAQC,aAAa,CAACf,mBAExBgB,IAAI,CACJnB,OAAOoB,EAAE,CAAC,OACVpB,OAAOqB,QAAQ,CAAC,IAAMrB,OAAOsB,OAAO,CAAC;IAEzC;AACF,IACA"}
@@ -0,0 +1,3 @@
1
+ export * from './amqp-connection/amqp-connection.js';
2
+ export * from './amqp-connection/amqp-health-probe.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,sCAAsC,CAAC;AACrD,cAAc,wCAAwC,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './amqp-connection/amqp-connection.js';
2
+ export * from './amqp-connection/amqp-health-probe.js';
3
+
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './amqp-connection/amqp-connection.js';\nexport * from './amqp-connection/amqp-health-probe.js';\n"],"names":[],"mappings":"AAAA,cAAc,uCAAuC;AACrD,cAAc,yCAAyC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The half of this package that needs `@entifix/transactions`: the `EventBus`
3
+ * implementation and the metrics it records.
4
+ *
5
+ * Behind a subpath so the connection and its health probe — which are just
6
+ * AMQP — can be taken without the event envelope and the subscription model.
7
+ * What keeps it optional is the `peerDependenciesMeta.optional` entry in the
8
+ * manifest.
9
+ */
10
+ export * from '../adapters/amqp-event-bus.js';
11
+ export * from '../adapters/bus-metrics.js';
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/transactions/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,cAAc,+BAA+B,CAAC;AAC9C,cAAc,4BAA4B,CAAC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The half of this package that needs `@entifix/transactions`: the `EventBus`
3
+ * implementation and the metrics it records.
4
+ *
5
+ * Behind a subpath so the connection and its health probe — which are just
6
+ * AMQP — can be taken without the event envelope and the subscription model.
7
+ * What keeps it optional is the `peerDependenciesMeta.optional` entry in the
8
+ * manifest.
9
+ */ export * from '../adapters/amqp-event-bus.js';
10
+ export * from '../adapters/bus-metrics.js';
11
+
12
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/transactions/index.ts"],"sourcesContent":["/**\n * The half of this package that needs `@entifix/transactions`: the `EventBus`\n * implementation and the metrics it records.\n *\n * Behind a subpath so the connection and its health probe — which are just\n * AMQP — can be taken without the event envelope and the subscription model.\n * What keeps it optional is the `peerDependenciesMeta.optional` entry in the\n * manifest.\n */\nexport * from '../adapters/amqp-event-bus.js';\nexport * from '../adapters/bus-metrics.js';\n"],"names":[],"mappings":"AAAA;;;;;;;;CAQC,GACD,cAAc,gCAAgC;AAC9C,cAAc,6BAA6B"}
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@entifix/amqp",
3
+ "version": "0.1.0-0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/r10c-technologies/entifix.git",
8
+ "directory": "packages/ts/amqp-client"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "main": "./dist/index.js",
15
+ "module": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ "./package.json": "./package.json",
19
+ ".": {
20
+ "@entifix/source": "./src/index.ts",
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./transactions": {
26
+ "@entifix/source": "./src/transactions/index.ts",
27
+ "types": "./dist/transactions/index.d.ts",
28
+ "import": "./dist/transactions/index.js",
29
+ "default": "./dist/transactions/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "!**/*.tsbuildinfo",
35
+ "LICENSE"
36
+ ],
37
+ "nx": {
38
+ "tags": [
39
+ "layer:entifix",
40
+ "scope:shared",
41
+ "entifix:client",
42
+ "runtime:datastore",
43
+ "tier:2"
44
+ ],
45
+ "sourceRoot": "packages/ts/amqp-client/src",
46
+ "targets": {
47
+ "build": {
48
+ "executor": "@nx/js:swc",
49
+ "outputs": [
50
+ "{options.outputPath}"
51
+ ],
52
+ "options": {
53
+ "outputPath": "packages/ts/amqp-client/dist",
54
+ "main": "packages/ts/amqp-client/src/index.ts",
55
+ "tsConfig": "packages/ts/amqp-client/tsconfig.lib.json",
56
+ "skipTypeCheck": true,
57
+ "stripLeadingPaths": true
58
+ }
59
+ }
60
+ }
61
+ },
62
+ "dependencies": {
63
+ "@swc/helpers": "0.5.23",
64
+ "amqplib": "2.0.1",
65
+ "@entifix/business": "0.1.0-0",
66
+ "@entifix/core": "0.1.0-0"
67
+ },
68
+ "devDependencies": {
69
+ "@types/amqplib": "0.10.5",
70
+ "effect": "3.22.1",
71
+ "@entifix/transactions": "0.1.0-0",
72
+ "@entifix/testing-unit": "0.1.0-0"
73
+ },
74
+ "peerDependencies": {
75
+ "effect": ">=3.22 <4",
76
+ "@entifix/transactions": "0.1.0-0"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "@entifix/transactions": {
80
+ "optional": true
81
+ }
82
+ }
83
+ }