@btravstack/amqp-worker 0.12.0 → 0.13.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
@@ -156,7 +156,7 @@ call, not on the first delivery. `runtimeInfo()` reads `{ queues }` back once
156
156
  consuming.
157
157
 
158
158
  A worker with several consumers can be several slices instead of one record:
159
- `AmqpHandler(contract, key)({ inject: { name: Dep }, ...arm })` mints a provider for ONE consumer or
159
+ `AmqpHandler(contract, key)({ inject: { name: Dep }, unit, sync })` mints a provider for ONE consumer or
160
160
  rpc, typed by the key alone, and `AmqpHandlers(contract)([...])` composes an
161
161
  array of them into the same handlers provider `AmqpModule` takes — the array
162
162
  must cover every key the contract declares, and each piece's own port must
@@ -167,6 +167,16 @@ two slices claiming one key type-check together. It is di's duplicate-provider
167
167
  defect at build once **both** pieces are discharged as providers in one graph
168
168
  — wire in only one and the other's handler is silently never registered.
169
169
 
170
+ A piece's `unit` names the ports its handler reads off `context.unit`, resolved
171
+ out of the per-delivery fork of the module bound on `unit.message` — which is
172
+ seeded with the validated message on `AmqpMessage(contract)`, so a unit module
173
+ derives a tenant from the delivery rather than from an ambient record. The
174
+ record arm above takes the same `unit:` — one record for every entry in it — so
175
+ a worker that has not outgrown one function reaches `context.unit` without
176
+ slicing first. The root is checked against what was declared either way: bind a
177
+ module that does not export one of those ports and `AmqpModule` refuses it,
178
+ naming the port.
179
+
170
180
  Its own test suite needs a **Docker daemon**: the specs run against the shared
171
181
  `rabbitmq` container `internal/test-infra` starts once per machine and every
172
182
  workspace reuses. Nothing else here does.
@@ -186,6 +196,7 @@ provider on its own port, which is how the composition root supplies it:
186
196
  | `connectionOptions` | `AmqpConnectionOptions`, the library's own connection tuning: heartbeat, reconnect interval, `findServers`, TLS |
187
197
  | `defaultConsumerOptions` | the library's `ConsumerOptions`, applied to every handler: `prefetch` (the throughput knob), `priority`, … |
188
198
  | `connectTimeoutMs` | pins `AMQP_CONNECT_TIMEOUT_MS` — how long startup waits before an unreachable broker is a `RuntimeStartFailed` (default `5000`) |
199
+ | `unit` | `{ message }` — the module the worker forks per delivery, seeded with the message on `AmqpMessage(contract)` |
189
200
 
190
201
  The full table — required/optional, defaults, and the reasoning — lives on
191
202
  [the reference page](https://btravstack.github.io/btravstack/reference/amqp-worker),
package/dist/index.cjs CHANGED
@@ -5,7 +5,73 @@ let _btravstack_config = require("@btravstack/config");
5
5
  let _btravstack_core = require("@btravstack/core");
6
6
  let unthrown = require("unthrown");
7
7
  let node_crypto = require("node:crypto");
8
+ //#region src/unit.ts
9
+ /**
10
+ * Where `messageUnits` leaves the forked context for the piece wrappers to read.
11
+ * A symbol rather than a name: the dispatcher merges it into the context every
12
+ * handler sees, and a string key would appear on the record a handler written
13
+ * against the `{ inject, sync }` arm destructures.
14
+ */
15
+ const UNIT_SCOPE = Symbol("@btravstack/amqp-worker/unit-scope");
16
+ /**
17
+ * The declared record, as a getter per name resolved on read out of the fork.
18
+ * Neither writable nor configurable — a handler reads what the fork holds, and
19
+ * cannot reshape the record under the next delivery. With no `unit.message`
20
+ * bound there is nothing to resolve from, so the record is empty whatever was
21
+ * declared.
22
+ */
23
+ const unitRecordOf = (forked, record) => {
24
+ const unit = {};
25
+ if (forked === void 0) return unit;
26
+ for (const [name, port] of Object.entries(record)) Object.defineProperty(unit, name, {
27
+ enumerable: true,
28
+ get: () => forked.get(port)
29
+ });
30
+ return unit;
31
+ };
32
+ /**
33
+ * One handler entry — a function, or `[handler, options]` — with its piece's
34
+ * declared record put on `context.unit`. Applied once per piece as di
35
+ * constructs it, so a delivery costs one record and one context object.
36
+ */
37
+ const withUnit = (record, entry) => {
38
+ const wrap = (handler) => (helpers, message) => {
39
+ const { [UNIT_SCOPE]: forked, ...rest } = helpers.context ?? {};
40
+ return handler({
41
+ ...helpers,
42
+ context: {
43
+ ...rest,
44
+ unit: unitRecordOf(forked, record)
45
+ }
46
+ }, message);
47
+ };
48
+ return Array.isArray(entry) ? [wrap(entry[0]), entry[1]] : wrap(entry);
49
+ };
50
+ //#endregion
8
51
  //#region src/handler.ts
52
+ /** One id, declared once, so no contract instantiating it warns about a duplicate. */
53
+ const AmqpMessagePort = (0, _btravstack_di.Port)("AmqpMessage");
54
+ /**
55
+ * The validated delivery as a port: the one thing the worker seeds the fork
56
+ * with, so a `unit.message` module derives a tenant — or anything else — from
57
+ * the message rather than from an ambient record.
58
+ *
59
+ * ```ts
60
+ * const Message = AmqpMessage(orderContract);
61
+ * const MessageUnit = Module("MessageUnit")({
62
+ * needs: [Message],
63
+ * provides: [Provider(Tenant)({ inject: { message: Message }, sync: ({ message }) => message.payload.tenantId })],
64
+ * exports: [Tenant],
65
+ * });
66
+ * ```
67
+ *
68
+ * `contract` is read for its TYPE only. One `Port(...)` call fixed per contract
69
+ * at the type level, the move `AmqpHandlersPort` makes, so a module built for
70
+ * one contract cannot read another's message.
71
+ */
72
+ const AmqpMessage = (contract) => {
73
+ return AmqpMessagePort;
74
+ };
9
75
  /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
10
76
  const HANDLER_PREFIX = "AmqpHandler:";
11
77
  /**
@@ -17,30 +83,49 @@ const HANDLER_PREFIX = "AmqpHandler:";
17
83
  * so a consumer the contract does not declare is a compile error here.
18
84
  *
19
85
  * There is no name to give: the contract key IS the port's name.
86
+ *
87
+ * `unit` declares the ports the handler reads off `context.unit`, resolved out
88
+ * of the fork the delivery opened; the root's `unit.message` module must export
89
+ * every one of them.
20
90
  */
21
91
  const AmqpHandler = (contract, key) => {
22
92
  const port = class extends (0, _btravstack_di.Port)(`${HANDLER_PREFIX}${key}`) {};
23
- return (0, _btravstack_di.Provider)(port);
93
+ return (options) => {
94
+ const record = options.unit ?? {};
95
+ return Object.assign((0, _btravstack_di.Provider)(port)({
96
+ inject: options.inject,
97
+ sync: (services) => withUnit(record, options.sync(services))
98
+ }), { unit: record });
99
+ };
24
100
  };
25
101
  //#endregion
26
102
  //#region src/message-units.ts
27
103
  /**
28
- * Open one kernel unit per delivery. It injects nothing — `next()` unchanged is
29
- * the whole of the chain — so the ambient `currentUnit()` record is what the
30
- * unit leaves for the adapters that read it.
104
+ * Open one kernel unit per delivery, forking `unit` — when one is bound — after
105
+ * the message is validated, before the handler runs; the fork is torn down
106
+ * when the unit closes. It is seeded with the validated delivery on
107
+ * `AmqpMessage(contract)`, so a unit module derives a tenant from the message
108
+ * rather than reading one off an ambient record. With no `unit` bound, `next()`
109
+ * runs unchanged, so the ambient `currentUnit()` record is what the unit leaves
110
+ * for the adapters that read it.
111
+ *
112
+ * The forked context rides the dispatcher's context under {@link UNIT_SCOPE},
113
+ * where each piece's own wrapper turns it into the typed `context.unit` record
114
+ * that piece declared — the records live with the pieces, so a hand-composed
115
+ * `amqp()` gets them without threading a second option through the starter.
31
116
  *
32
117
  * **The kernel's per-unit `AbortSignal` rides that record too**, and it is the
33
118
  * only route to it here: a handler has no parameter to receive one through. A
34
119
  * handler that must stop when the kernel stops waiting reads
35
120
  * `currentUnit()?.signal`.
36
121
  */
37
- const messageUnits = (host, observers) => (args, next) => {
122
+ const messageUnits = (host, observers, unit) => (args, next) => {
38
123
  const settle = (0, _btravstack_core.observe)(observers, {
39
124
  component: "amqp",
40
125
  name: "delivery",
41
126
  attributes: { handler: args.handlerName }
42
127
  });
43
- return host.run(metaFor(args.rawMessage), () => next()).tap(() => settle({ outcome: "ok" })).tapFailure((failure) => settle({
128
+ return host.run(metaFor(args.rawMessage), (scope) => unit === void 0 ? next() : scope.fork(unit, [[AmqpMessagePort, args.message]]).flatMap((forked) => next({ context: { [UNIT_SCOPE]: forked } }))).tap(() => settle({ outcome: "ok" })).tapFailure((failure) => settle({
44
129
  outcome: "error",
45
130
  cause: failure.tag === "Err" ? failure.error : failure.cause
46
131
  }));
@@ -144,12 +229,12 @@ const amqp = (options) => {
144
229
  * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
145
230
  * ```
146
231
  *
147
- * The first is di's own `Provider(port)` on the starter's handlers port
148
- * typed for the contract. The second takes the pieces `AmqpHandler(contract,
149
- * key)` builds: they are the provider's deps, keyed by the contract key each
150
- * piece's port id carries, so the services record IS the handlers record. Every
151
- * declared key must be covered, and two slices claiming one key are di's
152
- * duplicate-provider defect at build.
232
+ * The first is `{ inject, unit?, sync }`, whose `sync` hands back the whole
233
+ * handlers record — one `unit:` record for every entry in it. The second takes
234
+ * the pieces `AmqpHandler(contract, key)` builds: they are the provider's deps,
235
+ * keyed by the contract key each piece's port id carries, so the services
236
+ * record IS the handlers record. Every declared key must be covered, and two
237
+ * slices claiming one key are di's duplicate-provider defect at build.
153
238
  */
154
239
  const AmqpHandlers = (contract) => {
155
240
  const build = (0, _btravstack_di.Provider)(AmqpHandlersPort);
@@ -157,7 +242,14 @@ const AmqpHandlers = (contract) => {
157
242
  inject: Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(12), piece.port])),
158
243
  sync: (services) => services
159
244
  });
160
- return ((first) => Array.isArray(first) ? compose(first) : build(first));
245
+ const whole = (options) => {
246
+ const record = options.unit ?? {};
247
+ return build({
248
+ inject: options.inject,
249
+ sync: (services) => Object.fromEntries(Object.entries(options.sync(services)).map(([key, entry]) => [key, withUnit(record, entry)]))
250
+ });
251
+ };
252
+ return ((first) => Array.isArray(first) ? compose(first) : whole(first));
161
253
  };
162
254
  const startFailed = (cause) => new _btravstack_core.RuntimeStartFailed({
163
255
  runtime: "amqp",
@@ -175,7 +267,7 @@ const queuesOf = (contract) => [...new Set(Object.values({
175
267
  const createWorker = (host, config, options, handlers, observers) => _amqp_contract_worker.TypedAmqpWorker.create({
176
268
  contract: options.contract,
177
269
  handlers,
178
- middleware: messageUnits(host, observers),
270
+ middleware: messageUnits(host, observers, options.unit?.message),
179
271
  urls: [config.url],
180
272
  ...options.connectionOptions === void 0 ? {} : { connectionOptions: options.connectionOptions },
181
273
  ...options.defaultConsumerOptions === void 0 ? {} : { defaultConsumerOptions: options.defaultConsumerOptions },
@@ -230,6 +322,7 @@ const AmqpModule = (name) => (options) => {
230
322
  exports.AmqpConfig = AmqpConfig;
231
323
  exports.AmqpHandler = AmqpHandler;
232
324
  exports.AmqpHandlers = AmqpHandlers;
325
+ exports.AmqpMessage = AmqpMessage;
233
326
  exports.AmqpModule = AmqpModule;
234
327
  exports.AmqpRuntime = AmqpRuntime;
235
328
  exports.amqp = amqp;
package/dist/index.d.cts CHANGED
@@ -1,10 +1,59 @@
1
1
  import { ConfigInvalid, Env } from "@btravstack/config";
2
- import { AnyModule, AnyPort, AnyProvider, Exportable, Module, NeedsGate, PortClassOf, PortInstance, Provider } from "@btravstack/di";
2
+ import { AnyModule, AnyPort, AnyProvider, Exportable, Module, NeedsGate, PortClassOf, PortInstance, Provider, Scope, ServiceOf } from "@btravstack/di";
3
3
  import { ConsumerOptions, TypedAmqpWorker, WorkerInferHandlers } from "@amqp-contract/worker";
4
4
  import { Runtime, RuntimePort } from "@btravstack/core";
5
+ //#region src/unit.d.ts
6
+ /** What a bound unit module exports — the port instances a handler of that kind may read. */
7
+ type UnitExportsOf<M> = M extends Module<infer X, never, unknown> ? X : never;
8
+ /** The declared `unit:` record, as the services a handler reads off `context.unit`. */
9
+ type UnitRecordOf<U extends Readonly<Record<string, AnyPort>>> = { readonly [N in keyof U]: ServiceOf<InstanceType<U[N]>>; };
10
+ /**
11
+ * A piece injecting a port the bound `unit.message` module does not export.
12
+ * `unknown` when every declared port is covered — including the empty case, so
13
+ * a worker whose pieces declare no `unit:` is gated on nothing.
14
+ */
15
+ type UnitGate<Unit, Declared> = [Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>] extends [never] ? unknown : {
16
+ readonly "UNIT DOES NOT PROVIDE — a piece injects a port the bound unit module does not export": Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>;
17
+ };
18
+ //#endregion
5
19
  //#region src/handler.d.ts
6
20
  /** The consumer and rpc names `C` declares — the keys of its handlers record. */
7
21
  type HandlerKeyOf<C extends AnyAmqpContract> = keyof WorkerInferHandlers<C> & string;
22
+ /**
23
+ * The validated delivery, typed by the contract: the union of every consumer's
24
+ * and rpc's own consumed message. Reached through the handler's own second
25
+ * parameter, since `@amqp-contract/worker` exports
26
+ * `WorkerInferConsumedMessage` but not the `InferConsumerNames` constraint it
27
+ * takes — the same by-index route {@link AnyAmqpContract} travels.
28
+ */
29
+ type AmqpMessageOf<C extends AnyAmqpContract> = Parameters<Extract<WorkerInferHandlers<C>[HandlerKeyOf<C>], (...args: never) => unknown>>[1];
30
+ /** The seeded port's class, typed for `C`: what `AmqpMessage(contract)` answers. */
31
+ type AmqpMessagePortOf<C extends AnyAmqpContract> = PortClassOf<"AmqpMessage", AmqpMessageOf<C>>;
32
+ /**
33
+ * The seed's port instance, as it appears in a needs union — subtracted from
34
+ * what a bound `unit.message` module still owes, since the fork is what
35
+ * discharges it.
36
+ */
37
+ type AmqpMessageInstance = PortInstance<"AmqpMessage", unknown>;
38
+ /**
39
+ * The validated delivery as a port: the one thing the worker seeds the fork
40
+ * with, so a `unit.message` module derives a tenant — or anything else — from
41
+ * the message rather than from an ambient record.
42
+ *
43
+ * ```ts
44
+ * const Message = AmqpMessage(orderContract);
45
+ * const MessageUnit = Module("MessageUnit")({
46
+ * needs: [Message],
47
+ * provides: [Provider(Tenant)({ inject: { message: Message }, sync: ({ message }) => message.payload.tenantId })],
48
+ * exports: [Tenant],
49
+ * });
50
+ * ```
51
+ *
52
+ * `contract` is read for its TYPE only. One `Port(...)` call fixed per contract
53
+ * at the type level, the move `AmqpHandlersPort` makes, so a module built for
54
+ * one contract cannot read another's message.
55
+ */
56
+ declare const AmqpMessage: <C extends AnyAmqpContract>(contract: C) => AmqpMessagePortOf<C>;
8
57
  /**
9
58
  * The port one piece targets. Its id carries the contract key, which is what
10
59
  * makes two slices claiming one consumer di's duplicate-provider defect rather
@@ -13,6 +62,14 @@ type HandlerKeyOf<C extends AnyAmqpContract> = keyof WorkerInferHandlers<C> & st
13
62
  type HandlerPortOf<C extends AnyAmqpContract, K extends HandlerKeyOf<C>> = PortClassOf<`${typeof HANDLER_PREFIX}${K}`, WorkerInferHandlers<C>[K]>;
14
63
  /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
15
64
  declare const HANDLER_PREFIX = "AmqpHandler:";
65
+ /** What a minted piece returns. */
66
+ type MintedHandler<C extends AnyAmqpContract, K extends HandlerKeyOf<C>, N, U extends Readonly<Record<string, AnyPort>>> = Provider<InstanceType<HandlerPortOf<C, K>>, never, N> & {
67
+ readonly port: HandlerPortOf<C, K>;
68
+ /** The declared `unit:` record, which `AmqpHandlers`'s array arm reads back off the piece. */
69
+ readonly unit: U;
70
+ /** Phantom: the ports this piece injects, which the root's `unit.message` must export. */
71
+ readonly _declared?: InstanceType<U[keyof U]>;
72
+ };
16
73
  /**
17
74
  * One consumer or rpc of a contract, as a provider on a port of its own.
18
75
  *
@@ -22,8 +79,21 @@ declare const HANDLER_PREFIX = "AmqpHandler:";
22
79
  * so a consumer the contract does not declare is a compile error here.
23
80
  *
24
81
  * There is no name to give: the contract key IS the port's name.
82
+ *
83
+ * `unit` declares the ports the handler reads off `context.unit`, resolved out
84
+ * of the fork the delivery opened; the root's `unit.message` module must export
85
+ * every one of them.
25
86
  */
26
- declare const AmqpHandler: <C extends AnyAmqpContract, const K extends HandlerKeyOf<C>>(contract: C, key: K) => ReturnType<typeof Provider<HandlerPortOf<C, K>>>;
87
+ declare const AmqpHandler: <C extends AnyAmqpContract, const K extends HandlerKeyOf<C>>(contract: C, key: K) => <const D extends Readonly<Record<string, AnyPort>>, const U extends Readonly<Record<string, AnyPort>> = Record<never, never>>(options: {
88
+ readonly inject: D;
89
+ /** The unit-scoped ports this handler reads off `context.unit`. */
90
+ readonly unit?: U;
91
+ readonly sync: (services: { readonly [N in keyof D]: ServiceOf<InstanceType<D[N]>>; }) => WorkerInferHandlers<C, {
92
+ readonly unit: UnitRecordOf<U>;
93
+ }>[K & keyof WorkerInferHandlers<C, {
94
+ readonly unit: UnitRecordOf<U>;
95
+ }>];
96
+ }) => MintedHandler<C, K, InstanceType<D[keyof D]>, U>;
27
97
  //#endregion
28
98
  //#region src/amqp-runtime.d.ts
29
99
  /** What the worker publishes once it is consuming, read back through `RunningApp.runtimeInfo()`. */
@@ -44,6 +114,22 @@ declare class AmqpConfig extends AmqpConfig_base<{
44
114
  }> {}
45
115
  /** The runtime's port: what `amqp()` provides, and what the module `start` boots must export. */
46
116
  declare class AmqpRuntime extends RuntimePort<Runtime<never, AmqpInfo>> {}
117
+ /**
118
+ * A module `unit.message` may bind, as the upper bound `amqp()` constrains its
119
+ * own `Unit` type parameter to. `Module`'s `_exports` channel is contravariant,
120
+ * so `Exports = never` — never `unknown` — is what makes a REAL module's own
121
+ * (necessarily narrower) export type assignable to this bound: `(x: Concrete)
122
+ * => void` is assignable to `(x: never) => void`, not to `(x: unknown) =>
123
+ * void`.
124
+ */
125
+ type AnyUnitModule = Module<never, never, unknown>;
126
+ /**
127
+ * The needs a bound `unit.message` module still owes, or `never` when none is
128
+ * bound. `Scope` is excluded, since nothing can ever provide it — the same
129
+ * exemption `NeedsGate` itself carries — and so is the delivery, which the
130
+ * fork's own seed discharges.
131
+ */
132
+ type UnitNeedsOf<Unit> = Unit extends Module<never, never, infer N> ? Exclude<N, Scope | AmqpMessageInstance> : never;
47
133
  /**
48
134
  * The contract type `TypedAmqpWorker.create` accepts, extracted rather than
49
135
  * imported by name so `@amqp-contract/contract` stays out of the peer range.
@@ -60,20 +146,20 @@ type AmqpConnectionOptions = NonNullable<Parameters<typeof TypedAmqpWorker.creat
60
146
  type HandlersPortOf<C extends AnyAmqpContract> = PortClassOf<"AmqpHandlers", WorkerInferHandlers<C>>;
61
147
  /** The handlers port's instance for `C` — the module's one need. */
62
148
  type HandlersInstanceOf<C extends AnyAmqpContract> = PortInstance<"AmqpHandlers", WorkerInferHandlers<C>>;
63
- type AmqpOptions<TContract extends AnyAmqpContract> = {
149
+ type AmqpOptions<TContract extends AnyAmqpContract, Unit extends AnyUnitModule | undefined = undefined> = {
64
150
  /**
65
151
  * The contract; the handlers port is typed by it — one handler per
66
152
  * `consumers` / `rpcs` key, with no injected context, since a handler is
67
153
  * built by di from the services it declares.
68
154
  */
69
155
  readonly contract: TContract;
70
- } & AmqpTuning;
156
+ } & AmqpTuning<Unit>;
71
157
  /**
72
158
  * What an AMQP deployment tunes, shared verbatim by `amqp()` and `AmqpModule` —
73
159
  * spelled once so the two cannot drift, which is what a second copy of an
74
160
  * option list always eventually does.
75
161
  */
76
- type AmqpTuning = {
162
+ type AmqpTuning<Unit extends AnyUnitModule | undefined = undefined> = {
77
163
  /** Pins `AmqpConfig.url` instead of reading `AMQP_URL` — a test's container. */
78
164
  readonly url?: string;
79
165
  /** Connection tuning, `@amqp-contract/worker`'s own type: heartbeat, reconnect interval, `findServers`, TLS/socket options. */
@@ -88,6 +174,15 @@ type AmqpTuning = {
88
174
  * `connectionOptions`, where setting it is silently inert.
89
175
  */
90
176
  readonly connectTimeoutMs?: number;
177
+ /**
178
+ * The unit module the worker forks around every delivery it dispatches,
179
+ * seeded with the validated message on `AmqpMessage(contract)`. Built after
180
+ * the message is validated, torn down when the unit closes; what it exports
181
+ * is what a piece may declare on its own `unit:` record.
182
+ */
183
+ readonly unit?: {
184
+ readonly message?: Unit;
185
+ };
91
186
  };
92
187
  /**
93
188
  * The AMQP starter: a module providing the runtime and its configuration (bound
@@ -97,11 +192,17 @@ type AmqpTuning = {
97
192
  *
98
193
  * With `url` pinned the module reads nothing from the environment.
99
194
  */
100
- declare const amqp: <TContract extends AnyAmqpContract>(options: AmqpOptions<TContract>) => Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract>>;
195
+ declare const amqp: <TContract extends AnyAmqpContract, Unit extends AnyUnitModule | undefined = undefined>(options: AmqpOptions<TContract, Unit>) => Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract> | UnitNeedsOf<Unit>>;
101
196
  /** One piece of the handlers record — what `AmqpHandler(contract, key)(…)` returns, as the composing form consumes it. */
102
197
  type PieceOf<C extends AnyAmqpContract> = { readonly [K in HandlerKeyOf<C>]: {
103
198
  readonly port: HandlerPortOf<C, K>;
199
+ /** Phantom, carrying the ports the piece injects off `context.unit`. */
200
+ readonly _declared?: unknown;
104
201
  }; }[HandlerKeyOf<C>];
202
+ /** Every port the pieces in `T` inject off `context.unit` — what the root's `unit.message` must export. */
203
+ type DeclaredOf<T extends readonly {
204
+ readonly _declared?: unknown;
205
+ }[]> = NonNullable<T[number]["_declared"]>;
105
206
  /** The key a piece carries, read back off its port id. */
106
207
  type KeyOfPiece<P> = P extends {
107
208
  readonly port: {
@@ -123,6 +224,29 @@ type Uncovered<C extends AnyAmqpContract, T extends readonly PieceOf<C>[]> = Exc
123
224
  * hand.
124
225
  */
125
226
  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]];
227
+ /**
228
+ * The record arm: the whole handlers record from one `sync`, with one `unit:`
229
+ * record shared by every entry in it.
230
+ *
231
+ * It is `{ inject, unit?, sync }` — di's other arms are gone — for PARITY, not
232
+ * for a type-system reason: `value` could have carried the record just as well,
233
+ * since `U` infers from `unit` and never from the arm. `@btravstack/http-server`'s
234
+ * `api.OrpcRouter(contract)` is `{ inject, unit?, sync }` and so are all three
235
+ * packages' piece factories, so one arm across the family is one surface to
236
+ * learn and one to keep.
237
+ */
238
+ type Whole<C extends AnyAmqpContract> = <const D extends Readonly<Record<string, AnyPort>>, const U extends Readonly<Record<string, AnyPort>> = Record<never, never>>(options: {
239
+ readonly inject: D;
240
+ /** The unit-scoped ports every handler in the record reads off `context.unit`. */
241
+ readonly unit?: U;
242
+ readonly sync: (services: { readonly [N in keyof D]: ServiceOf<InstanceType<D[N]>>; }) => WorkerInferHandlers<C, {
243
+ readonly unit: UnitRecordOf<U>;
244
+ }>;
245
+ }) => Provider<HandlersInstanceOf<C>, never, InstanceType<D[keyof D]>> & {
246
+ readonly port: HandlersPortOf<C>;
247
+ /** Phantom: the declared ports, which `AmqpModule` gates `unit.message` against. */
248
+ readonly _declaredUnit?: InstanceType<U[keyof U]>;
249
+ };
126
250
  /**
127
251
  * The composing arm. Declared LAST in the intersection below on purpose:
128
252
  * TypeScript reports the last overload's failure, so a non-covering array is
@@ -134,6 +258,8 @@ type Refuse<T extends readonly unknown[], Marker extends string, Detail> = T ext
134
258
  */
135
259
  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"]>> & {
136
260
  readonly port: HandlersPortOf<C>;
261
+ /** Phantom: the union of every piece's declared ports, which `AmqpModule` gates `unit.message` against. */
262
+ readonly _declaredUnit?: DeclaredOf<T>;
137
263
  };
138
264
  /**
139
265
  * The handlers as a provider, from the contract. Two call forms, one port.
@@ -143,26 +269,33 @@ type Compose<C extends AnyAmqpContract> = <const T extends readonly PieceOf<C>[]
143
269
  * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
144
270
  * ```
145
271
  *
146
- * The first is di's own `Provider(port)` on the starter's handlers port
147
- * typed for the contract. The second takes the pieces `AmqpHandler(contract,
148
- * key)` builds: they are the provider's deps, keyed by the contract key each
149
- * piece's port id carries, so the services record IS the handlers record. Every
150
- * declared key must be covered, and two slices claiming one key are di's
151
- * duplicate-provider defect at build.
152
- */
153
- declare const AmqpHandlers: <C extends AnyAmqpContract>(contract: C) => ReturnType<typeof Provider<HandlersPortOf<C>>> & Compose<C>;
272
+ * The first is `{ inject, unit?, sync }`, whose `sync` hands back the whole
273
+ * handlers record — one `unit:` record for every entry in it. The second takes
274
+ * the pieces `AmqpHandler(contract, key)` builds: they are the provider's deps,
275
+ * keyed by the contract key each piece's port id carries, so the services
276
+ * record IS the handlers record. Every declared key must be covered, and two
277
+ * slices claiming one key are di's duplicate-provider defect at build.
278
+ */
279
+ declare const AmqpHandlers: <C extends AnyAmqpContract>(contract: C) => Whole<C> & Compose<C>;
154
280
  //#endregion
155
281
  //#region src/amqp-module.d.ts
156
282
  /** The starter's own module, as the sugar adds it to the application's imports. */
157
- type AmqpStarter<TContract extends AnyAmqpContract> = Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract>>;
283
+ type AmqpStarter<TContract extends AnyAmqpContract, Unit extends AnyUnitModule | undefined> = Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract> | UnitNeedsOf<Unit>>;
158
284
  /** The application's imports plus the starter — the tuple `Module(name)` is handed. */
159
- type Imports<I extends readonly AnyModule[], TContract extends AnyAmqpContract> = readonly [...I, AmqpStarter<TContract>];
285
+ type Imports<I extends readonly AnyModule[], TContract extends AnyAmqpContract, Unit extends AnyUnitModule | undefined> = readonly [...I, AmqpStarter<TContract, Unit>];
160
286
  /** The handlers provider plus the application's own — the tuple `Module(name)` is handed. */
161
287
  type Provides<P extends readonly AnyProvider[], TContract extends AnyAmqpContract, HandlersError, HandlersNeeds> = readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P];
162
- type AmqpModuleOptions<TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, I extends readonly AnyModule[], P extends readonly AnyProvider[], X extends readonly Exportable<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>>[], N extends readonly AnyPort[]> = AmqpTuning & {
288
+ type AmqpModuleOptions<TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, Declared, Unit extends AnyUnitModule | undefined, I extends readonly AnyModule[], P extends readonly AnyProvider[], X extends readonly Exportable<Imports<I, TContract, Unit>, Provides<P, TContract, HandlersError, HandlersNeeds>>[], N extends readonly AnyPort[]> = AmqpTuning<Unit> & {
163
289
  readonly contract: TContract;
164
- /** The application's handlers — what `AmqpHandlers(contract)(…)` returns for THIS contract. */
165
- readonly handlers: Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>;
290
+ /**
291
+ * The application's handlers — what `AmqpHandlers(contract)(…)` returns for
292
+ * THIS contract. Its `_declaredUnit` phantom carries every port its pieces
293
+ * read off `context.unit`, which is what {@link UnitGate} checks `unit.message`
294
+ * against.
295
+ */
296
+ readonly handlers: Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds> & {
297
+ readonly _declaredUnit?: Declared;
298
+ };
166
299
  readonly imports?: I;
167
300
  readonly provides?: P;
168
301
  /** The application's own exports; `AmqpRuntime` is added, since `start` resolves it. */
@@ -172,7 +305,7 @@ type AmqpModuleOptions<TContract extends AnyAmqpContract, HandlersError, Handler
172
305
  * over the augmented tuples below, so forgetting one is an error at THIS call.
173
306
  */
174
307
  readonly needs?: N;
175
- } & NeedsGate<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>, N>;
308
+ } & NeedsGate<Imports<I, TContract, Unit>, Provides<P, TContract, HandlersError, HandlersNeeds>, N> & UnitGate<Unit, Declared>;
176
309
  /**
177
310
  * `Module(name)({...})` for an AMQP deployment: everything a di module takes,
178
311
  * plus the contract and the handlers provider. The sugar imports the starter,
@@ -189,6 +322,6 @@ type AmqpModuleOptions<TContract extends AnyAmqpContract, HandlersError, Handler
189
322
  * await runMain(OrderAmqpWorker);
190
323
  * ```
191
324
  */
192
- declare const AmqpModule: <const Name extends string>(name: Name) => <TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, const I extends readonly AnyModule[] = [], const P extends readonly AnyProvider[] = [], const X extends readonly Exportable<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>>[] = [], const N extends readonly AnyPort[] = []>(options: AmqpModuleOptions<TContract, HandlersError, HandlersNeeds, I, P, X, N>) => Module<(Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> extends (infer T) ? T extends Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> ? T extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [typeof AmqpRuntime, ...X])[number] extends (infer E_1) ? E_1 extends AnyPort ? InstanceType<E_1> : E_1 extends AnyProvider ? InstanceType<E_1["port"]> : never : never), ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_1) ? T_1 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_1 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[1] | ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_2) ? T_2 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_2 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[1], Exclude<((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]> | Exclude<((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]>>;
325
+ declare const AmqpModule: <const Name extends string>(name: Name) => <TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, Declared = never, Unit extends AnyUnitModule | undefined = undefined, const I extends readonly AnyModule[] = [], const P extends readonly AnyProvider[] = [], const X extends readonly Exportable<Imports<I, TContract, Unit>, Provides<P, TContract, HandlersError, HandlersNeeds>>[] = [], const N extends readonly AnyPort[] = []>(options: AmqpModuleOptions<TContract, HandlersError, HandlersNeeds, Declared, Unit, I, P, X, N>) => Module<(Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> extends (infer T) ? T extends Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> ? T extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [typeof AmqpRuntime, ...X])[number] extends (infer E_1) ? E_1 extends AnyPort ? InstanceType<E_1> : E_1 extends AnyProvider ? InstanceType<E_1["port"]> : never : never), ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_1) ? T_1 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_1 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[1] | ((readonly [...I, AmqpStarter<TContract, Unit>])[number] extends (infer T_2) ? T_2 extends (readonly [...I, AmqpStarter<TContract, Unit>])[number] ? T_2 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[1], Exclude<((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract, Unit>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract, Unit>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]> | Exclude<((readonly [...I, AmqpStarter<TContract, Unit>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract, Unit>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract, Unit>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract, Unit>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]>>;
193
326
  //#endregion
194
- export { AmqpConfig, type AmqpConnectionOptions, AmqpHandler, AmqpHandlers, type AmqpInfo, AmqpModule, type AmqpModuleOptions, type AmqpOptions, AmqpRuntime, type HandlerPortOf, type HandlersInstanceOf, type HandlersPortOf, amqp };
327
+ export { AmqpConfig, type AmqpConnectionOptions, AmqpHandler, AmqpHandlers, type AmqpInfo, AmqpMessage, type AmqpMessageOf, type AmqpMessagePortOf, AmqpModule, type AmqpModuleOptions, type AmqpOptions, AmqpRuntime, type HandlerPortOf, type HandlersInstanceOf, type HandlersPortOf, amqp };
package/dist/index.d.mts CHANGED
@@ -1,10 +1,59 @@
1
- import { AnyModule, AnyPort, AnyProvider, Exportable, Module, NeedsGate, PortClassOf, PortInstance, Provider } from "@btravstack/di";
1
+ import { AnyModule, AnyPort, AnyProvider, Exportable, Module, NeedsGate, PortClassOf, PortInstance, Provider, Scope, ServiceOf } from "@btravstack/di";
2
2
  import { ConsumerOptions, TypedAmqpWorker, WorkerInferHandlers } from "@amqp-contract/worker";
3
3
  import { ConfigInvalid, Env } from "@btravstack/config";
4
4
  import { Runtime, RuntimePort } from "@btravstack/core";
5
+ //#region src/unit.d.ts
6
+ /** What a bound unit module exports — the port instances a handler of that kind may read. */
7
+ type UnitExportsOf<M> = M extends Module<infer X, never, unknown> ? X : never;
8
+ /** The declared `unit:` record, as the services a handler reads off `context.unit`. */
9
+ type UnitRecordOf<U extends Readonly<Record<string, AnyPort>>> = { readonly [N in keyof U]: ServiceOf<InstanceType<U[N]>>; };
10
+ /**
11
+ * A piece injecting a port the bound `unit.message` module does not export.
12
+ * `unknown` when every declared port is covered — including the empty case, so
13
+ * a worker whose pieces declare no `unit:` is gated on nothing.
14
+ */
15
+ type UnitGate<Unit, Declared> = [Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>] extends [never] ? unknown : {
16
+ readonly "UNIT DOES NOT PROVIDE — a piece injects a port the bound unit module does not export": Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>;
17
+ };
18
+ //#endregion
5
19
  //#region src/handler.d.ts
6
20
  /** The consumer and rpc names `C` declares — the keys of its handlers record. */
7
21
  type HandlerKeyOf<C extends AnyAmqpContract> = keyof WorkerInferHandlers<C> & string;
22
+ /**
23
+ * The validated delivery, typed by the contract: the union of every consumer's
24
+ * and rpc's own consumed message. Reached through the handler's own second
25
+ * parameter, since `@amqp-contract/worker` exports
26
+ * `WorkerInferConsumedMessage` but not the `InferConsumerNames` constraint it
27
+ * takes — the same by-index route {@link AnyAmqpContract} travels.
28
+ */
29
+ type AmqpMessageOf<C extends AnyAmqpContract> = Parameters<Extract<WorkerInferHandlers<C>[HandlerKeyOf<C>], (...args: never) => unknown>>[1];
30
+ /** The seeded port's class, typed for `C`: what `AmqpMessage(contract)` answers. */
31
+ type AmqpMessagePortOf<C extends AnyAmqpContract> = PortClassOf<"AmqpMessage", AmqpMessageOf<C>>;
32
+ /**
33
+ * The seed's port instance, as it appears in a needs union — subtracted from
34
+ * what a bound `unit.message` module still owes, since the fork is what
35
+ * discharges it.
36
+ */
37
+ type AmqpMessageInstance = PortInstance<"AmqpMessage", unknown>;
38
+ /**
39
+ * The validated delivery as a port: the one thing the worker seeds the fork
40
+ * with, so a `unit.message` module derives a tenant — or anything else — from
41
+ * the message rather than from an ambient record.
42
+ *
43
+ * ```ts
44
+ * const Message = AmqpMessage(orderContract);
45
+ * const MessageUnit = Module("MessageUnit")({
46
+ * needs: [Message],
47
+ * provides: [Provider(Tenant)({ inject: { message: Message }, sync: ({ message }) => message.payload.tenantId })],
48
+ * exports: [Tenant],
49
+ * });
50
+ * ```
51
+ *
52
+ * `contract` is read for its TYPE only. One `Port(...)` call fixed per contract
53
+ * at the type level, the move `AmqpHandlersPort` makes, so a module built for
54
+ * one contract cannot read another's message.
55
+ */
56
+ declare const AmqpMessage: <C extends AnyAmqpContract>(contract: C) => AmqpMessagePortOf<C>;
8
57
  /**
9
58
  * The port one piece targets. Its id carries the contract key, which is what
10
59
  * makes two slices claiming one consumer di's duplicate-provider defect rather
@@ -13,6 +62,14 @@ type HandlerKeyOf<C extends AnyAmqpContract> = keyof WorkerInferHandlers<C> & st
13
62
  type HandlerPortOf<C extends AnyAmqpContract, K extends HandlerKeyOf<C>> = PortClassOf<`${typeof HANDLER_PREFIX}${K}`, WorkerInferHandlers<C>[K]>;
14
63
  /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
15
64
  declare const HANDLER_PREFIX = "AmqpHandler:";
65
+ /** What a minted piece returns. */
66
+ type MintedHandler<C extends AnyAmqpContract, K extends HandlerKeyOf<C>, N, U extends Readonly<Record<string, AnyPort>>> = Provider<InstanceType<HandlerPortOf<C, K>>, never, N> & {
67
+ readonly port: HandlerPortOf<C, K>;
68
+ /** The declared `unit:` record, which `AmqpHandlers`'s array arm reads back off the piece. */
69
+ readonly unit: U;
70
+ /** Phantom: the ports this piece injects, which the root's `unit.message` must export. */
71
+ readonly _declared?: InstanceType<U[keyof U]>;
72
+ };
16
73
  /**
17
74
  * One consumer or rpc of a contract, as a provider on a port of its own.
18
75
  *
@@ -22,8 +79,21 @@ declare const HANDLER_PREFIX = "AmqpHandler:";
22
79
  * so a consumer the contract does not declare is a compile error here.
23
80
  *
24
81
  * There is no name to give: the contract key IS the port's name.
82
+ *
83
+ * `unit` declares the ports the handler reads off `context.unit`, resolved out
84
+ * of the fork the delivery opened; the root's `unit.message` module must export
85
+ * every one of them.
25
86
  */
26
- declare const AmqpHandler: <C extends AnyAmqpContract, const K extends HandlerKeyOf<C>>(contract: C, key: K) => ReturnType<typeof Provider<HandlerPortOf<C, K>>>;
87
+ declare const AmqpHandler: <C extends AnyAmqpContract, const K extends HandlerKeyOf<C>>(contract: C, key: K) => <const D extends Readonly<Record<string, AnyPort>>, const U extends Readonly<Record<string, AnyPort>> = Record<never, never>>(options: {
88
+ readonly inject: D;
89
+ /** The unit-scoped ports this handler reads off `context.unit`. */
90
+ readonly unit?: U;
91
+ readonly sync: (services: { readonly [N in keyof D]: ServiceOf<InstanceType<D[N]>>; }) => WorkerInferHandlers<C, {
92
+ readonly unit: UnitRecordOf<U>;
93
+ }>[K & keyof WorkerInferHandlers<C, {
94
+ readonly unit: UnitRecordOf<U>;
95
+ }>];
96
+ }) => MintedHandler<C, K, InstanceType<D[keyof D]>, U>;
27
97
  //#endregion
28
98
  //#region src/amqp-runtime.d.ts
29
99
  /** What the worker publishes once it is consuming, read back through `RunningApp.runtimeInfo()`. */
@@ -44,6 +114,22 @@ declare class AmqpConfig extends AmqpConfig_base<{
44
114
  }> {}
45
115
  /** The runtime's port: what `amqp()` provides, and what the module `start` boots must export. */
46
116
  declare class AmqpRuntime extends RuntimePort<Runtime<never, AmqpInfo>> {}
117
+ /**
118
+ * A module `unit.message` may bind, as the upper bound `amqp()` constrains its
119
+ * own `Unit` type parameter to. `Module`'s `_exports` channel is contravariant,
120
+ * so `Exports = never` — never `unknown` — is what makes a REAL module's own
121
+ * (necessarily narrower) export type assignable to this bound: `(x: Concrete)
122
+ * => void` is assignable to `(x: never) => void`, not to `(x: unknown) =>
123
+ * void`.
124
+ */
125
+ type AnyUnitModule = Module<never, never, unknown>;
126
+ /**
127
+ * The needs a bound `unit.message` module still owes, or `never` when none is
128
+ * bound. `Scope` is excluded, since nothing can ever provide it — the same
129
+ * exemption `NeedsGate` itself carries — and so is the delivery, which the
130
+ * fork's own seed discharges.
131
+ */
132
+ type UnitNeedsOf<Unit> = Unit extends Module<never, never, infer N> ? Exclude<N, Scope | AmqpMessageInstance> : never;
47
133
  /**
48
134
  * The contract type `TypedAmqpWorker.create` accepts, extracted rather than
49
135
  * imported by name so `@amqp-contract/contract` stays out of the peer range.
@@ -60,20 +146,20 @@ type AmqpConnectionOptions = NonNullable<Parameters<typeof TypedAmqpWorker.creat
60
146
  type HandlersPortOf<C extends AnyAmqpContract> = PortClassOf<"AmqpHandlers", WorkerInferHandlers<C>>;
61
147
  /** The handlers port's instance for `C` — the module's one need. */
62
148
  type HandlersInstanceOf<C extends AnyAmqpContract> = PortInstance<"AmqpHandlers", WorkerInferHandlers<C>>;
63
- type AmqpOptions<TContract extends AnyAmqpContract> = {
149
+ type AmqpOptions<TContract extends AnyAmqpContract, Unit extends AnyUnitModule | undefined = undefined> = {
64
150
  /**
65
151
  * The contract; the handlers port is typed by it — one handler per
66
152
  * `consumers` / `rpcs` key, with no injected context, since a handler is
67
153
  * built by di from the services it declares.
68
154
  */
69
155
  readonly contract: TContract;
70
- } & AmqpTuning;
156
+ } & AmqpTuning<Unit>;
71
157
  /**
72
158
  * What an AMQP deployment tunes, shared verbatim by `amqp()` and `AmqpModule` —
73
159
  * spelled once so the two cannot drift, which is what a second copy of an
74
160
  * option list always eventually does.
75
161
  */
76
- type AmqpTuning = {
162
+ type AmqpTuning<Unit extends AnyUnitModule | undefined = undefined> = {
77
163
  /** Pins `AmqpConfig.url` instead of reading `AMQP_URL` — a test's container. */
78
164
  readonly url?: string;
79
165
  /** Connection tuning, `@amqp-contract/worker`'s own type: heartbeat, reconnect interval, `findServers`, TLS/socket options. */
@@ -88,6 +174,15 @@ type AmqpTuning = {
88
174
  * `connectionOptions`, where setting it is silently inert.
89
175
  */
90
176
  readonly connectTimeoutMs?: number;
177
+ /**
178
+ * The unit module the worker forks around every delivery it dispatches,
179
+ * seeded with the validated message on `AmqpMessage(contract)`. Built after
180
+ * the message is validated, torn down when the unit closes; what it exports
181
+ * is what a piece may declare on its own `unit:` record.
182
+ */
183
+ readonly unit?: {
184
+ readonly message?: Unit;
185
+ };
91
186
  };
92
187
  /**
93
188
  * The AMQP starter: a module providing the runtime and its configuration (bound
@@ -97,11 +192,17 @@ type AmqpTuning = {
97
192
  *
98
193
  * With `url` pinned the module reads nothing from the environment.
99
194
  */
100
- declare const amqp: <TContract extends AnyAmqpContract>(options: AmqpOptions<TContract>) => Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract>>;
195
+ declare const amqp: <TContract extends AnyAmqpContract, Unit extends AnyUnitModule | undefined = undefined>(options: AmqpOptions<TContract, Unit>) => Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract> | UnitNeedsOf<Unit>>;
101
196
  /** One piece of the handlers record — what `AmqpHandler(contract, key)(…)` returns, as the composing form consumes it. */
102
197
  type PieceOf<C extends AnyAmqpContract> = { readonly [K in HandlerKeyOf<C>]: {
103
198
  readonly port: HandlerPortOf<C, K>;
199
+ /** Phantom, carrying the ports the piece injects off `context.unit`. */
200
+ readonly _declared?: unknown;
104
201
  }; }[HandlerKeyOf<C>];
202
+ /** Every port the pieces in `T` inject off `context.unit` — what the root's `unit.message` must export. */
203
+ type DeclaredOf<T extends readonly {
204
+ readonly _declared?: unknown;
205
+ }[]> = NonNullable<T[number]["_declared"]>;
105
206
  /** The key a piece carries, read back off its port id. */
106
207
  type KeyOfPiece<P> = P extends {
107
208
  readonly port: {
@@ -123,6 +224,29 @@ type Uncovered<C extends AnyAmqpContract, T extends readonly PieceOf<C>[]> = Exc
123
224
  * hand.
124
225
  */
125
226
  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]];
227
+ /**
228
+ * The record arm: the whole handlers record from one `sync`, with one `unit:`
229
+ * record shared by every entry in it.
230
+ *
231
+ * It is `{ inject, unit?, sync }` — di's other arms are gone — for PARITY, not
232
+ * for a type-system reason: `value` could have carried the record just as well,
233
+ * since `U` infers from `unit` and never from the arm. `@btravstack/http-server`'s
234
+ * `api.OrpcRouter(contract)` is `{ inject, unit?, sync }` and so are all three
235
+ * packages' piece factories, so one arm across the family is one surface to
236
+ * learn and one to keep.
237
+ */
238
+ type Whole<C extends AnyAmqpContract> = <const D extends Readonly<Record<string, AnyPort>>, const U extends Readonly<Record<string, AnyPort>> = Record<never, never>>(options: {
239
+ readonly inject: D;
240
+ /** The unit-scoped ports every handler in the record reads off `context.unit`. */
241
+ readonly unit?: U;
242
+ readonly sync: (services: { readonly [N in keyof D]: ServiceOf<InstanceType<D[N]>>; }) => WorkerInferHandlers<C, {
243
+ readonly unit: UnitRecordOf<U>;
244
+ }>;
245
+ }) => Provider<HandlersInstanceOf<C>, never, InstanceType<D[keyof D]>> & {
246
+ readonly port: HandlersPortOf<C>;
247
+ /** Phantom: the declared ports, which `AmqpModule` gates `unit.message` against. */
248
+ readonly _declaredUnit?: InstanceType<U[keyof U]>;
249
+ };
126
250
  /**
127
251
  * The composing arm. Declared LAST in the intersection below on purpose:
128
252
  * TypeScript reports the last overload's failure, so a non-covering array is
@@ -134,6 +258,8 @@ type Refuse<T extends readonly unknown[], Marker extends string, Detail> = T ext
134
258
  */
135
259
  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"]>> & {
136
260
  readonly port: HandlersPortOf<C>;
261
+ /** Phantom: the union of every piece's declared ports, which `AmqpModule` gates `unit.message` against. */
262
+ readonly _declaredUnit?: DeclaredOf<T>;
137
263
  };
138
264
  /**
139
265
  * The handlers as a provider, from the contract. Two call forms, one port.
@@ -143,26 +269,33 @@ type Compose<C extends AnyAmqpContract> = <const T extends readonly PieceOf<C>[]
143
269
  * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
144
270
  * ```
145
271
  *
146
- * The first is di's own `Provider(port)` on the starter's handlers port
147
- * typed for the contract. The second takes the pieces `AmqpHandler(contract,
148
- * key)` builds: they are the provider's deps, keyed by the contract key each
149
- * piece's port id carries, so the services record IS the handlers record. Every
150
- * declared key must be covered, and two slices claiming one key are di's
151
- * duplicate-provider defect at build.
152
- */
153
- declare const AmqpHandlers: <C extends AnyAmqpContract>(contract: C) => ReturnType<typeof Provider<HandlersPortOf<C>>> & Compose<C>;
272
+ * The first is `{ inject, unit?, sync }`, whose `sync` hands back the whole
273
+ * handlers record — one `unit:` record for every entry in it. The second takes
274
+ * the pieces `AmqpHandler(contract, key)` builds: they are the provider's deps,
275
+ * keyed by the contract key each piece's port id carries, so the services
276
+ * record IS the handlers record. Every declared key must be covered, and two
277
+ * slices claiming one key are di's duplicate-provider defect at build.
278
+ */
279
+ declare const AmqpHandlers: <C extends AnyAmqpContract>(contract: C) => Whole<C> & Compose<C>;
154
280
  //#endregion
155
281
  //#region src/amqp-module.d.ts
156
282
  /** The starter's own module, as the sugar adds it to the application's imports. */
157
- type AmqpStarter<TContract extends AnyAmqpContract> = Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract>>;
283
+ type AmqpStarter<TContract extends AnyAmqpContract, Unit extends AnyUnitModule | undefined> = Module<AmqpRuntime | AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf<TContract> | UnitNeedsOf<Unit>>;
158
284
  /** The application's imports plus the starter — the tuple `Module(name)` is handed. */
159
- type Imports<I extends readonly AnyModule[], TContract extends AnyAmqpContract> = readonly [...I, AmqpStarter<TContract>];
285
+ type Imports<I extends readonly AnyModule[], TContract extends AnyAmqpContract, Unit extends AnyUnitModule | undefined> = readonly [...I, AmqpStarter<TContract, Unit>];
160
286
  /** The handlers provider plus the application's own — the tuple `Module(name)` is handed. */
161
287
  type Provides<P extends readonly AnyProvider[], TContract extends AnyAmqpContract, HandlersError, HandlersNeeds> = readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P];
162
- type AmqpModuleOptions<TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, I extends readonly AnyModule[], P extends readonly AnyProvider[], X extends readonly Exportable<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>>[], N extends readonly AnyPort[]> = AmqpTuning & {
288
+ type AmqpModuleOptions<TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, Declared, Unit extends AnyUnitModule | undefined, I extends readonly AnyModule[], P extends readonly AnyProvider[], X extends readonly Exportable<Imports<I, TContract, Unit>, Provides<P, TContract, HandlersError, HandlersNeeds>>[], N extends readonly AnyPort[]> = AmqpTuning<Unit> & {
163
289
  readonly contract: TContract;
164
- /** The application's handlers — what `AmqpHandlers(contract)(…)` returns for THIS contract. */
165
- readonly handlers: Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>;
290
+ /**
291
+ * The application's handlers — what `AmqpHandlers(contract)(…)` returns for
292
+ * THIS contract. Its `_declaredUnit` phantom carries every port its pieces
293
+ * read off `context.unit`, which is what {@link UnitGate} checks `unit.message`
294
+ * against.
295
+ */
296
+ readonly handlers: Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds> & {
297
+ readonly _declaredUnit?: Declared;
298
+ };
166
299
  readonly imports?: I;
167
300
  readonly provides?: P;
168
301
  /** The application's own exports; `AmqpRuntime` is added, since `start` resolves it. */
@@ -172,7 +305,7 @@ type AmqpModuleOptions<TContract extends AnyAmqpContract, HandlersError, Handler
172
305
  * over the augmented tuples below, so forgetting one is an error at THIS call.
173
306
  */
174
307
  readonly needs?: N;
175
- } & NeedsGate<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>, N>;
308
+ } & NeedsGate<Imports<I, TContract, Unit>, Provides<P, TContract, HandlersError, HandlersNeeds>, N> & UnitGate<Unit, Declared>;
176
309
  /**
177
310
  * `Module(name)({...})` for an AMQP deployment: everything a di module takes,
178
311
  * plus the contract and the handlers provider. The sugar imports the starter,
@@ -189,6 +322,6 @@ type AmqpModuleOptions<TContract extends AnyAmqpContract, HandlersError, Handler
189
322
  * await runMain(OrderAmqpWorker);
190
323
  * ```
191
324
  */
192
- declare const AmqpModule: <const Name extends string>(name: Name) => <TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, const I extends readonly AnyModule[] = [], const P extends readonly AnyProvider[] = [], const X extends readonly Exportable<Imports<I, TContract>, Provides<P, TContract, HandlersError, HandlersNeeds>>[] = [], const N extends readonly AnyPort[] = []>(options: AmqpModuleOptions<TContract, HandlersError, HandlersNeeds, I, P, X, N>) => Module<(Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> extends (infer T) ? T extends Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> ? T extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [typeof AmqpRuntime, ...X])[number] extends (infer E_1) ? E_1 extends AnyPort ? InstanceType<E_1> : E_1 extends AnyProvider ? InstanceType<E_1["port"]> : never : never), ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_1) ? T_1 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_1 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[1] | ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_2) ? T_2 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_2 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[1], Exclude<((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]> | Exclude<((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]>>;
325
+ declare const AmqpModule: <const Name extends string>(name: Name) => <TContract extends AnyAmqpContract, HandlersError, HandlersNeeds, Declared = never, Unit extends AnyUnitModule | undefined = undefined, const I extends readonly AnyModule[] = [], const P extends readonly AnyProvider[] = [], const X extends readonly Exportable<Imports<I, TContract, Unit>, Provides<P, TContract, HandlersError, HandlersNeeds>>[] = [], const N extends readonly AnyPort[] = []>(options: AmqpModuleOptions<TContract, HandlersError, HandlersNeeds, Declared, Unit, I, P, X, N>) => Module<(Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> extends (infer T) ? T extends Extract<(readonly [typeof AmqpRuntime, ...X])[number], AnyModule> ? T extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [typeof AmqpRuntime, ...X])[number] extends (infer E_1) ? E_1 extends AnyPort ? InstanceType<E_1> : E_1 extends AnyProvider ? InstanceType<E_1["port"]> : never : never), ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_1) ? T_1 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_1 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[1] | ((readonly [...I, AmqpStarter<TContract, Unit>])[number] extends (infer T_2) ? T_2 extends (readonly [...I, AmqpStarter<TContract, Unit>])[number] ? T_2 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[1], Exclude<((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract, Unit>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract, Unit>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]> | Exclude<((readonly [...I, AmqpStarter<TContract, Unit>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract, Unit>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[2], ((readonly [...I, AmqpStarter<TContract, Unit>])[number] extends (infer T_4) ? T_4 extends (readonly [...I, AmqpStarter<TContract, Unit>])[number] ? T_4 extends Module<infer X_1, infer E, infer N_1> ? readonly [X_1, E, N_1] : never : never : never)[0] | ((readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] extends (infer T_3) ? T_3 extends (readonly [Provider<HandlersInstanceOf<TContract>, HandlersError, HandlersNeeds>, ...P])[number] ? T_3 extends Provider<infer P_1, infer E_2, infer N_2> ? readonly [P_1, E_2, N_2] : never : never : never)[0]>>;
193
326
  //#endregion
194
- export { AmqpConfig, type AmqpConnectionOptions, AmqpHandler, AmqpHandlers, type AmqpInfo, AmqpModule, type AmqpModuleOptions, type AmqpOptions, AmqpRuntime, type HandlerPortOf, type HandlersInstanceOf, type HandlersPortOf, amqp };
327
+ export { AmqpConfig, type AmqpConnectionOptions, AmqpHandler, AmqpHandlers, type AmqpInfo, AmqpMessage, type AmqpMessageOf, type AmqpMessagePortOf, AmqpModule, type AmqpModuleOptions, type AmqpOptions, AmqpRuntime, type HandlerPortOf, type HandlersInstanceOf, type HandlersPortOf, amqp };
package/dist/index.mjs CHANGED
@@ -4,7 +4,73 @@ import { Config, Env } from "@btravstack/config";
4
4
  import { Observers, RuntimePort, RuntimeStartFailed, noObserver, observe, releasedBy, traceIdOfTraceparent } from "@btravstack/core";
5
5
  import { P } from "unthrown";
6
6
  import { randomUUID } from "node:crypto";
7
+ //#region src/unit.ts
8
+ /**
9
+ * Where `messageUnits` leaves the forked context for the piece wrappers to read.
10
+ * A symbol rather than a name: the dispatcher merges it into the context every
11
+ * handler sees, and a string key would appear on the record a handler written
12
+ * against the `{ inject, sync }` arm destructures.
13
+ */
14
+ const UNIT_SCOPE = Symbol("@btravstack/amqp-worker/unit-scope");
15
+ /**
16
+ * The declared record, as a getter per name resolved on read out of the fork.
17
+ * Neither writable nor configurable — a handler reads what the fork holds, and
18
+ * cannot reshape the record under the next delivery. With no `unit.message`
19
+ * bound there is nothing to resolve from, so the record is empty whatever was
20
+ * declared.
21
+ */
22
+ const unitRecordOf = (forked, record) => {
23
+ const unit = {};
24
+ if (forked === void 0) return unit;
25
+ for (const [name, port] of Object.entries(record)) Object.defineProperty(unit, name, {
26
+ enumerable: true,
27
+ get: () => forked.get(port)
28
+ });
29
+ return unit;
30
+ };
31
+ /**
32
+ * One handler entry — a function, or `[handler, options]` — with its piece's
33
+ * declared record put on `context.unit`. Applied once per piece as di
34
+ * constructs it, so a delivery costs one record and one context object.
35
+ */
36
+ const withUnit = (record, entry) => {
37
+ const wrap = (handler) => (helpers, message) => {
38
+ const { [UNIT_SCOPE]: forked, ...rest } = helpers.context ?? {};
39
+ return handler({
40
+ ...helpers,
41
+ context: {
42
+ ...rest,
43
+ unit: unitRecordOf(forked, record)
44
+ }
45
+ }, message);
46
+ };
47
+ return Array.isArray(entry) ? [wrap(entry[0]), entry[1]] : wrap(entry);
48
+ };
49
+ //#endregion
7
50
  //#region src/handler.ts
51
+ /** One id, declared once, so no contract instantiating it warns about a duplicate. */
52
+ const AmqpMessagePort = Port("AmqpMessage");
53
+ /**
54
+ * The validated delivery as a port: the one thing the worker seeds the fork
55
+ * with, so a `unit.message` module derives a tenant — or anything else — from
56
+ * the message rather than from an ambient record.
57
+ *
58
+ * ```ts
59
+ * const Message = AmqpMessage(orderContract);
60
+ * const MessageUnit = Module("MessageUnit")({
61
+ * needs: [Message],
62
+ * provides: [Provider(Tenant)({ inject: { message: Message }, sync: ({ message }) => message.payload.tenantId })],
63
+ * exports: [Tenant],
64
+ * });
65
+ * ```
66
+ *
67
+ * `contract` is read for its TYPE only. One `Port(...)` call fixed per contract
68
+ * at the type level, the move `AmqpHandlersPort` makes, so a module built for
69
+ * one contract cannot read another's message.
70
+ */
71
+ const AmqpMessage = (contract) => {
72
+ return AmqpMessagePort;
73
+ };
8
74
  /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
9
75
  const HANDLER_PREFIX = "AmqpHandler:";
10
76
  /**
@@ -16,30 +82,49 @@ const HANDLER_PREFIX = "AmqpHandler:";
16
82
  * so a consumer the contract does not declare is a compile error here.
17
83
  *
18
84
  * There is no name to give: the contract key IS the port's name.
85
+ *
86
+ * `unit` declares the ports the handler reads off `context.unit`, resolved out
87
+ * of the fork the delivery opened; the root's `unit.message` module must export
88
+ * every one of them.
19
89
  */
20
90
  const AmqpHandler = (contract, key) => {
21
91
  const port = class extends Port(`${HANDLER_PREFIX}${key}`) {};
22
- return Provider(port);
92
+ return (options) => {
93
+ const record = options.unit ?? {};
94
+ return Object.assign(Provider(port)({
95
+ inject: options.inject,
96
+ sync: (services) => withUnit(record, options.sync(services))
97
+ }), { unit: record });
98
+ };
23
99
  };
24
100
  //#endregion
25
101
  //#region src/message-units.ts
26
102
  /**
27
- * Open one kernel unit per delivery. It injects nothing — `next()` unchanged is
28
- * the whole of the chain — so the ambient `currentUnit()` record is what the
29
- * unit leaves for the adapters that read it.
103
+ * Open one kernel unit per delivery, forking `unit` — when one is bound — after
104
+ * the message is validated, before the handler runs; the fork is torn down
105
+ * when the unit closes. It is seeded with the validated delivery on
106
+ * `AmqpMessage(contract)`, so a unit module derives a tenant from the message
107
+ * rather than reading one off an ambient record. With no `unit` bound, `next()`
108
+ * runs unchanged, so the ambient `currentUnit()` record is what the unit leaves
109
+ * for the adapters that read it.
110
+ *
111
+ * The forked context rides the dispatcher's context under {@link UNIT_SCOPE},
112
+ * where each piece's own wrapper turns it into the typed `context.unit` record
113
+ * that piece declared — the records live with the pieces, so a hand-composed
114
+ * `amqp()` gets them without threading a second option through the starter.
30
115
  *
31
116
  * **The kernel's per-unit `AbortSignal` rides that record too**, and it is the
32
117
  * only route to it here: a handler has no parameter to receive one through. A
33
118
  * handler that must stop when the kernel stops waiting reads
34
119
  * `currentUnit()?.signal`.
35
120
  */
36
- const messageUnits = (host, observers) => (args, next) => {
121
+ const messageUnits = (host, observers, unit) => (args, next) => {
37
122
  const settle = observe(observers, {
38
123
  component: "amqp",
39
124
  name: "delivery",
40
125
  attributes: { handler: args.handlerName }
41
126
  });
42
- return host.run(metaFor(args.rawMessage), () => next()).tap(() => settle({ outcome: "ok" })).tapFailure((failure) => settle({
127
+ return host.run(metaFor(args.rawMessage), (scope) => unit === void 0 ? next() : scope.fork(unit, [[AmqpMessagePort, args.message]]).flatMap((forked) => next({ context: { [UNIT_SCOPE]: forked } }))).tap(() => settle({ outcome: "ok" })).tapFailure((failure) => settle({
43
128
  outcome: "error",
44
129
  cause: failure.tag === "Err" ? failure.error : failure.cause
45
130
  }));
@@ -143,12 +228,12 @@ const amqp = (options) => {
143
228
  * AmqpHandlers(orderContract)([orderNotifications, orderAudit])
144
229
  * ```
145
230
  *
146
- * The first is di's own `Provider(port)` on the starter's handlers port
147
- * typed for the contract. The second takes the pieces `AmqpHandler(contract,
148
- * key)` builds: they are the provider's deps, keyed by the contract key each
149
- * piece's port id carries, so the services record IS the handlers record. Every
150
- * declared key must be covered, and two slices claiming one key are di's
151
- * duplicate-provider defect at build.
231
+ * The first is `{ inject, unit?, sync }`, whose `sync` hands back the whole
232
+ * handlers record — one `unit:` record for every entry in it. The second takes
233
+ * the pieces `AmqpHandler(contract, key)` builds: they are the provider's deps,
234
+ * keyed by the contract key each piece's port id carries, so the services
235
+ * record IS the handlers record. Every declared key must be covered, and two
236
+ * slices claiming one key are di's duplicate-provider defect at build.
152
237
  */
153
238
  const AmqpHandlers = (contract) => {
154
239
  const build = Provider(AmqpHandlersPort);
@@ -156,7 +241,14 @@ const AmqpHandlers = (contract) => {
156
241
  inject: Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(12), piece.port])),
157
242
  sync: (services) => services
158
243
  });
159
- return ((first) => Array.isArray(first) ? compose(first) : build(first));
244
+ const whole = (options) => {
245
+ const record = options.unit ?? {};
246
+ return build({
247
+ inject: options.inject,
248
+ sync: (services) => Object.fromEntries(Object.entries(options.sync(services)).map(([key, entry]) => [key, withUnit(record, entry)]))
249
+ });
250
+ };
251
+ return ((first) => Array.isArray(first) ? compose(first) : whole(first));
160
252
  };
161
253
  const startFailed = (cause) => new RuntimeStartFailed({
162
254
  runtime: "amqp",
@@ -174,7 +266,7 @@ const queuesOf = (contract) => [...new Set(Object.values({
174
266
  const createWorker = (host, config, options, handlers, observers) => TypedAmqpWorker.create({
175
267
  contract: options.contract,
176
268
  handlers,
177
- middleware: messageUnits(host, observers),
269
+ middleware: messageUnits(host, observers, options.unit?.message),
178
270
  urls: [config.url],
179
271
  ...options.connectionOptions === void 0 ? {} : { connectionOptions: options.connectionOptions },
180
272
  ...options.defaultConsumerOptions === void 0 ? {} : { defaultConsumerOptions: options.defaultConsumerOptions },
@@ -226,4 +318,4 @@ const AmqpModule = (name) => (options) => {
226
318
  });
227
319
  };
228
320
  //#endregion
229
- export { AmqpConfig, AmqpHandler, AmqpHandlers, AmqpModule, AmqpRuntime, amqp };
321
+ export { AmqpConfig, AmqpHandler, AmqpHandlers, AmqpMessage, AmqpModule, AmqpRuntime, amqp };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/amqp-worker",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",
@@ -47,7 +47,7 @@
47
47
  "@amqp-contract/contract": "3.0.0-beta.7",
48
48
  "@amqp-contract/testing": "3.0.0-beta.7",
49
49
  "@amqp-contract/worker": "3.0.0-beta.7",
50
- "@btravstack/tsconfig": "0.2.0",
50
+ "@btravstack/tsconfig": "0.4.0",
51
51
  "@opentelemetry/api": "1.9.1",
52
52
  "@types/node": "26.4.0",
53
53
  "@unthrown/vitest": "5.7.0",
@@ -57,19 +57,19 @@
57
57
  "unthrown": "5.7.0",
58
58
  "vitest": "4.1.11",
59
59
  "zod": "4.5.3",
60
- "@btravstack/config": "0.12.0",
61
- "@btravstack/core": "0.12.0",
62
- "@btravstack/di": "0.12.0",
60
+ "@btravstack/config": "0.13.0",
61
+ "@btravstack/core": "0.13.0",
62
+ "@btravstack/di": "0.13.0",
63
63
  "@btravstack/internal-test-infra": "0.0.0",
64
- "@btravstack/testing": "0.12.0"
64
+ "@btravstack/testing": "0.13.0"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "@amqp-contract/worker": "^3.0.0-beta.7",
68
68
  "@opentelemetry/api": "^1",
69
69
  "unthrown": "^5.0.0",
70
- "@btravstack/config": "^0.12.0",
71
- "@btravstack/core": "^0.12.0",
72
- "@btravstack/di": "^0.12.0"
70
+ "@btravstack/config": "^0.13.0",
71
+ "@btravstack/core": "^0.13.0",
72
+ "@btravstack/di": "^0.13.0"
73
73
  },
74
74
  "engines": {
75
75
  "node": ">=22"