@entifix/amqp 0.13.0 → 0.15.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.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"amqp-connection.d.ts","sourceRoot":"","sources":["../../src/amqp-connection/amqp-connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,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,EACrB,OAAO,EAAE,mBAAmB,KACzB,OAAO,CAAC,IAAI,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC;AAEvC;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;IACxD,QAAQ,CAAC,SAAS,EAAE,MAAM,OAAO,CAAC;CACnC;AAED;;;;;;;;;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,mBAGf;CAAG;AAExB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;
|
|
1
|
+
{"version":3,"file":"amqp-connection.d.ts","sourceRoot":"","sources":["../../src/amqp-connection/amqp-connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,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,EACrB,OAAO,EAAE,mBAAmB,KACzB,OAAO,CAAC,IAAI,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC;AAEvC;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;IACxD,QAAQ,CAAC,SAAS,EAAE,MAAM,OAAO,CAAC;CACnC;AAED;;;;;;;;;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,mBAGf;CAAG;AAExB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAwBD;;GAEG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM;;;CAsI5C,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,GACpB,UAAU,YAAY,KACrB,KAAK,CAAC,KAAK,CAAC,cAAc,EAAE,WAAW,CAcvC,CAAC"}
|
|
@@ -51,15 +51,25 @@ import { Context, Effect, Layer } from 'effect';
|
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
53
|
for (const consumer of consumers){
|
|
54
|
-
if (consumer.boundTo !== channel) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
if (consumer.boundTo !== channel && consumer.binding !== channel) {
|
|
55
|
+
// Claimed before the setup is awaited, so a second pass running
|
|
56
|
+
// concurrently — two `addConsumer` calls in flight at once — sees the
|
|
57
|
+
// consumer taken and skips it rather than binding it twice (#161).
|
|
58
|
+
consumer.binding = channel;
|
|
59
|
+
try {
|
|
60
|
+
var _ref;
|
|
61
|
+
consumer.tags = (_ref = await consumer.setup(channel, {
|
|
62
|
+
replaceTags: (tags)=>{
|
|
63
|
+
consumer.tags = tags;
|
|
64
|
+
},
|
|
65
|
+
cancelled: ()=>cancelled
|
|
66
|
+
})) != null ? _ref : [];
|
|
67
|
+
consumer.boundTo = channel;
|
|
68
|
+
} finally{
|
|
69
|
+
// Released either way: a setup that threw is free to be tried again
|
|
70
|
+
// on the next pass, and one that bound is `boundTo` from here on.
|
|
71
|
+
consumer.binding = undefined;
|
|
72
|
+
}
|
|
63
73
|
}
|
|
64
74
|
}
|
|
65
75
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/amqp-connection/amqp-connection.ts"],"sourcesContent":["import { Unavailable } 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 control: AmqpConsumerControl,\n) => Promise<void | readonly string[]>;\n\n/**\n * What a consumer may tell the connector after its setup ran: that it now\n * holds other tags — it paused, or resumed under a new one — so a shutdown\n * cancels what it actually holds; and whether intake has been stopped, so a\n * consumer does not resume into a shutdown.\n */\nexport interface AmqpConsumerControl {\n readonly replaceTags: (tags: readonly string[]) => void;\n readonly cancelled: () => boolean;\n}\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 =\n (await consumer.setup(channel, {\n replaceTags: tags => {\n consumer.tags = tags;\n },\n cancelled: () => cancelled,\n })) ?? [];\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, Unavailable> =>\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 => new Unavailable({ dependency: 'amqp' }),\n }),\n built => Effect.promise(() => built.close()),\n ).pipe(Effect.map(built => built.connector)),\n );\n"],"names":["Unavailable","amqp","Context","Effect","Layer","EVENTS_EXCHANGE","EVENTS_DLX","AmqpChannelTag","Service","makeAmqpConnector","uri","live","opening","closed","cancelled","consumers","bindAll","channel","consumer","boundTo","tags","setup","replaceTags","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","dependency","promise","pipe","map"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/amqp-connection/amqp-connection.ts"],"sourcesContent":["import { Unavailable } 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 control: AmqpConsumerControl,\n) => Promise<void | readonly string[]>;\n\n/**\n * What a consumer may tell the connector after its setup ran: that it now\n * holds other tags — it paused, or resumed under a new one — so a shutdown\n * cancels what it actually holds; and whether intake has been stopped, so a\n * consumer does not resume into a shutdown.\n */\nexport interface AmqpConsumerControl {\n readonly replaceTags: (tags: readonly string[]) => void;\n readonly cancelled: () => boolean;\n}\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 channel a pass is binding this consumer to right now, if any. */\n binding?: 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 && consumer.binding !== channel) {\n // Claimed before the setup is awaited, so a second pass running\n // concurrently — two `addConsumer` calls in flight at once — sees the\n // consumer taken and skips it rather than binding it twice (#161).\n consumer.binding = channel;\n try {\n consumer.tags =\n (await consumer.setup(channel, {\n replaceTags: tags => {\n consumer.tags = tags;\n },\n cancelled: () => cancelled,\n })) ?? [];\n consumer.boundTo = channel;\n } finally {\n // Released either way: a setup that threw is free to be tried again\n // on the next pass, and one that bound is `boundTo` from here on.\n consumer.binding = undefined;\n }\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, Unavailable> =>\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 => new Unavailable({ dependency: 'amqp' }),\n }),\n built => Effect.promise(() => built.close()),\n ).pipe(Effect.map(built => built.connector)),\n );\n"],"names":["Unavailable","amqp","Context","Effect","Layer","EVENTS_EXCHANGE","EVENTS_DLX","AmqpChannelTag","Service","makeAmqpConnector","uri","live","opening","closed","cancelled","consumers","bindAll","channel","consumer","boundTo","binding","tags","setup","replaceTags","undefined","open","connection","connect","createChannel","assertExchange","durable","drop","on","ensure","Error","finally","connector","withChannel","use","error","addConsumer","push","cancelConsumers","tag","cancel","catch","close","current","AmqpLayer","settings","effect","acquireRelease","tryPromise","try","built","dependency","promise","pipe","map"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/amqp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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.1",
|
|
60
|
-
"@entifix/business": "0.
|
|
61
|
-
"@entifix/core": "0.
|
|
60
|
+
"@entifix/business": "0.15.0",
|
|
61
|
+
"@entifix/core": "0.15.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@types/amqplib": "0.10.8",
|
|
65
|
-
"effect": "4.0.
|
|
66
|
-
"@entifix/testing-unit": "0.
|
|
67
|
-
"@entifix/transactions": "0.
|
|
65
|
+
"effect": "4.0.7",
|
|
66
|
+
"@entifix/testing-unit": "0.15.0",
|
|
67
|
+
"@entifix/transactions": "0.15.0"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"effect": "^4.0.
|
|
71
|
-
"@entifix/transactions": "0.
|
|
70
|
+
"effect": "^4.0.7",
|
|
71
|
+
"@entifix/transactions": "0.15.0"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@entifix/transactions": {
|