Skip to content

Commit 2904d5d

Browse files
committed
docs: sync docs, READMEs, and agent rules with the 3.0 API
- fix non-compiling .getOrThrow() on E=never in the quick starts and the stale unthrown-4 recover() comment - add the missing MessageValidationError arm to the request-reply tutorial's errCases snippets and prose - document worker close() draining (drainTimeoutMs), middleware arrays, standalone topology in defineContract (+ setupAmqpTopology escape hatch), the { data, message? } RPC error shape, curated ConsumerOptions, strict connectTimeoutMs, AsyncAPI 3.1 and the failOnMissingConverter default flip, and the new exports - AGENTS.md: fix the invariant-5 test pointer and extend the load-bearing invariant map to 16 entries covering the new guards
1 parent 7f406c6 commit 2904d5d

19 files changed

Lines changed: 173 additions & 75 deletions

.agents/rules/handlers.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ All handlers (consumer and RPC) receive a third `helpers` argument — `{ contex
3838

3939
When the RPC declares an `errors` map (`defineRpc(queue, { request, response, errors })`), the handler may also return `Err(rpcError(code, data))` for a declared code — the worker validates `data` against the declared schema, publishes an error reply (marked by the `RPC_ERROR_CODE_HEADER` header), and **acks the request**; typed business errors never enter the retry/DLQ pipeline. Undeclared codes or invalid error data are contract violations routed to the DLQ. See `packages/worker/src/worker.ts` (`publishRpcErrorReply`) and [docs/guide/error-model.md](../../docs/guide/error-model.md#typed-rpc-errors-rpcerror).
4040

41-
You can define RPC handlers either with `defineHandler` / `defineHandlers` (overloaded against `InferRpcNames<TContract>`, and `validateHandlerTargetExists` checks both `contract.consumers` and `contract.rpcs`) or inline inside `TypedAmqpWorker.create({ handlers: { … } })`. The inline `handlers` parameter is typed against `WorkerInferHandlers<TContract>`, so each name (consumer or RPC) gets the correct signature inferred:
41+
You can define RPC handlers either with `declareHandler` / `declareHandlers` (overloaded against `InferRpcNames<TContract>`, and `validateHandlerTargetExists` checks both `contract.consumers` and `contract.rpcs`) or inline inside `TypedAmqpWorker.create({ handlers: { … } })`. The inline `handlers` parameter is typed against `WorkerInferHandlers<TContract>`, so each name (consumer or RPC) gets the correct signature inferred:
4242

4343
```typescript
4444
import { fromPromise, OkAsync } from "unthrown";
@@ -91,23 +91,23 @@ RPC error semantics worth knowing:
9191
- **Client-side timeout** → call resolves to `Err(RpcTimeoutError)`; pending state is cleared. If a reply still arrives, it's logged at `warn` and counted via `recordLateRpcReply` (telemetry hook for tuning) — it's not retried.
9292
- **Client closed mid-call** → call resolves to `Err(RpcCancelledError)`.
9393

94-
## Using `defineHandler` / `defineHandlers`
94+
## Using `declareHandler` / `declareHandlers`
9595

96-
Use `defineHandler` (single) or `defineHandlers` (object) for full type inference and a runtime check that the name exists in the contract. Both helpers accept consumer **and** RPC names — they're overloaded against `InferConsumerNames` and `InferRpcNames`, and `validateHandlerTargetExists` inspects both `contract.consumers` and `contract.rpcs` (an unknown name throws _"Handler target X not found in contract"_).
96+
Use `declareHandler` (single) or `declareHandlers` (object) for full type inference and a runtime check that the name exists in the contract. Both helpers accept consumer **and** RPC names — they're overloaded against `InferConsumerNames` and `InferRpcNames`, and `validateHandlerTargetExists` inspects both `contract.consumers` and `contract.rpcs` (an unknown name throws _"Handler target X not found in contract"_).
9797

9898
```typescript
99-
import { defineHandler, RetryableError, NonRetryableError } from "@amqp-contract/worker";
99+
import { declareHandler, RetryableError, NonRetryableError } from "@amqp-contract/worker";
100100
import { ErrAsync, fromPromise, OkAsync } from "unthrown";
101101

102-
const processOrderHandler = defineHandler(contract, "processOrder", ({ payload }) =>
102+
const processOrderHandler = declareHandler(contract, "processOrder", ({ payload }) =>
103103
fromPromise(
104104
processPayment(payload.orderId),
105105
(error) => new RetryableError("Payment service unavailable", error),
106106
).map(() => undefined),
107107
);
108108

109109
// Permanent failures use NonRetryableError → DLQ, never retried
110-
const validateOrderHandler = defineHandler(contract, "validateOrder", ({ payload }) => {
110+
const validateOrderHandler = declareHandler(contract, "validateOrder", ({ payload }) => {
111111
if (payload.amount < 1) {
112112
return ErrAsync(new NonRetryableError("Invalid amount"));
113113
}
@@ -201,5 +201,5 @@ For the authoritative list, read [`packages/worker/src/index.ts`](../../packages
201201

202202
- Classes: `TypedAmqpWorker`, `RetryableError`, `NonRetryableError`, `MessageValidationError` (the error classes are unthrown `TaggedError`s). `HandlerError` is a **type** (`RetryableError | NonRetryableError`), not a class.
203203
- Factories / guards: `retryable`, `nonRetryable`, `isRetryableError`, `isNonRetryableError`, `isHandlerError`
204-
- Helpers: `defineHandler`, `defineHandlers` (both accept consumer **and** RPC names)
204+
- Helpers: `declareHandler`, `declareHandlers` (both accept consumer **and** RPC names)
205205
- Types: `CreateWorkerOptions`, `ConsumerOptions`, `WorkerConsumedMessage`, `WorkerInferConsumedMessage`, `WorkerInferConsumerHandler`, `WorkerInferConsumerHandlerEntry`, `WorkerInferConsumerHeaders`, `WorkerInferHandlers` (consumers ∪ rpcs), `WorkerInferRpcConsumedMessage`, `WorkerInferRpcHandler`, `WorkerInferRpcHandlerEntry`, `WorkerInferRpcHeaders`, `WorkerInferRpcRequest`, `WorkerInferRpcResponse`

.agents/rules/recipes.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ End-to-end how-tos for the changes that come up most. Each recipe lists the exac
88
2. **Queue**`defineQueue(...)` with a `deadLetter` and a `retry` mode (immediate-requeue or ttl-backoff). Quorum by default; classic only if you need priority/exclusive/auto-delete.
99
3. **Consumer entry**`defineEventConsumer(eventPublisher, queue, { routingKey: ... })`. The queue↔exchange binding is auto-generated.
1010
4. **Add to `defineContract`** under `consumers: { ... }`. Don't add the queue or binding yourself — they're auto-extracted.
11-
5. **Handler** — implement with `defineHandler(contract, "yourConsumerName", ({ payload, headers }) => …)` returning `AsyncResult<void, HandlerError>`. See [handlers.md](./handlers.md).
11+
5. **Handler** — implement with `declareHandler(contract, "yourConsumerName", ({ payload, headers }) => …)` returning `AsyncResult<void, HandlerError>`. See [handlers.md](./handlers.md).
1212
6. **Tests** — integration test in `src/__tests__/<consumer>.spec.ts` using `it` from `@amqp-contract/testing/extension`. Mock the handler with `vi.fn().mockReturnValue(OkAsync(undefined))`.
1313
7. **Changeset**`pnpm changeset` with a minor bump. Public API surface grew.
1414

1515
## Add a new RPC
1616

1717
1. **Schemas** — request and response, both via `defineMessage`.
1818
2. **Queue**`defineQueue(...)` for the RPC. Quorum by default. **Configure a `deadLetter`** even though replies, not the queue, drive most failure modes: missing `replyTo` / `correlationId` and response-schema mismatches are surfaced as `NonRetryableError` and the worker `nack`s them without requeue, so without a DLX they're dropped silently.
19-
3. **RPC entry**`defineRpc(queue, { request, response })`.
19+
3. **RPC entry**`defineRpc(queue, { request, response })`. Typed business errors go in an optional `errors` map whose entries are `{ data: schema, message?: string }` (the raw Standard Schema, NOT `defineMessage`); the optional `message` is the default human message when the handler constructs the error without one.
2020
4. **Add to `defineContract`** under `rpcs: { ... }`.
21-
5. **Server-side handler** — define it with `defineHandler(contract, "yourRpcName", ({ payload }) => OkAsync({ /* response */ }))`, via `defineHandlers`, or inline in the `handlers` object passed to `TypedAmqpWorker.create({ handlers: { … } })`. All three are RPC-aware: `defineHandler` / `defineHandlers` are overloaded against `InferRpcNames` and validate the name against both `contract.consumers` and `contract.rpcs`. The worker validates the response against the response schema and publishes back automatically.
21+
5. **Server-side handler** — define it with `declareHandler(contract, "yourRpcName", ({ payload }) => OkAsync({ /* response */ }))`, via `declareHandlers`, or inline in the `handlers` object passed to `TypedAmqpWorker.create({ handlers: { … } })`. All three are RPC-aware: `declareHandler` / `declareHandlers` are overloaded against `InferRpcNames` and validate the name against both `contract.consumers` and `contract.rpcs`. The worker validates the response against the response schema and publishes back automatically.
2222
6. **Client call**`client.call("yourRpcName", request, { timeoutMs: 5_000 })`. `timeoutMs` is required.
2323
7. **Tests** — round-trip integration test (worker + client both wired up). For "no server" scenarios, just create the client without a worker; for "request validation fails", pass a deliberately wrong payload through `as unknown as ...`.
2424
8. **Changeset** — minor bump.

.agents/rules/testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ describe("Order Processing", () => {
4848

4949
publishMessage("orders-exchange", "order.created", { orderId: "123" });
5050

51-
const messages = await waitForMessages({ nbEvents: 1, timeout: 5000 });
51+
const messages = await waitForMessages({ count: 1, timeoutMs: 5000 });
5252
expect(messages).toHaveLength(1);
5353
expect(messages[0]).toMatchObject({ orderId: "123" });
5454
});

AGENTS.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,18 @@ Each invariant maps to a named guarding test — extend the mapping when you add
9090
2. **NonRetryableError → exactly one `nack(requeue=false)`** (DLQ, never republished/acked) — `packages/worker/src/invariants.spec.ts`.
9191
3. **Retryable without retry config → DLQ, not infinite requeue**`packages/worker/src/invariants.spec.ts`.
9292
4. **Immediate-requeue honors the retry budget** (requeue below, DLQ at) — `packages/worker/src/invariants.spec.ts`.
93-
5. **Validation failures bypass the retry pipeline** (deterministic poison → DLQ) — `packages/worker/src/__tests__/worker-retry.spec.ts`.
93+
5. **Validation failures bypass the retry pipeline** (deterministic poison → DLQ on first delivery, zero retry publishes) — `packages/worker/src/__tests__/worker-retry.spec.ts` ("an invalid payload on a retry-configured queue goes straight to the DLQ with zero retries").
9494
6. **A message is acked/nacked exactly once**`packages/worker/src/__tests__/worker-double-ack.spec.ts`.
9595
7. **Middleware short-circuit skips the handler; its result routes like a handler result**`packages/worker/src/middleware.spec.ts` + `tests/src/middleware.spec.ts`.
9696
8. **Middleware-substituted payloads are re-validated before the handler**`packages/worker/src/middleware.spec.ts` ("threads substituted payloads…") + `tests/src/middleware.spec.ts` ("blocks handler execution when the substitution fails the schema").
97-
9. **RPC replies require `replyTo` + `correlationId`; undeclared error codes/invalid error data → DLQ, never a malformed reply**`tests/src/rpc.spec.ts` (undeclared-code and invalid-error-data timeout tests).
98-
10. **Worker creation fails fast on missing handlers, before any connection is acquired**`packages/worker/src/worker-cleanup.spec.ts`.
97+
9. **RPC replies require `replyTo` + `correlationId`; undeclared error codes/invalid error data → DLQ, never a malformed reply**`tests/src/rpc.spec.ts` (undeclared-code/invalid-error-data timeout tests + "RPC DLQ routing" describe: DLQ delivery for undeclared codes and reply-less requests).
98+
10. **Worker creation fails fast on missing or non-callable handlers, before any connection is acquired**`packages/worker/src/worker-cleanup.spec.ts`.
9999
11. **`createContext` failure routes to DLQ; the handler never runs**`tests/src/middleware.spec.ts` ("routes a throwing createContext…").
100+
12. **Compressed and plain payloads survive the wire byte-for-byte** (the channel must never JSON-serialize a Buffer) — `tests/src/compression.spec.ts`.
101+
13. **`worker.close()` drains in-flight handlers before closing the channel** (their acks land; no redelivery of completed work) — `packages/worker/src/__tests__/worker-close-drain.spec.ts`.
102+
14. **A classic-queue immediate-requeue retry republishes to THIS queue via the default exchange** (never the original exchange — sibling queues must not see duplicates) — `packages/worker/src/invariants.spec.ts`.
103+
15. **Connection-pool leases are idempotent and close/create-race-safe** (double release never underflows; an acquire racing the last release gets a fresh connection) — `packages/core/src/connection-manager.spec.ts`.
104+
16. **Telemetry never throws into the data path** (a buggy provider degrades to "no telemetry") — `packages/core/src/telemetry.spec.ts` ("throwing telemetry providers") + `packages/client/src/interceptors.spec.ts` (sync-throwing interceptor → Defect).
100105

101106
## Workflow rules for agents
102107

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,18 @@ const worker = await TypedAmqpWorker.create({
8282
},
8383
},
8484
urls: ["amqp://localhost"],
85-
}).getOrThrow();
85+
}).get();
8686

8787
// 6. Type-safe publishing with validation
8888
const client = await TypedAmqpClient.create({
8989
contract,
9090
urls: ["amqp://localhost"],
91-
}).getOrThrow();
91+
}).get();
9292

9393
// publish() returns an AsyncResult instead of throwing — awaiting it yields a
94-
// Result. unthrown 4 gates get() to infallible results, so clear the error
95-
// channel with recover() first (or use .match()). See the error model guide.
94+
// Result. create()/close() have an empty error channel (E = never), so .get()
95+
// is correct there; publish() still has a modeled error, so extract it with
96+
// .getOrThrow() (or handle it with .match()). See the error model guide.
9697
await client
9798
.publish("orderCreated", {
9899
orderId: "ORD-123", // ✅ TypeScript knows!
@@ -101,8 +102,8 @@ await client
101102
.getOrThrow();
102103

103104
// 7. Clean up
104-
await client.close().getOrThrow();
105-
await worker.close().getOrThrow();
105+
await client.close().get();
106+
await worker.close().get();
106107
```
107108

108109
▶ For the full runnable version (including the RabbitMQ Docker command), follow the [5-minute quick start](https://btravstack.github.io/amqp-contract/guide/getting-started).
@@ -138,7 +139,7 @@ docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:4-management
138139
| [@amqp-contract/contract](./packages/contract) | Contract builder and type definitions |
139140
| [@amqp-contract/client](./packages/client) | Type-safe client for publishing |
140141
| [@amqp-contract/worker](./packages/worker) | Type-safe worker with retry support |
141-
| [@amqp-contract/asyncapi](./packages/asyncapi) | AsyncAPI 3.0 generator |
142+
| [@amqp-contract/asyncapi](./packages/asyncapi) | AsyncAPI 3.1 generator |
142143

143144
## Contributing
144145

docs/examples/command-pattern.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,14 @@ Only one place implements the command. The worker runs as part of the `payment-s
9393
// payment-service/src/worker.ts
9494
import {
9595
TypedAmqpWorker,
96-
defineHandler,
96+
declareHandler,
9797
RetryableError,
9898
NonRetryableError,
9999
} from "@amqp-contract/worker";
100100
import { fromPromise, type AsyncResult, type Result } from "unthrown";
101101
import { contract } from "@org/payment-contract";
102102

103-
const chargeHandler = defineHandler(contract, "chargeCustomer", ({ payload }) =>
103+
const chargeHandler = declareHandler(contract, "chargeCustomer", ({ payload }) =>
104104
fromPromise(
105105
chargeProvider({
106106
customerId: payload.customerId,

docs/how-to/add-middleware.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ A middleware proves something once and passes the result downstream. Handlers re
1919
```typescript
2020
import {
2121
composeMiddleware,
22-
defineMiddleware,
22+
declareMiddleware,
2323
nonRetryable,
2424
TypedAmqpWorker,
2525
type EmptyContext,
2626
} from "@amqp-contract/worker";
2727
import { ErrAsync } from "unthrown";
2828

29-
const auth = defineMiddleware<EmptyContext, { tenantId: string }>((args, next) => {
29+
const auth = declareMiddleware<EmptyContext, { tenantId: string }>((args, next) => {
3030
const tenantId = args.rawMessage.properties.headers?.["x-tenant-id"];
3131
if (typeof tenantId !== "string") {
3232
// Short-circuit: routes like any handler error. The handler never runs.
@@ -46,12 +46,12 @@ const worker = await TypedAmqpWorker.create({
4646
}).get();
4747
```
4848

49-
The two type parameters on `defineMiddleware` are the context going in and the context coming out. Without any middleware or `createContext`, handlers get `EmptyContext`.
49+
The two type parameters on `declareMiddleware` are the context going in and the context coming out. Without any middleware or `createContext`, handlers get `EmptyContext`.
5050

5151
## Chain several middleware
5252

5353
```typescript
54-
const timing = defineMiddleware<{ tenantId: string }, { tenantId: string }>((args, next) => {
54+
const timing = declareMiddleware<{ tenantId: string }, { tenantId: string }>((args, next) => {
5555
const start = Date.now();
5656
return next().tap(() => {
5757
console.log(`${args.handlerName} (${args.context.tenantId}): ${Date.now() - start}ms`);
@@ -63,6 +63,8 @@ middleware: composeMiddleware(auth, timing),
6363

6464
`composeMiddleware(outermost, …, innermost)` runs left to right. Context types accumulate along the chain, so `timing` sees the `tenantId` that `auth` added, and handlers see the final accumulated type.
6565

66+
The `middleware` option also accepts an array directly — `middleware: [auth, timing]` — with the first entry outermost, mirroring the client's interceptor arrays. At runtime it composes exactly like `composeMiddleware(...)`, but the array form does not thread the stepwise context types across entries: when middleware accumulate typed context for the handlers, pre-compose with `composeMiddleware` so the chain's final context type is inferred into `helpers.context`.
67+
6668
Because `next()` returns the handler's `AsyncResult`, a middleware can post-process it with `.tap`, `.mapErr` or `.flatMapErr`.
6769

6870
## Inject dependencies per message
@@ -97,7 +99,7 @@ For a dependency-injection graph, [demesne](https://btravstack.github.io/demesne
9799
`next({ payload })` replaces the payload for everything downstream.
98100

99101
```typescript
100-
const normalize = defineMiddleware<EmptyContext, EmptyContext>((args, next) =>
102+
const normalize = declareMiddleware<EmptyContext, EmptyContext>((args, next) =>
101103
next({ payload: { ...args.message.payload, email: args.message.payload.email.toLowerCase() } }),
102104
);
103105
```

0 commit comments

Comments
 (0)