@btravstack/amqp-worker 0.8.0 → 0.10.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
@@ -50,37 +50,35 @@ import { ErrAsync, OkAsync, P } from "unthrown";
50
50
  // built by di from the use cases it lists — no injected context — on the
51
51
  // starter's own handlers port, typed by the contract (a consumer serves one
52
52
  // handlers record, so there is nothing to name).
53
- const orderHandlers = AmqpHandlers(orderContract)(
54
- { placeOrder: PlaceOrder },
55
- {
56
- sync: ({ placeOrder }) => ({
57
- orderNotifications: (message) =>
58
- placeOrder
59
- .execute(
60
- TenantId(message.payload.tenantId),
61
- message.payload.id,
62
- message.payload.payload?.quantity ?? 0,
63
- )
64
- .map(() => undefined)
65
- // A modeled domain error is permanent: dead-letter it, no retry.
66
- .mapErrCases((matcher) =>
67
- matcher.with(
68
- P.tag("InvalidQuantity"),
69
- P.tag("InvalidOrderId"),
70
- P.tag("DuplicateOrder"),
71
- (error) => new NonRetryableError(error._tag, error),
72
- ),
73
- )
74
- // Infrastructure failing is not: recover the defect into a retry.
75
- .recoverDefect((cause) =>
76
- ErrAsync(new RetryableError("placing the order failed", cause)),
53
+ const orderHandlers = AmqpHandlers(orderContract)({
54
+ inject: { placeOrder: PlaceOrder },
55
+ sync: ({ placeOrder }) => ({
56
+ orderNotifications: ({ input: message }) =>
57
+ placeOrder
58
+ .execute(
59
+ TenantId(message.payload.tenantId),
60
+ message.payload.id,
61
+ message.payload.payload?.quantity ?? 0,
62
+ )
63
+ .map(() => undefined)
64
+ // A modeled domain error is permanent: dead-letter it, no retry.
65
+ .mapErrCases((matcher) =>
66
+ matcher.with(
67
+ P.tag("InvalidQuantity"),
68
+ P.tag("InvalidOrderId"),
69
+ P.tag("DuplicateOrder"),
70
+ (error) => new NonRetryableError(error._tag, error),
77
71
  ),
78
- // Every consumer the contract declares must be covered — an uncovered
79
- // key is refused at this call, not on the first delivery.
80
- orderAudit: () => OkAsync(undefined),
81
- }),
82
- },
83
- );
72
+ )
73
+ // Infrastructure failing is not: recover the defect into a retry.
74
+ .recoverDefect((cause) =>
75
+ ErrAsync(new RetryableError("placing the order failed", cause)),
76
+ ),
77
+ // Every consumer the contract declares must be covered — an uncovered
78
+ // key is refused at this call, not on the first delivery.
79
+ orderAudit: () => OkAsync(undefined),
80
+ }),
81
+ });
84
82
 
85
83
  const Worker = AmqpModule("Worker")({
86
84
  needs: [Env],
@@ -99,7 +97,7 @@ call, not on the first delivery. `runtimeInfo()` reads `{ queues }` back once
99
97
  consuming.
100
98
 
101
99
  A worker with several consumers can be several slices instead of one record:
102
- `AmqpHandler(contract, key)({ name: Dep }, arm)` mints a provider for ONE consumer or
100
+ `AmqpHandler(contract, key)({ inject: { name: Dep }, ...arm })` mints a provider for ONE consumer or
103
101
  rpc, typed by the key alone, and `AmqpHandlers(contract)([...])` composes an
104
102
  array of them into the same handlers provider `AmqpModule` takes — the array
105
103
  must cover every key the contract declares, and each piece's own port must
@@ -108,6 +106,10 @@ pieces' ports, not what they close over. Two slices claiming one key are di's
108
106
  duplicate-provider defect at build, which is the point: a consumer belongs to
109
107
  exactly one slice.
110
108
 
109
+ Its own test suite needs a **Docker daemon**: the specs run against the shared
110
+ `rabbitmq` container `internal/test-infra` starts once per machine and every
111
+ workspace reuses. Nothing else here does.
112
+
111
113
  ## Options
112
114
 
113
115
  `AmqpModule(name)({...})` takes `amqp()`'s options plus `handlers` and the
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,27 +125,38 @@ 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
- config: AmqpConfig,
120
- handlers: AmqpHandlersPort
121
- }, { sync: ({ config: bound, handlers }) => ({
122
- name: "amqp",
123
- resolves: [],
124
- start: (host) => createWorker(host, bound, options, handlers)
125
- }) })],
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
+ })
145
+ })
146
+ ],
126
147
  exports: [AmqpRuntime, AmqpConfig]
127
148
  });
128
149
  };
129
150
  /**
130
- * The handlers as a provider, from the contract. Three call forms, one port.
151
+ * The handlers as a provider, from the contract. Two call forms, one port.
131
152
  *
132
153
  * ```ts
133
- * AmqpHandlers(orderContract)({ logger: Logger }, { sync: ({ logger }) => ({ orderNotifications: (m) => … }) })
154
+ * AmqpHandlers(orderContract)({ inject: { logger: Logger }, sync: ({ logger }) => ({ orderNotifications: (m) => … }) })
134
155
  * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
135
156
  * ```
136
157
  *
137
- * The first two are di's own `Provider(port)` on the starter's handlers port
138
- * typed for the contract. The third takes the pieces `AmqpHandler(contract,
158
+ * The first is di's own `Provider(port)` on the starter's handlers port
159
+ * typed for the contract. The second takes the pieces `AmqpHandler(contract,
139
160
  * key)` builds: they are the provider's deps, keyed by the contract key each
140
161
  * piece's port id carries, so the services record IS the handlers record. Every
141
162
  * declared key must be covered, and two slices claiming one key are di's
@@ -143,8 +164,11 @@ const amqp = (options) => {
143
164
  */
144
165
  const AmqpHandlers = (contract) => {
145
166
  const build = (0, _btravstack_di.Provider)(AmqpHandlersPort);
146
- const compose = (pieces) => build(Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(12), piece.port])), { sync: (services) => services });
147
- return ((first, second) => second === void 0 && Array.isArray(first) ? compose(first) : build(first, second));
167
+ const compose = (pieces) => build({
168
+ inject: Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(12), piece.port])),
169
+ sync: (services) => services
170
+ });
171
+ return ((first) => Array.isArray(first) ? compose(first) : build(first));
148
172
  };
149
173
  const startFailed = (cause) => new _btravstack_core.RuntimeStartFailed({
150
174
  runtime: "amqp",
@@ -159,15 +183,15 @@ const queuesOf = (contract) => [...new Set(Object.values({
159
183
  ...contract.consumers,
160
184
  ...contract.rpcs
161
185
  }).map((entry) => entry.queue.name))].sort();
162
- const createWorker = (host, config, options, handlers) => _amqp_contract_worker.TypedAmqpWorker.create({
186
+ const createWorker = (host, config, options, handlers, observers) => _amqp_contract_worker.TypedAmqpWorker.create({
163
187
  contract: options.contract,
164
188
  handlers,
165
- middleware: messageUnits(host),
189
+ middleware: messageUnits(host, observers),
166
190
  urls: [config.url],
167
191
  ...options.connectionOptions === void 0 ? {} : { connectionOptions: options.connectionOptions },
168
192
  ...options.defaultConsumerOptions === void 0 ? {} : { defaultConsumerOptions: options.defaultConsumerOptions },
169
193
  connectTimeoutMs: config.connectTimeoutMs
170
- }).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)));
171
195
  const consume = (worker, queues) => {
172
196
  let closing;
173
197
  const beginClose = () => closing ??= worker.close({ drainTimeoutMs: null });
package/dist/index.d.cts CHANGED
@@ -123,15 +123,15 @@ type Compose<C extends AnyAmqpContract> = <const T extends readonly PieceOf<C>[]
123
123
  readonly port: HandlersPortOf<C>;
124
124
  };
125
125
  /**
126
- * The handlers as a provider, from the contract. Three call forms, one port.
126
+ * The handlers as a provider, from the contract. Two call forms, one port.
127
127
  *
128
128
  * ```ts
129
- * AmqpHandlers(orderContract)({ logger: Logger }, { sync: ({ logger }) => ({ orderNotifications: (m) => … }) })
129
+ * AmqpHandlers(orderContract)({ inject: { logger: Logger }, sync: ({ logger }) => ({ orderNotifications: (m) => … }) })
130
130
  * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
131
131
  * ```
132
132
  *
133
- * The first two are di's own `Provider(port)` on the starter's handlers port
134
- * typed for the contract. The third takes the pieces `AmqpHandler(contract,
133
+ * The first is di's own `Provider(port)` on the starter's handlers port
134
+ * typed for the contract. The second takes the pieces `AmqpHandler(contract,
135
135
  * key)` builds: they are the provider's deps, keyed by the contract key each
136
136
  * piece's port id carries, so the services record IS the handlers record. Every
137
137
  * declared key must be covered, and two slices claiming one key are di's
package/dist/index.d.mts CHANGED
@@ -123,15 +123,15 @@ type Compose<C extends AnyAmqpContract> = <const T extends readonly PieceOf<C>[]
123
123
  readonly port: HandlersPortOf<C>;
124
124
  };
125
125
  /**
126
- * The handlers as a provider, from the contract. Three call forms, one port.
126
+ * The handlers as a provider, from the contract. Two call forms, one port.
127
127
  *
128
128
  * ```ts
129
- * AmqpHandlers(orderContract)({ logger: Logger }, { sync: ({ logger }) => ({ orderNotifications: (m) => … }) })
129
+ * AmqpHandlers(orderContract)({ inject: { logger: Logger }, sync: ({ logger }) => ({ orderNotifications: (m) => … }) })
130
130
  * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
131
131
  * ```
132
132
  *
133
- * The first two are di's own `Provider(port)` on the starter's handlers port
134
- * typed for the contract. The third takes the pieces `AmqpHandler(contract,
133
+ * The first is di's own `Provider(port)` on the starter's handlers port
134
+ * typed for the contract. The second takes the pieces `AmqpHandler(contract,
135
135
  * key)` builds: they are the provider's deps, keyed by the contract key each
136
136
  * piece's port id carries, so the services record IS the handlers record. Every
137
137
  * declared key must be covered, and two slices claiming one key are di's
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,27 +124,38 @@ const amqp = (options) => {
114
124
  }));
115
125
  return Module("Amqp")({
116
126
  needs: [Env, AmqpHandlersPort],
117
- provides: [config, Provider(AmqpRuntime)({
118
- config: AmqpConfig,
119
- handlers: AmqpHandlersPort
120
- }, { sync: ({ config: bound, handlers }) => ({
121
- name: "amqp",
122
- resolves: [],
123
- start: (host) => createWorker(host, bound, options, handlers)
124
- }) })],
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
+ })
144
+ })
145
+ ],
125
146
  exports: [AmqpRuntime, AmqpConfig]
126
147
  });
127
148
  };
128
149
  /**
129
- * The handlers as a provider, from the contract. Three call forms, one port.
150
+ * The handlers as a provider, from the contract. Two call forms, one port.
130
151
  *
131
152
  * ```ts
132
- * AmqpHandlers(orderContract)({ logger: Logger }, { sync: ({ logger }) => ({ orderNotifications: (m) => … }) })
153
+ * AmqpHandlers(orderContract)({ inject: { logger: Logger }, sync: ({ logger }) => ({ orderNotifications: (m) => … }) })
133
154
  * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
134
155
  * ```
135
156
  *
136
- * The first two are di's own `Provider(port)` on the starter's handlers port
137
- * typed for the contract. The third takes the pieces `AmqpHandler(contract,
157
+ * The first is di's own `Provider(port)` on the starter's handlers port
158
+ * typed for the contract. The second takes the pieces `AmqpHandler(contract,
138
159
  * key)` builds: they are the provider's deps, keyed by the contract key each
139
160
  * piece's port id carries, so the services record IS the handlers record. Every
140
161
  * declared key must be covered, and two slices claiming one key are di's
@@ -142,8 +163,11 @@ const amqp = (options) => {
142
163
  */
143
164
  const AmqpHandlers = (contract) => {
144
165
  const build = Provider(AmqpHandlersPort);
145
- const compose = (pieces) => build(Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(12), piece.port])), { sync: (services) => services });
146
- return ((first, second) => second === void 0 && Array.isArray(first) ? compose(first) : build(first, second));
166
+ const compose = (pieces) => build({
167
+ inject: Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(12), piece.port])),
168
+ sync: (services) => services
169
+ });
170
+ return ((first) => Array.isArray(first) ? compose(first) : build(first));
147
171
  };
148
172
  const startFailed = (cause) => new RuntimeStartFailed({
149
173
  runtime: "amqp",
@@ -158,15 +182,15 @@ const queuesOf = (contract) => [...new Set(Object.values({
158
182
  ...contract.consumers,
159
183
  ...contract.rpcs
160
184
  }).map((entry) => entry.queue.name))].sort();
161
- const createWorker = (host, config, options, handlers) => TypedAmqpWorker.create({
185
+ const createWorker = (host, config, options, handlers, observers) => TypedAmqpWorker.create({
162
186
  contract: options.contract,
163
187
  handlers,
164
- middleware: messageUnits(host),
188
+ middleware: messageUnits(host, observers),
165
189
  urls: [config.url],
166
190
  ...options.connectionOptions === void 0 ? {} : { connectionOptions: options.connectionOptions },
167
191
  ...options.defaultConsumerOptions === void 0 ? {} : { defaultConsumerOptions: options.defaultConsumerOptions },
168
192
  connectTimeoutMs: config.connectTimeoutMs
169
- }).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)));
170
194
  const consume = (worker, queues) => {
171
195
  let closing;
172
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.8.0",
3
+ "version": "0.10.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.8.0",
61
- "@btravstack/core": "0.8.0",
62
- "@btravstack/di": "0.8.0",
59
+ "zod": "4.5.3",
60
+ "@btravstack/config": "0.10.0",
61
+ "@btravstack/core": "0.10.0",
62
+ "@btravstack/di": "0.10.0",
63
63
  "@btravstack/internal-test-infra": "0.0.0",
64
- "@btravstack/testing": "0.8.0"
64
+ "@btravstack/testing": "0.10.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.8.0",
71
- "@btravstack/core": "^0.8.0",
72
- "@btravstack/di": "^0.8.0"
70
+ "@btravstack/config": "^0.10.0",
71
+ "@btravstack/core": "^0.10.0",
72
+ "@btravstack/di": "^0.10.0"
73
73
  },
74
74
  "engines": {
75
75
  "node": ">=22"