@entifix/amqp 0.5.0 → 0.7.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.
@@ -10,7 +10,7 @@ import { AmqpChannelTag, type AmqpConnector } from '../amqp-connection/amqp-conn
10
10
  * it — readable in the broker's own UI, which is where someone looks when a
11
11
  * queue has depth. The slice is the *subscriber's*, never the publisher's:
12
12
  * `EventSourceTag` names the emitter, and one deployment hosts several slices
13
- * (ADR 0021 co-deploys `transaction` inside marketplace-admin-service), so
13
+ * (a host may co-deploy two slices in one process), so
14
14
  * deriving from it would file a consumer's queue under whoever happened to
15
15
  * publish from the same process.
16
16
  *
@@ -29,10 +29,10 @@ export declare const queueNameFor: (subscription: Subscription) => string;
29
29
  * **The event's own name is the routing key**, which is what lets a subscriber
30
30
  * bind the exact pattern it declared in `tools/slices/*.slice.ts`.
31
31
  *
32
- * The queue a subscriber binds is its own choice of shape (ADR 0030). A `work`
32
+ * The queue a subscriber binds is its own choice of shape. A `work`
33
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
34
+ * accumulating while the consumer is down; a `broadcast` one gets an exclusive
35
+ * queue, where every replica sees every event and nothing
36
36
  * survives a restart. Choosing broadcast for a workload that is work is how an
37
37
  * event published during a rollout is dropped by the broker while the outbox
38
38
  * has already recorded it sent.
@@ -1 +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;CAsLvD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,2FAuB7B,CAAC"}
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;AA+DjC;;;;;;;;;;;;;;;;;;;;;;;;;;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"}
@@ -20,7 +20,7 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
20
20
  * it — readable in the broker's own UI, which is where someone looks when a
21
21
  * queue has depth. The slice is the *subscriber's*, never the publisher's:
22
22
  * `EventSourceTag` names the emitter, and one deployment hosts several slices
23
- * (ADR 0021 co-deploys `transaction` inside marketplace-admin-service), so
23
+ * (a host may co-deploy two slices in one process), so
24
24
  * deriving from it would file a consumer's queue under whoever happened to
25
25
  * publish from the same process.
26
26
  *
@@ -34,8 +34,8 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
34
34
  * Declares the queue a subscription consumes and returns its name.
35
35
  *
36
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
37
+ * restart and accumulates while it is gone — the hop the outbox relay's
38
+ * durability chain was missing. Its retry ceiling is `x-delivery-limit`, a queue argument
39
39
  * rather than logic every consumer reimplements, so it also counts a
40
40
  * redelivery after a crash.
41
41
  *
@@ -50,8 +50,7 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
50
50
  */ const declareQueue = async (channel, subscription)=>{
51
51
  if (subscription.mode === 'broadcast') {
52
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.
53
+ // replicas differ from one another, as a per-connection push does.
55
54
  const { queue } = await channel.assertQueue('', {
56
55
  exclusive: true
57
56
  });
@@ -83,8 +82,7 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
83
82
  * Only a transient failure on a **work** queue: there the broker counts the
84
83
  * redelivery against `x-delivery-limit` and dead-letters at the ceiling, so a
85
84
  * 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.
85
+ * path, so requeueing there is an unbounded loop.
88
86
  */ const requeues = (failure, mode)=>failure === 'transient' && mode === 'work';
89
87
  /**
90
88
  * RabbitMQ-backed {@link EventBus}. Events go out as `event` envelopes on the
@@ -94,10 +92,10 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
94
92
  * **The event's own name is the routing key**, which is what lets a subscriber
95
93
  * bind the exact pattern it declared in `tools/slices/*.slice.ts`.
96
94
  *
97
- * The queue a subscriber binds is its own choice of shape (ADR 0030). A `work`
95
+ * The queue a subscriber binds is its own choice of shape. A `work`
98
96
  * 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
97
+ * accumulating while the consumer is down; a `broadcast` one gets an exclusive
98
+ * queue, where every replica sees every event and nothing
101
99
  * survives a restart. Choosing broadcast for a workload that is work is how an
102
100
  * event published during a rollout is dropped by the broker while the outbox
103
101
  * has already recorded it sent.
@@ -118,7 +116,8 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
118
116
  *
119
117
  * Nothing tracked them before, so a SIGTERM killed them mid-flight and the
120
118
  * broker redelivered whatever was unacked — safe only while every consumer
121
- * happens to be idempotent, which is the assumption #178 stops making.
119
+ * happens to be idempotent, which is the assumption the command inbox stops
120
+ * making.
122
121
  */ let inFlight = 0;
123
122
  const bus = {
124
123
  publish: (event)=>Effect.tryPromise({
@@ -187,8 +186,8 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
187
186
  // the database. Without this arm such a message is requeued
188
187
  // and retried to `x-delivery-limit` before it reaches the
189
188
  // quarantine it belonged in immediately — spending the budget
190
- // of every message behind it, which is exactly what ADR 0030
191
- // gives poison zero retries to avoid. Measured on
189
+ // of every message behind it, which is exactly what giving
190
+ // poison zero retries avoids. Measured on
192
191
  // `catalog.published`: a payload the contract rejects took
193
192
  // five deliveries to quarantine.
194
193
  Effect.flatMap((event)=>handler(event).pipe(Effect.mapError((error)=>error instanceof EntifixBuildError ? 'poison' : 'transient'))));
@@ -1 +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.tap(() =>\n wiring\n .recordPublish(event.name)\n .pipe(Effect.andThen(recordPublished(event.name))),\n ),\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 Effect.tapError(() => recordPublishFailure(event.name)),\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","tap","recordPublish","andThen","tapError","subscribe","handler","addConsumer","prefetch","runPromise","recordSubscription","tags","consumerTag","consume","message","settle","parse","content","toString","flatMap","parsed","mapError","matchEffect","onSuccess","sync","ack","onFailure","nack","finally","drainDeliveries","gen","sleep","AmqpEventBusLayer","effect","shutdown","register","phase","run","promise","cancelConsumers"],"mappings":"AACA,SAASA,mBAAmB,EAAEC,iBAAiB,QAAQ,oBAAoB;AAC3E,SACEC,iBAAiB,EACjBC,gBAAgB,EAChBC,iBAAiB,EACjBC,iBAAiB,QACZ,gBAAgB;AACvB,SAEEC,WAAW,QAGN,wBAAwB;AAE/B,SAASC,QAAQ,EAAEC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAEjD,SACEC,cAAc,EAEdC,UAAU,EACVC,eAAe,QACV,wCAAwC;AAC/C,SACEC,cAAc,EACdC,YAAY,EACZC,eAAe,EACfC,oBAAoB,QAEf,mBAAmB;AAE1B,+EAA+E,GAC/E,MAAMC,oBAAoB;AAE1B;;;;;;;CAOC,GACD,MAAMC,gBAAgBX,SAASY,MAAM,CAAC;AAiBtC;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,MAAMC,eAAe,CAACC,eAC3B,GAAGA,aAAaC,KAAK,CAAC,CAAC,EAAED,aAAaE,OAAO,CAC1CC,UAAU,CAAC,KAAK,UAChBA,UAAU,CAAC,KAAK,WAAW,CAAC;AAEjC;;;;;;;;;;;;;;;;;CAiBC,GACD,MAAMC,eAAe,OACnBC,SACAL;IAEA,IAAIA,aAAaM,IAAI,KAAK,aAAa;QACrC,yEAAyE;QACzE,wEAAwE;QACxE,uBAAuB;QACvB,MAAM,EAAEC,KAAK,EAAE,GAAG,MAAMF,QAAQG,WAAW,CAAC,IAAI;YAAEC,WAAW;QAAK;QAClE,OAAOF;IACT;IAEA,MAAMA,QAAQR,aAAaC;IAC3B,MAAMU,aAAa,GAAGH,QAAQX,mBAAmB;IAEjD,MAAMS,QAAQG,WAAW,CAACE,YAAY;QACpCC,SAAS;QACTC,WAAW;YAAE,gBAAgB;QAAS;IACxC;IACA,MAAMP,QAAQQ,SAAS,CAACH,YAAYpB,YAAYiB;IAChD,MAAMF,QAAQG,WAAW,CAACD,OAAO;QAC/BI,SAAS;QACTC,WAAW;YACT,gBAAgB;YAChB,oBAAoBZ,aAAac,WAAW;YAC5C,0BAA0BxB;YAC1B,6BAA6BiB;QAC/B;IACF;IAEA,OAAOA;AACT;AAEA;;;;;;;;CAQC,GACD,MAAMQ,WAAW,CAACC,SAA0BV,OAC1CU,YAAY,eAAeV,SAAS;AAEtC;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,MAAMW,mBAAmB,CAC9BC,WACAC;IAEA;;;;;;GAMC,GACD,IAAIC,WAAW;IAEf,MAAMC,MAAgB;QACpBC,SAASC,CAAAA,QACPpC,OAAOqC,UAAU,CAAC;gBAChBC,KAAK,IACHP,UAAUQ,WAAW,CAAC,OAAMrB;wBAC1BA,QAAQiB,OAAO,CACb/B,iBACAgC,MAAMI,IAAI,EACVC,OAAOC,IAAI,CAACC,KAAKC,SAAS,CAAChD,kBAAkBwC,UAC7C;4BAAES,YAAY;wBAAK;oBAEvB;gBACFC,OAAOC,CAAAA,QACL,IAAIpD,iBAAiB,uBAAuBoD,OAAO;wBACjDC,SAASZ,MAAMa,EAAE;wBACjBT,MAAMJ,MAAMI,IAAI;oBAClB;YAKJ,GAAGU,IAAI,CACLlD,OAAOmD,GAAG,CAAC,IACTnB,OACGoB,aAAa,CAAChB,MAAMI,IAAI,EACxBU,IAAI,CAAClD,OAAOqD,OAAO,CAAC9C,gBAAgB6B,MAAMI,IAAI,MAEnD,mEAAmE;YACnE,qEAAqE;YACrE,oEAAoE;YACpE,gDAAgD;YAChDxC,OAAOsD,QAAQ,CAAC,IAAM9C,qBAAqB4B,MAAMI,IAAI;QAGzDe,WAAW,CAAC1C,cAAc2C,UACxBxD,OAAOqC,UAAU,CAAC;gBAChBC,KAAK,IACHP,UAAU0B,WAAW,CAAC,OAAMvC;wBAC1B,mEAAmE;wBACnE,qEAAqE;wBACrE,oCAAoC;wBACpC,MAAMA,QAAQwC,QAAQ,CAAC;wBACvB,MAAMtC,QAAQ,MAAMH,aAAaC,SAASL;wBAC1C,MAAMK,QAAQQ,SAAS,CACrBN,OACAhB,iBACAS,aAAaE,OAAO;wBAEtB,qEAAqE;wBACrE,oEAAoE;wBACpE,6BAA6B;wBAC7B,MAAMf,OAAO2D,UAAU,CACrB3B,OAAO4B,kBAAkB,CAAC;4BACxB9C,OAAOD,aAAaC,KAAK;4BACzBC,SAASF,aAAaE,OAAO;4BAC7BI,MAAMN,aAAaM,IAAI;4BACvBC;wBACF;wBAEF,qEAAqE;wBACrE,oEAAoE;wBACpE,4DAA4D;wBAC5D,MAAMyC,OAAyB;4BAC7BzC;4BACAN,OAAOD,aAAaC,KAAK;4BACzBK,MAAMN,aAAaM,IAAI;wBACzB;wBACA,MAAM,EAAE2C,WAAW,EAAE,GAAG,MAAM5C,QAAQ6C,OAAO,CAC3C3C,OACA,CAAC4C;4BACC,IAAIA,YAAY,MAAM;gCACpB;4BACF;4BAEA,+DAA+D;4BAC/D,8DAA8D;4BAC9D,iEAAiE;4BACjE,oEAAoE;4BACpE,qCAAqC;4BACrC,MAAMC,SAASjE,OAAOsC,GAAG,CAAC;gCACxBA,KAAK,IAAMK,KAAKuB,KAAK,CAACF,QAAQG,OAAO,CAACC,QAAQ;gCAC9CtB,OAAO,IAAuB;4BAChC,GAAGI,IAAI,CACLlD,OAAOqE,OAAO,CAACC,CAAAA,SACbzE,kBAAkByE,QAAQpB,IAAI,CAC5BlD,OAAOuE,QAAQ,CAAC,IAAuB,aAG3C,kEAAkE;4BAClE,qCAAqC;4BACrC,EAAE;4BACF,kDAAkD;4BAClD,yDAAyD;4BACzD,sDAAsD;4BACtD,yDAAyD;4BACzD,6DAA6D;4BAC7D,6DAA6D;4BAC7D,2DAA2D;4BAC3D,8DAA8D;4BAC9D,4DAA4D;4BAC5D,0DAA0D;4BAC1D,8DAA8D;4BAC9D,6DAA6D;4BAC7D,kDAAkD;4BAClD,2DAA2D;4BAC3D,iCAAiC;4BACjCvE,OAAOqE,OAAO,CAACjC,CAAAA,QACboB,QAAQpB,OAAOc,IAAI,CACjBlD,OAAOuE,QAAQ,CAAC,CAACxB,QACfA,iBAAiBrD,oBACb,WACA;4BAMZ,mEAAmE;4BACnE,4DAA4D;4BAC5D,8CAA8C;4BAC9CuC,YAAY;4BACZ,KAAKjC,OAAO2D,UAAU,CACpBM,OAAOf,IAAI,CACT,8DAA8D;4BAC9D,+DAA+D;4BAC/D,6DAA6D;4BAC7D,yDAAyD;4BACzD,kCAAkC;4BAClClD,OAAOwE,WAAW,CAAC;gCACjBC,WAAW,IACTpE,eAAewD,MAAMX,IAAI,CACvBlD,OAAOqD,OAAO,CACZrD,OAAO0E,IAAI,CAAC,IAAMxD,QAAQyD,GAAG,CAACX;gCAGpCY,WAAW/C,CAAAA,UACTvB,aAAauD,MAAMhC,SAASqB,IAAI,CAC9BlD,OAAOqD,OAAO,CACZrD,OAAO0E,IAAI,CAAC,IACVxD,QAAQ2D,IAAI,CACVb,SACA,OACApC,SAASC,SAAShB,aAAaM,IAAI;4BAK/C,KAEF2D,OAAO,CAAC;gCACR7C,YAAY;4BACd;wBACF;wBAEF,OAAO;4BAAC6B;yBAAY;oBACtB;gBACFhB,OAAOC,CAAAA,QACL,IAAIpD,iBAAiB,yBAAyBoD,OAAO;wBACnDjC,OAAOD,aAAaC,KAAK;wBACzBC,SAASF,aAAaE,OAAO;oBAC/B;YACJ;IACJ;IAEA,OAAO;QACLmB;QACA6C,iBAAiB/E,OAAOgF,GAAG,CAAC;YAC1B,MAAO/C,WAAW,EAAG;gBACnB,OAAOjC,OAAOiF,KAAK,CAACvE;YACtB;QACF;IACF;AACF,EAAE;AAEF;;;;;;CAMC,GACD,OAAO,MAAMwE,oBAAoBjF,MAAMkF,MAAM,CAC3CrF,aACAE,OAAOgF,GAAG,CAAC;IACT,MAAMjD,YAAY,OAAO7B;IACzB,MAAM8B,SAAS,OAAOvC;IACtB,MAAM2F,WAAW,OAAO5F;IACxB,MAAM,EAAE0C,GAAG,EAAE6C,eAAe,EAAE,GAAGjD,iBAAiBC,WAAWC;IAE7D,qEAAqE;IACrE,qEAAqE;IACrE,4EAA4E;IAC5E,2EAA2E;IAC3E,mDAAmD;IACnD,OAAOoD,SAASC,QAAQ,CAAC;QACvB7C,MAAM;QACN8C,OAAO;QACPC,KAAKvF,OAAOwF,OAAO,CAAC,IAAMzD,UAAU0D,eAAe,IAAIvC,IAAI,CACzDlD,OAAOqD,OAAO,CAAC0B;IAEnB;IAEA,OAAO7C;AACT,IACA"}
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 once did,\n * 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 * (a host may co-deploy two slices in one process), 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 the outbox relay's\n * durability 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, as a per-connection push does.\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.\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. 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 an exclusive\n * queue, 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 the command inbox stops\n * 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.\n }).pipe(\n Effect.tap(() =>\n wiring\n .recordPublish(event.name)\n .pipe(Effect.andThen(recordPublished(event.name))),\n ),\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 Effect.tapError(() => recordPublishFailure(event.name)),\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 giving\n // poison zero retries avoids. 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","tap","recordPublish","andThen","tapError","subscribe","handler","addConsumer","prefetch","runPromise","recordSubscription","tags","consumerTag","consume","message","settle","parse","content","toString","flatMap","parsed","mapError","matchEffect","onSuccess","sync","ack","onFailure","nack","finally","drainDeliveries","gen","sleep","AmqpEventBusLayer","effect","shutdown","register","phase","run","promise","cancelConsumers"],"mappings":"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"}
@@ -1,7 +1,8 @@
1
1
  import type { SubscriptionMode } from '@entifix/transactions';
2
2
  import { Metric } from 'effect';
3
3
  /**
4
- * The bus half of ADR 0001's first metric set.
4
+ * The bus half of the framework's first metric set; the outbox half is in
5
+ * `@entifix/mongo`.
5
6
  *
6
7
  * Counters rather than logs because a quarantined message that is only ever a
7
8
  * log line is indistinguishable from a dropped one when the question is *how
@@ -1 +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,wBAE7B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,wBAE7B,CAAC;AAEH,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,wBAE5B,CAAC;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,wBAI1B,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,uDAI9C,CAAC;AAEJ,6CAA6C;AAC7C,eAAO,MAAM,oBAAoB,GAAI,WAAW,MAAM,uDAInD,CAAC;AAEJ,6DAA6D;AAC7D,eAAO,MAAM,cAAc,GAAI,MAAM,gBAAgB,uDACQ,CAAC;AAE9D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,GACvB,MAAM,gBAAgB,EACtB,SAAS,WAAW,GAAG,QAAQ,uDAK9B,CAAC"}
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;;;;;;;;;;GAUG;AAEH;;;;;;GAMG;AAEH,kDAAkD;AAClD,eAAO,MAAM,kBAAkB,wBAE7B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,wBAE7B,CAAC;AAEH,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,wBAE5B,CAAC;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,wBAI1B,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,uDAI9C,CAAC;AAEJ,6CAA6C;AAC7C,eAAO,MAAM,oBAAoB,GAAI,WAAW,MAAM,uDAInD,CAAC;AAEJ,6DAA6D;AAC7D,eAAO,MAAM,cAAc,GAAI,MAAM,gBAAgB,uDACQ,CAAC;AAE9D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,GACvB,MAAM,gBAAgB,EACtB,SAAS,WAAW,GAAG,QAAQ,uDAK9B,CAAC"}
@@ -1,6 +1,7 @@
1
1
  import { Metric } from 'effect';
2
2
  /**
3
- * The bus half of ADR 0001's first metric set.
3
+ * The bus half of the framework's first metric set; the outbox half is in
4
+ * `@entifix/mongo`.
4
5
  *
5
6
  * Counters rather than logs because a quarantined message that is only ever a
6
7
  * log line is indistinguishable from a dropped one when the question is *how
@@ -1 +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.Counter<number>,\n tags: SubscriptionTags,\n) =>\n metric.pipe(\n Metric.withAttributes({ queue: tags.queue }),\n Metric.withAttributes({ slice: tags.slice }),\n Metric.withAttributes({ mode: tags.mode }),\n );\n\n/** Record one event accepted by the broker. */\nexport const recordPublished = (eventName: string) =>\n Metric.update(\n Metric.withAttributes(busEventsPublished, { event: eventName }),\n 1,\n );\n\n/** Record one publish the broker refused. */\nexport const recordPublishFailure = (eventName: string) =>\n Metric.update(\n Metric.withAttributes(busPublishFailures, { event: eventName }),\n 1,\n );\n\n/** Record one delivery acked after its handler completed. */\nexport const recordConsumed = (tags: SubscriptionTags) =>\n Metric.update(withSubscription(busEventsConsumed, tags), 1);\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.update(\n Metric.withAttributes(withSubscription(busEventsFailed, tags), { failure }),\n 1,\n );\n"],"names":["Metric","busEventsPublished","counter","description","busPublishFailures","busEventsConsumed","busEventsFailed","withSubscription","metric","tags","pipe","withAttributes","queue","slice","mode","recordPublished","eventName","update","event","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,cAAc,CAAC;QAAEC,OAAOH,KAAKG,KAAK;IAAC,IAC1CZ,OAAOW,cAAc,CAAC;QAAEE,OAAOJ,KAAKI,KAAK;IAAC,IAC1Cb,OAAOW,cAAc,CAAC;QAAEG,MAAML,KAAKK,IAAI;IAAC;AAG5C,6CAA6C,GAC7C,OAAO,MAAMC,kBAAkB,CAACC,YAC9BhB,OAAOiB,MAAM,CACXjB,OAAOW,cAAc,CAACV,oBAAoB;QAAEiB,OAAOF;IAAU,IAC7D,GACA;AAEJ,2CAA2C,GAC3C,OAAO,MAAMG,uBAAuB,CAACH,YACnChB,OAAOiB,MAAM,CACXjB,OAAOW,cAAc,CAACP,oBAAoB;QAAEc,OAAOF;IAAU,IAC7D,GACA;AAEJ,2DAA2D,GAC3D,OAAO,MAAMI,iBAAiB,CAACX,OAC7BT,OAAOiB,MAAM,CAACV,iBAAiBF,mBAAmBI,OAAO,GAAG;AAE9D;;;;;;;CAOC,GACD,OAAO,MAAMY,eAAe,CAC1BZ,MACAa,UAEAtB,OAAOiB,MAAM,CACXjB,OAAOW,cAAc,CAACJ,iBAAiBD,iBAAiBG,OAAO;QAAEa;IAAQ,IACzE,GACA"}
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 the framework's first metric set; the outbox half is in\n * `@entifix/mongo`.\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.Counter<number>,\n tags: SubscriptionTags,\n) =>\n metric.pipe(\n Metric.withAttributes({ queue: tags.queue }),\n Metric.withAttributes({ slice: tags.slice }),\n Metric.withAttributes({ mode: tags.mode }),\n );\n\n/** Record one event accepted by the broker. */\nexport const recordPublished = (eventName: string) =>\n Metric.update(\n Metric.withAttributes(busEventsPublished, { event: eventName }),\n 1,\n );\n\n/** Record one publish the broker refused. */\nexport const recordPublishFailure = (eventName: string) =>\n Metric.update(\n Metric.withAttributes(busPublishFailures, { event: eventName }),\n 1,\n );\n\n/** Record one delivery acked after its handler completed. */\nexport const recordConsumed = (tags: SubscriptionTags) =>\n Metric.update(withSubscription(busEventsConsumed, tags), 1);\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.update(\n Metric.withAttributes(withSubscription(busEventsFailed, tags), { failure }),\n 1,\n );\n"],"names":["Metric","busEventsPublished","counter","description","busPublishFailures","busEventsConsumed","busEventsFailed","withSubscription","metric","tags","pipe","withAttributes","queue","slice","mode","recordPublished","eventName","update","event","recordPublishFailure","recordConsumed","recordFailed","failure"],"mappings":"AACA,SAASA,MAAM,QAAQ,SAAS;AAEhC;;;;;;;;;;CAUC,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,cAAc,CAAC;QAAEC,OAAOH,KAAKG,KAAK;IAAC,IAC1CZ,OAAOW,cAAc,CAAC;QAAEE,OAAOJ,KAAKI,KAAK;IAAC,IAC1Cb,OAAOW,cAAc,CAAC;QAAEG,MAAML,KAAKK,IAAI;IAAC;AAG5C,6CAA6C,GAC7C,OAAO,MAAMC,kBAAkB,CAACC,YAC9BhB,OAAOiB,MAAM,CACXjB,OAAOW,cAAc,CAACV,oBAAoB;QAAEiB,OAAOF;IAAU,IAC7D,GACA;AAEJ,2CAA2C,GAC3C,OAAO,MAAMG,uBAAuB,CAACH,YACnChB,OAAOiB,MAAM,CACXjB,OAAOW,cAAc,CAACP,oBAAoB;QAAEc,OAAOF;IAAU,IAC7D,GACA;AAEJ,2DAA2D,GAC3D,OAAO,MAAMI,iBAAiB,CAACX,OAC7BT,OAAOiB,MAAM,CAACV,iBAAiBF,mBAAmBI,OAAO,GAAG;AAE9D;;;;;;;CAOC,GACD,OAAO,MAAMY,eAAe,CAC1BZ,MACAa,UAEAtB,OAAOiB,MAAM,CACXjB,OAAOW,cAAc,CAACJ,iBAAiBD,iBAAiBG,OAAO;QAAEa;IAAQ,IACzE,GACA"}
@@ -14,8 +14,8 @@ import { Context, Layer } from 'effect';
14
14
  * Fanout meant every subscriber received every publisher's traffic and threw
15
15
  * away what it did not want, in its own handler. That put the routing rule in
16
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.
17
+ * publisher: a projection consuming its own event would have been folding
18
+ * transaction events to find it.
19
19
  */
20
20
  export declare const EVENTS_EXCHANGE = "entifix.events";
21
21
  /**
@@ -24,7 +24,7 @@ export declare const EVENTS_EXCHANGE = "entifix.events";
24
24
  * **`direct`**, not topic: it routes on the *queue's* name rather than on the
25
25
  * event's, because a quarantine is per queue. One shared quarantine would mean
26
26
  * a replay redelivers another subscriber's messages — a recovery step that
27
- * causes a second incident (ADR 0030).
27
+ * causes a second incident.
28
28
  *
29
29
  * Declared beside the event exchange rather than inside `subscribe`, so it is
30
30
  * restored on every reconnect and a publisher-only service still agrees with
@@ -68,7 +68,7 @@ export interface AmqpConnector {
68
68
  * Stop delivering. Cancels every registered consumer and latches the
69
69
  * connector so a reconnect does not quietly re-arm them.
70
70
  *
71
- * The first phase of a graceful shutdown (ADR 0030): the messages already
71
+ * The first phase of a graceful shutdown: the messages already
72
72
  * being handled are still being handled — cancelling only stops *new*
73
73
  * deliveries — so the caller waits for those separately before the connection
74
74
  * closes. Without this a rollout kills handlers mid-flight and the broker
@@ -14,8 +14,8 @@ import { Context, Effect, Layer } from 'effect';
14
14
  * Fanout meant every subscriber received every publisher's traffic and threw
15
15
  * away what it did not want, in its own handler. That put the routing rule in
16
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.
17
+ * publisher: a projection consuming its own event would have been folding
18
+ * transaction events to find it.
19
19
  */ export const EVENTS_EXCHANGE = 'entifix.events';
20
20
  /**
21
21
  * Where a message goes when its queue gives up on it.
@@ -23,7 +23,7 @@ import { Context, Effect, Layer } from 'effect';
23
23
  * **`direct`**, not topic: it routes on the *queue's* name rather than on the
24
24
  * event's, because a quarantine is per queue. One shared quarantine would mean
25
25
  * a replay redelivers another subscriber's messages — a recovery step that
26
- * causes a second incident (ADR 0030).
26
+ * causes a second incident.
27
27
  *
28
28
  * Declared beside the event exchange rather than inside `subscribe`, so it is
29
29
  * restored on every reconnect and a publisher-only service still agrees with
@@ -1 +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.Service<\n AmqpChannelTag,\n AmqpConnector\n>()('AmqpChannelTag') {}\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.effect(\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","Service","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","effect","acquireRelease","tryPromise","try","built","promise","pipe","map"],"mappings":"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"}
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: a projection consuming its own event would have been folding\n * transaction events to find it.\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.\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: 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.Service<\n AmqpChannelTag,\n AmqpConnector\n>()('AmqpChannelTag') {}\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.effect(\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","Service","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","effect","acquireRelease","tryPromise","try","built","promise","pipe","map"],"mappings":"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"}
@@ -22,7 +22,7 @@ import { AmqpChannelTag, EVENTS_EXCHANGE } from './amqp-connection.js';
22
22
  kind: 'broker',
23
23
  // The exchange itself is the logical name, and it is the same for every
24
24
  // service — so unlike a store this one needs no argument from the
25
- // composition root (ADR 0031).
25
+ // composition root.
26
26
  targets: [
27
27
  EVENTS_EXCHANGE
28
28
  ],
@@ -1 +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.catch(() => 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","catch","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,KAAK,CAAC,IAAMrB,OAAOsB,OAAO,CAAC;IAEtC;AACF,IACA"}
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.\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.catch(() => 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","catch","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,oBAAoB;QACpBC,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,KAAK,CAAC,IAAMrB,OAAOsB,OAAO,CAAC;IAEtC;AACF,IACA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entifix/amqp",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "RabbitMQ connection and health probe, and an optional event bus for @entifix/transactions with retry and quarantine.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,18 +57,18 @@
57
57
  "dependencies": {
58
58
  "@swc/helpers": "0.5.23",
59
59
  "amqplib": "2.2.0",
60
- "@entifix/core": "0.5.0",
61
- "@entifix/business": "0.5.0"
60
+ "@entifix/core": "0.7.0",
61
+ "@entifix/business": "0.7.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@types/amqplib": "0.10.8",
65
65
  "effect": "4.0.5",
66
- "@entifix/testing-unit": "0.5.0",
67
- "@entifix/transactions": "0.5.0"
66
+ "@entifix/testing-unit": "0.7.0",
67
+ "@entifix/transactions": "0.7.0"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "effect": "^4.0.5",
71
- "@entifix/transactions": "0.5.0"
71
+ "@entifix/transactions": "0.7.0"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@entifix/transactions": {