@btravstack/amqp-worker 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  ```sh
15
15
  pnpm add @btravstack/amqp-worker @btravstack/core @btravstack/config @btravstack/di unthrown \
16
- @amqp-contract/worker @opentelemetry/api
16
+ @amqp-contract/worker@^3.0.0-beta.7 @opentelemetry/api
17
17
  ```
18
18
 
19
19
  All six are peer dependencies — install them (`@opentelemetry/api` because
@@ -23,20 +23,79 @@ All six are peer dependencies — install them (`@opentelemetry/api` because
23
23
 
24
24
  <!-- doctest: prelude
25
25
  import { NonRetryableError, RetryableError } from "@amqp-contract/worker";
26
- import { orderContract } from "@btravstack/example-order-amqp-contract";
27
- import { PlaceOrder } from "@btravstack/example-order-application";
28
- import { TenantId } from "@btravstack/example-order-domain";
29
- import { Module } from "@btravstack/di";
30
- import { OrderApplicationModule, OrderRepository, Outbox } from "@btravstack/example-order-application";
31
- import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure";
26
+ import {
27
+ defineContract,
28
+ defineEventConsumer,
29
+ defineEventPublisher,
30
+ defineExchange,
31
+ defineMessage,
32
+ defineQueue,
33
+ } from "@amqp-contract/contract";
34
+ import { Module, Port, type AnyModule } from "@btravstack/di";
35
+ import { TaggedError, type AsyncResult } from "unthrown";
32
36
  import { observability } from "@btravstack/observability";
33
37
  import { otel } from "@btravstack/observability/otel";
38
+ import { z } from "zod";
39
+
40
+ // The contract this README's worker serves: one event, broadcast to two
41
+ // subscribers' queues. It is the application's own artifact — a client takes
42
+ // it without this package.
43
+ const orders = defineExchange("orders");
44
+ const parked = defineExchange("orders-dlx", { type: "direct" });
45
+ const orderChanged = defineMessage(
46
+ z.object({
47
+ tenantId: z.uuidv7(),
48
+ kind: z.literal("order"),
49
+ id: z.uuidv7(),
50
+ occurredAt: z.string(),
51
+ payload: z.object({ quantity: z.number() }).nullable(),
52
+ }),
53
+ );
54
+ const orderChangedEvent = defineEventPublisher(orders, orderChanged, {
55
+ routingKey: "order.changed",
56
+ });
57
+ const queueOptions = {
58
+ deadLetter: { exchange: parked, externalConsumers: true },
59
+ retry: { mode: "ttl-backoff", maxRetries: 3, initialDelayMs: 10 },
60
+ } as const;
61
+ const orderContract = defineContract({
62
+ publishers: { orderChanged: orderChangedEvent },
63
+ consumers: {
64
+ orderNotifications: defineEventConsumer(
65
+ orderChangedEvent,
66
+ defineQueue("order-notifications", queueOptions),
67
+ ),
68
+ orderAudit: defineEventConsumer(
69
+ orderChangedEvent,
70
+ defineQueue("order-audit", queueOptions),
71
+ ),
72
+ },
73
+ });
74
+
75
+ // The application's own layers, declared here for the same reason: the use
76
+ // case, its errors and its tenant id are yours, not this package's.
77
+ class DuplicateOrder extends TaggedError("DuplicateOrder")<{ readonly id: string }> {}
78
+ class InvalidOrderId extends TaggedError("InvalidOrderId")<{ readonly id: string }> {}
79
+ class InvalidQuantity extends TaggedError("InvalidQuantity")<{ readonly id: string }> {}
80
+ declare const TenantId: (value: string) => string;
81
+ class PlaceOrder extends Port("PlaceOrder")<{
82
+ readonly execute: (
83
+ tenantId: string,
84
+ id: string,
85
+ quantity: number,
86
+ ) => AsyncResult<
87
+ { readonly id: string },
88
+ DuplicateOrder | InvalidOrderId | InvalidQuantity
89
+ >;
90
+ }> {}
91
+ declare const OrderApplicationModule: Module<PlaceOrder, never, never>;
92
+ declare const OrderPersistenceModule: AnyModule;
34
93
 
35
94
  // The application module the README's composition imports — the orders
36
95
  // vertical plus its persistence, as an application would compose it.
37
96
  const AppModule = Module("App")({
38
97
  imports: [OrderApplicationModule, OrderPersistenceModule, observability(), otel()],
39
- exports: [PlaceOrder, OrderRepository, Outbox, Logger],
98
+ exports: [PlaceOrder, Logger],
40
99
  });
41
100
  -->
42
101
 
@@ -53,7 +112,7 @@ import { ErrAsync, OkAsync, P } from "unthrown";
53
112
  const orderHandlers = AmqpHandlers(orderContract)({
54
113
  inject: { placeOrder: PlaceOrder },
55
114
  sync: ({ placeOrder }) => ({
56
- orderNotifications: (message) =>
115
+ orderNotifications: ({ input: message }) =>
57
116
  placeOrder
58
117
  .execute(
59
118
  TenantId(message.payload.tenantId),
@@ -102,9 +161,15 @@ rpc, typed by the key alone, and `AmqpHandlers(contract)([...])` composes an
102
161
  array of them into the same handlers provider `AmqpModule` takes — the array
103
162
  must cover every key the contract declares, and each piece's own port must
104
163
  still be discharged (`provides`), since the composed provider's deps are the
105
- pieces' ports, not what they close over. Two slices claiming one key are di's
106
- duplicate-provider defect at build, which is the point: a consumer belongs to
107
- exactly one slice.
164
+ pieces' ports, not what they close over. A consumer belongs to exactly one slice, and the
165
+ compiler holds only half of that: coverage is checked, injectivity is not, so
166
+ two slices claiming one key type-check together. It is di's duplicate-provider
167
+ defect at build once **both** pieces are discharged as providers in one graph
168
+ — wire in only one and the other's handler is silently never registered.
169
+
170
+ Its own test suite needs a **Docker daemon**: the specs run against the shared
171
+ `rabbitmq` container `internal/test-infra` starts once per machine and every
172
+ workspace reuses. Nothing else here does.
108
173
 
109
174
  ## Options
110
175
 
package/dist/index.cjs CHANGED
@@ -34,7 +34,17 @@ const AmqpHandler = (contract, key) => {
34
34
  * handler that must stop when the kernel stops waiting reads
35
35
  * `currentUnit()?.signal`.
36
36
  */
37
- const messageUnits = (host) => (args, next) => host.run(metaFor(args.rawMessage), () => next());
37
+ const messageUnits = (host, observers) => (args, next) => {
38
+ const settle = (0, _btravstack_core.observe)(observers, {
39
+ component: "amqp",
40
+ name: "delivery",
41
+ attributes: { handler: args.handlerName }
42
+ });
43
+ return host.run(metaFor(args.rawMessage), () => next()).tap(() => settle({ outcome: "ok" })).tapFailure((failure) => settle({
44
+ outcome: "error",
45
+ cause: failure.tag === "Err" ? failure.error : failure.cause
46
+ }));
47
+ };
38
48
  /**
39
49
  * `UnitMeta.id` must be unique per unit, and a **delivery tag is not one**: tags
40
50
  * are per-channel and restart at `1` after a reconnect, which
@@ -115,17 +125,25 @@ const amqp = (options) => {
115
125
  }));
116
126
  return (0, _btravstack_di.Module)("Amqp")({
117
127
  needs: [_btravstack_config.Env, AmqpHandlersPort],
118
- provides: [config, (0, _btravstack_di.Provider)(AmqpRuntime)({
119
- inject: {
120
- config: AmqpConfig,
121
- handlers: AmqpHandlersPort
122
- },
123
- sync: ({ config: bound, handlers }) => ({
124
- name: "amqp",
125
- resolves: [],
126
- start: (host) => createWorker(host, bound, options, handlers)
128
+ provides: [
129
+ config,
130
+ _btravstack_di.Provider.member(_btravstack_core.Observers)({
131
+ inject: {},
132
+ value: _btravstack_core.noObserver
133
+ }),
134
+ (0, _btravstack_di.Provider)(AmqpRuntime)({
135
+ inject: {
136
+ config: AmqpConfig,
137
+ handlers: AmqpHandlersPort,
138
+ observers: _btravstack_core.Observers
139
+ },
140
+ sync: ({ config: bound, handlers, observers }) => ({
141
+ name: "amqp",
142
+ resolves: [],
143
+ start: (host) => createWorker(host, bound, options, handlers, observers)
144
+ })
127
145
  })
128
- })],
146
+ ],
129
147
  exports: [AmqpRuntime, AmqpConfig]
130
148
  });
131
149
  };
@@ -165,15 +183,15 @@ const queuesOf = (contract) => [...new Set(Object.values({
165
183
  ...contract.consumers,
166
184
  ...contract.rpcs
167
185
  }).map((entry) => entry.queue.name))].sort();
168
- const createWorker = (host, config, options, handlers) => _amqp_contract_worker.TypedAmqpWorker.create({
186
+ const createWorker = (host, config, options, handlers, observers) => _amqp_contract_worker.TypedAmqpWorker.create({
169
187
  contract: options.contract,
170
188
  handlers,
171
- middleware: messageUnits(host),
189
+ middleware: messageUnits(host, observers),
172
190
  urls: [config.url],
173
191
  ...options.connectionOptions === void 0 ? {} : { connectionOptions: options.connectionOptions },
174
192
  ...options.defaultConsumerOptions === void 0 ? {} : { defaultConsumerOptions: options.defaultConsumerOptions },
175
193
  connectTimeoutMs: config.connectTimeoutMs
176
- }).map((worker) => consume(worker, queuesOf(options.contract))).recoverDefect((cause) => (0, unthrown.ErrAsync)(startFailed(cause)));
194
+ }).map((worker) => consume(worker, queuesOf(options.contract))).mapErrCases((matcher) => matcher.with(unthrown.P.tag("@amqp-contract/ConnectionError"), (error) => startFailed(error)));
177
195
  const consume = (worker, queues) => {
178
196
  let closing;
179
197
  const beginClose = () => closing ??= worker.close({ drainTimeoutMs: null });
package/dist/index.d.cts CHANGED
@@ -110,6 +110,19 @@ type KeyOfPiece<P> = P extends {
110
110
  } ? K : never;
111
111
  /** The consumers and rpcs no piece in `T` covers. */
112
112
  type Uncovered<C extends AnyAmqpContract, T extends readonly PieceOf<C>[]> = Exclude<HandlerKeyOf<C>, KeyOfPiece<T[number]>>;
113
+ /**
114
+ * A refused array: as long as the array the caller wrote, its head the caller's
115
+ * own elements — which match — and its LAST element the marker paired with what
116
+ * is wrong.
117
+ *
118
+ * TypeScript compares two equal-length tuples element by element, so the extra
119
+ * diagnostic it reports lands on the trailing element and carries both the
120
+ * sentence and the missing key. A fixed two-element tuple named the key only
121
+ * when the array happened to be two elements long; every other arity was a
122
+ * length mismatch, and the developer diffed the contract against the array by
123
+ * hand.
124
+ */
125
+ type Refuse<T extends readonly unknown[], Marker extends string, Detail> = T extends readonly [...infer Head, unknown] ? readonly [...Head, readonly [Marker, Detail]] : readonly [readonly [Marker, Detail]];
113
126
  /**
114
127
  * The composing arm. Declared LAST in the intersection below on purpose:
115
128
  * TypeScript reports the last overload's failure, so a non-covering array is
@@ -119,7 +132,7 @@ type Uncovered<C extends AnyAmqpContract, T extends readonly PieceOf<C>[]> = Exc
119
132
  * The marker is a sentence rather than a bare label because it prints last,
120
133
  * after the caller's own several-hundred-character piece type.
121
134
  */
122
- type Compose<C extends AnyAmqpContract> = <const T extends readonly PieceOf<C>[]>(pieces: [Uncovered<C, T>] extends [never] ? T : readonly ["UNCOVERED HANDLERS — the contract declares a consumer this array does not cover", Uncovered<C, T>]) => Provider<HandlersInstanceOf<C>, never, InstanceType<T[number]["port"]>> & {
135
+ type Compose<C extends AnyAmqpContract> = <const T extends readonly PieceOf<C>[]>(pieces: [Uncovered<C, T>] extends [never] ? T : Refuse<T, "UNCOVERED HANDLERS — the contract declares a consumer this array does not cover", Uncovered<C, T>>) => Provider<HandlersInstanceOf<C>, never, InstanceType<T[number]["port"]>> & {
123
136
  readonly port: HandlersPortOf<C>;
124
137
  };
125
138
  /**
package/dist/index.d.mts CHANGED
@@ -110,6 +110,19 @@ type KeyOfPiece<P> = P extends {
110
110
  } ? K : never;
111
111
  /** The consumers and rpcs no piece in `T` covers. */
112
112
  type Uncovered<C extends AnyAmqpContract, T extends readonly PieceOf<C>[]> = Exclude<HandlerKeyOf<C>, KeyOfPiece<T[number]>>;
113
+ /**
114
+ * A refused array: as long as the array the caller wrote, its head the caller's
115
+ * own elements — which match — and its LAST element the marker paired with what
116
+ * is wrong.
117
+ *
118
+ * TypeScript compares two equal-length tuples element by element, so the extra
119
+ * diagnostic it reports lands on the trailing element and carries both the
120
+ * sentence and the missing key. A fixed two-element tuple named the key only
121
+ * when the array happened to be two elements long; every other arity was a
122
+ * length mismatch, and the developer diffed the contract against the array by
123
+ * hand.
124
+ */
125
+ type Refuse<T extends readonly unknown[], Marker extends string, Detail> = T extends readonly [...infer Head, unknown] ? readonly [...Head, readonly [Marker, Detail]] : readonly [readonly [Marker, Detail]];
113
126
  /**
114
127
  * The composing arm. Declared LAST in the intersection below on purpose:
115
128
  * TypeScript reports the last overload's failure, so a non-covering array is
@@ -119,7 +132,7 @@ type Uncovered<C extends AnyAmqpContract, T extends readonly PieceOf<C>[]> = Exc
119
132
  * The marker is a sentence rather than a bare label because it prints last,
120
133
  * after the caller's own several-hundred-character piece type.
121
134
  */
122
- type Compose<C extends AnyAmqpContract> = <const T extends readonly PieceOf<C>[]>(pieces: [Uncovered<C, T>] extends [never] ? T : readonly ["UNCOVERED HANDLERS — the contract declares a consumer this array does not cover", Uncovered<C, T>]) => Provider<HandlersInstanceOf<C>, never, InstanceType<T[number]["port"]>> & {
135
+ type Compose<C extends AnyAmqpContract> = <const T extends readonly PieceOf<C>[]>(pieces: [Uncovered<C, T>] extends [never] ? T : Refuse<T, "UNCOVERED HANDLERS — the contract declares a consumer this array does not cover", Uncovered<C, T>>) => Provider<HandlersInstanceOf<C>, never, InstanceType<T[number]["port"]>> & {
123
136
  readonly port: HandlersPortOf<C>;
124
137
  };
125
138
  /**
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { Module, Port, Provider } from "@btravstack/di";
2
2
  import { TypedAmqpWorker } from "@amqp-contract/worker";
3
3
  import { Config, Env } from "@btravstack/config";
4
- import { RuntimePort, RuntimeStartFailed, releasedBy } from "@btravstack/core";
5
- import { ErrAsync } from "unthrown";
4
+ import { Observers, RuntimePort, RuntimeStartFailed, noObserver, observe, releasedBy } from "@btravstack/core";
5
+ import { P } from "unthrown";
6
6
  import { randomUUID } from "node:crypto";
7
7
  //#region src/handler.ts
8
8
  /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
@@ -33,7 +33,17 @@ const AmqpHandler = (contract, key) => {
33
33
  * handler that must stop when the kernel stops waiting reads
34
34
  * `currentUnit()?.signal`.
35
35
  */
36
- const messageUnits = (host) => (args, next) => host.run(metaFor(args.rawMessage), () => next());
36
+ const messageUnits = (host, observers) => (args, next) => {
37
+ const settle = observe(observers, {
38
+ component: "amqp",
39
+ name: "delivery",
40
+ attributes: { handler: args.handlerName }
41
+ });
42
+ return host.run(metaFor(args.rawMessage), () => next()).tap(() => settle({ outcome: "ok" })).tapFailure((failure) => settle({
43
+ outcome: "error",
44
+ cause: failure.tag === "Err" ? failure.error : failure.cause
45
+ }));
46
+ };
37
47
  /**
38
48
  * `UnitMeta.id` must be unique per unit, and a **delivery tag is not one**: tags
39
49
  * are per-channel and restart at `1` after a reconnect, which
@@ -114,17 +124,25 @@ const amqp = (options) => {
114
124
  }));
115
125
  return Module("Amqp")({
116
126
  needs: [Env, AmqpHandlersPort],
117
- provides: [config, Provider(AmqpRuntime)({
118
- inject: {
119
- config: AmqpConfig,
120
- handlers: AmqpHandlersPort
121
- },
122
- sync: ({ config: bound, handlers }) => ({
123
- name: "amqp",
124
- resolves: [],
125
- start: (host) => createWorker(host, bound, options, handlers)
127
+ provides: [
128
+ config,
129
+ Provider.member(Observers)({
130
+ inject: {},
131
+ value: noObserver
132
+ }),
133
+ Provider(AmqpRuntime)({
134
+ inject: {
135
+ config: AmqpConfig,
136
+ handlers: AmqpHandlersPort,
137
+ observers: Observers
138
+ },
139
+ sync: ({ config: bound, handlers, observers }) => ({
140
+ name: "amqp",
141
+ resolves: [],
142
+ start: (host) => createWorker(host, bound, options, handlers, observers)
143
+ })
126
144
  })
127
- })],
145
+ ],
128
146
  exports: [AmqpRuntime, AmqpConfig]
129
147
  });
130
148
  };
@@ -164,15 +182,15 @@ const queuesOf = (contract) => [...new Set(Object.values({
164
182
  ...contract.consumers,
165
183
  ...contract.rpcs
166
184
  }).map((entry) => entry.queue.name))].sort();
167
- const createWorker = (host, config, options, handlers) => TypedAmqpWorker.create({
185
+ const createWorker = (host, config, options, handlers, observers) => TypedAmqpWorker.create({
168
186
  contract: options.contract,
169
187
  handlers,
170
- middleware: messageUnits(host),
188
+ middleware: messageUnits(host, observers),
171
189
  urls: [config.url],
172
190
  ...options.connectionOptions === void 0 ? {} : { connectionOptions: options.connectionOptions },
173
191
  ...options.defaultConsumerOptions === void 0 ? {} : { defaultConsumerOptions: options.defaultConsumerOptions },
174
192
  connectTimeoutMs: config.connectTimeoutMs
175
- }).map((worker) => consume(worker, queuesOf(options.contract))).recoverDefect((cause) => ErrAsync(startFailed(cause)));
193
+ }).map((worker) => consume(worker, queuesOf(options.contract))).mapErrCases((matcher) => matcher.with(P.tag("@amqp-contract/ConnectionError"), (error) => startFailed(error)));
176
194
  const consume = (worker, queues) => {
177
195
  let closing;
178
196
  const beginClose = () => closing ??= worker.close({ drainTimeoutMs: null });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/amqp-worker",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "The AMQP starter for @btravstack/core: the handlers as a port, one unit per delivery, and a drain the kernel's deadline releases",
5
5
  "keywords": [
6
6
  "amqp",
@@ -44,9 +44,9 @@
44
44
  "./package.json": "./package.json"
45
45
  },
46
46
  "devDependencies": {
47
- "@amqp-contract/contract": "3.0.0-beta.6",
48
- "@amqp-contract/testing": "3.0.0-beta.6",
49
- "@amqp-contract/worker": "3.0.0-beta.6",
47
+ "@amqp-contract/contract": "3.0.0-beta.7",
48
+ "@amqp-contract/testing": "3.0.0-beta.7",
49
+ "@amqp-contract/worker": "3.0.0-beta.7",
50
50
  "@btravstack/tsconfig": "0.2.0",
51
51
  "@opentelemetry/api": "1.9.1",
52
52
  "@types/node": "26.4.0",
@@ -56,20 +56,20 @@
56
56
  "typescript": "7.0.2",
57
57
  "unthrown": "5.7.0",
58
58
  "vitest": "4.1.11",
59
- "zod": "4.5.1",
60
- "@btravstack/config": "0.9.0",
61
- "@btravstack/core": "0.9.0",
62
- "@btravstack/di": "0.9.0",
59
+ "zod": "4.5.3",
60
+ "@btravstack/config": "0.11.0",
61
+ "@btravstack/core": "0.11.0",
62
+ "@btravstack/di": "0.11.0",
63
63
  "@btravstack/internal-test-infra": "0.0.0",
64
- "@btravstack/testing": "0.9.0"
64
+ "@btravstack/testing": "0.11.0"
65
65
  },
66
66
  "peerDependencies": {
67
- "@amqp-contract/worker": "^3.0.0-beta.6",
67
+ "@amqp-contract/worker": "^3.0.0-beta.7",
68
68
  "@opentelemetry/api": "^1",
69
69
  "unthrown": "^5.0.0",
70
- "@btravstack/config": "^0.9.0",
71
- "@btravstack/core": "^0.9.0",
72
- "@btravstack/di": "^0.9.0"
70
+ "@btravstack/config": "^0.11.0",
71
+ "@btravstack/core": "^0.11.0",
72
+ "@btravstack/di": "^0.11.0"
73
73
  },
74
74
  "engines": {
75
75
  "node": ">=22"