Skip to content

feat(start-amqp): the AMQP consumer runtime - #18

Merged
btravers merged 20 commits into
mainfrom
feat/start-amqp
Aug 13, 2026
Merged

feat(start-amqp): the AMQP consumer runtime#18
btravers merged 20 commits into
mainfrom
feat/start-amqp

Conversation

@btravers

Copy link
Copy Markdown
Contributor

The last deferred runtime package, and the fourth deployment of the order example consuming through it. Deferred, deliberately loses its first entry.

Why this package exists

TypedAmqpWorker.close() is the whole of an AMQP worker's shutdown — cancel every consumer, drain in-flight handlers so their acks land on a still-open channel, then close — and its drain budget is a parameter, not a constructor setting. So this runtime does something -temporal cannot: it passes drainTimeoutMs: null and races the kernel's own AbortSignal against it.

The kernel's drainTimeoutMs is the only deadline in the process. -temporal's README has to warn you to keep forceAfter at or below it and raise them together; this package has no such warning to give, because there is no second number.

The integration, in full

start(OrderAmqpModule, {
  runtime: amqpRuntime({
    urls: [env.AMQP_URL],
    contract: orderContract,
    needs: [PlaceOrder, Logger],
    handlers: (host) => ({ placeOrder: placeHandler(orderContract) }),
    middleware: (host) => messageUnits<AmqpNeeds>(host),
  }),
});

One line — the middleware — is what an amqp-contract user adds. @amqp-contract/contract stays out of the published type surface: MessageMiddleware is declared structurally in our own source.

Four things the wave learned that the spec had wrong

Each was found by an implementer or reviewer reading the installed library rather than trusting the plan, and each is now in the docs:

  1. @amqp-contract/worker had to become a peer. A value import of TypedAmqpWorker was being inlined — 344 KB of dist and a second private copy of amqplib in any app that also uses amqp-contract, which every consumer of this package does by construction. Now ~5 KB. The spec's "devDependency, never a peer" was a bad analogy from -temporal, which only borrows a type from its contract library.
  2. A Defect is NOT retried by the library. dispatchMessage nacks it once with requeue: false — straight to the DLQ, never touching the queue's retry config. So an infrastructure failure would be parked on its first attempt exactly like a permanent domain error unless the handler recovers it into a RetryableError. Two agents confirmed this independently from the dispatch source. Every consumer would have hit it; CLAUDE.md's Thesis fix: audit dropped Results, and document two runtime contracts #3 now records the three-way split — the library's, the handler's, and never the kernel's.
  3. "The broker redelivers an abandoned delivery" was an overclaim. At the kernel's deadline nothing is dropped — close() holds the connection open — so nothing is redelivered. That only happens if the process actually dies, which no in-process test can observe. The test that claimed it in its title now claims what it proves.
  4. maxRetries: 3 means four total attempts, where Temporal's maximumAttempts: 3 means three.

What shipped

  • packages/start-amqpamqpRuntime, messageUnits, AmqpInfo (queues derived from the contract, so Serving.info cannot disagree with what the worker consumes), the deadline race, 8 specs at 100% lines/functions.
  • examples/order-amqp-contract — exchange, queue with a dead-letter exchange and ttl-backoff retry, publisher, consumer, layering.test-d.ts.
  • examples/order-amqp — the fourth deployment: the same ApplicationModule, the mapErrCases triage into NonRetryableError/RetryableError, 9 specs against a real broker.
  • Package README + CLAUDE.md, root CLAUDE.md, examples/README.md (eleven workspaces), and a changeset.

Testing needs Docker, and that is now the rule

@amqp-contract/testing is testcontainers-based; there is no in-process AMQP broker the way Temporal ships a time-skipping binary, and a hand-written channel double would test our own fake rather than the drain. 1c2e724 replaced the repo's no-Docker ban with a positive rule — reach for the cheapest fixture that tests the real behaviour — and rewrote the three sentences that cited the ban as their reason.

Unverified: whether CI's runner has a Docker socket. .github/workflows/ci.yml delegates wholly to btravstack/config's reusable workflow, which cannot be read or modified from here. GitHub-hosted Ubuntu runners ship a daemon, so this should work with no workflow change — if it does not, the fix is an input added upstream, not here. Watch this PR's first CI run.

Follow-up, deliberately not in this PR

packages/start-amqp/tsconfig.json does not exclude src/**/*.test-d.ts, where packages/start and examples/order-amqp both do. Harmless today — the file compiles clean under the stricter config — but a future type test with an intentionally-unused parameter would fail the main tsc run for the reason the relaxed config exists to avoid. One line, surfaced rather than folded into a second fix wave.

Process

Eight tasks, each implemented by a fresh subagent and reviewed by another; three plan defects were caught mid-flight and corrected, and a final whole-branch review raised three Important findings — a handler map typed loosely enough to accept a typo'd key, builders throwing outside the qualified chain (exit 70 where the sibling deliberately exits 1), and a changeset that stated a false peer-dependency fact. All fixed and re-verified, blocker B by reproducing its mutation independently.

Local suite: 219 tests across 16 workspaces, green except packages/start's documented binds 9000, which is a proxy on the author's machine.

🤖 Generated with Claude Code

Benoit Travers and others added 18 commits August 13, 2026 13:33
The no-Docker rule is gone. A suite that needs a broker, a database or a
service starts one with testcontainers; a hand-written double that fakes the
thing under test proves less than the container does.

What survives is the preference underneath, now stated as one: reach for the
cheapest fixture that tests the real behaviour — in memory when the behaviour is
the library's, a local binary when one exists, a container when neither does —
and state the cost in the workspace's README, because a suite that needs a
daemon is otherwise a fact a contributor discovers the hard way.

Three sentences cited the ban as their reason and would now be false: the
SQLite choice, the Temporal example's time-skipping server, and
`@temporal-contract/testing`'s uninstalled `testcontainers` peer. Each now says
what is actually true — those choices are cheaper, not compulsory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TypedAmqpWorker is imported as a value, so leaving @amqp-contract/worker
and @opentelemetry/api as devDependencies-only let tsdown inline both
(plus amqplib, amqp-connection-manager, @amqp-contract/core) straight
into dist: index.mjs was 344 KB and a consumer would carry a second
private copy of amqplib. Peering on them, the way start-temporal peers
on @temporalio/worker, collapses the bundle to under 2 KB.

@opentelemetry/api stays a required peer rather than an optional one:
@amqp-contract/core's telemetry module statically imports it regardless
of its own peerDependenciesMeta.optional claim, so the package cannot
actually load without it.
connectTimeoutMs is a top-level CreateWorkerOptions field, and passing
it inside AmqpOptions.connectionOptions (as the runtime previously
required) is silently inert: the worker falls back to the library's
30s default, which is what made the "broker will not answer" test wait
out ~30s to see its own assertion. AmqpOptions now takes
connectTimeoutMs directly and forwards it as its own top-level option,
so serveBroken can set it to 2s and the test fails fast.
…der throws

`AmqpOptions.handlers` was `Record<string, unknown>` and `middleware` was
`unknown` — both erased, so a typo'd handler key or a builder that throws
compiled clean and only failed at runtime, on the first delivery, silently to
the DLQ. Parameterise `AmqpOptions` on the contract so `handlers` is
`WorkerInferHandlers<TContract, MessageUnitContext<Needs>>` (a wrong or
missing key is now a compile error, pinned both ways by the new
amqp-runtime.test-d.ts) and type `middleware` as the package's own
`MessageMiddleware<Needs>` instead of `unknown`.

Wrap both builder calls in `fromThrowable` the way `-temporal`'s `activities`
builder already does, so a throw from `handlers` or `middleware` — a typo'd
`declareHandler` call, say — settles as `Err(RuntimeStartFailed)` and exit 1
rather than an unmodelled `Defect` and exit 70. Guarded by two new specs,
mutation-verified by hoisting the calls back outside the qualified chain.

Add the package's test:types script and tsconfig.test-d.json, and exclude
*.test-d.ts from the coverage glob so it doesn't get scored as untested
source. Sync CLAUDE.md and README.md with the new types and with the
qualified-builder shape, and reconcile the "caught once/twice" claim about
the bare-generic trap.
`orderAmqpRuntime` took `contract` as a parameter but `placeHandler` closed
over the imported `orderContract` singleton instead of using it — harmless
today (every caller passes the same value), but it taught that the parameter
was load-bearing when it was decorative. Make `placeHandler` a factory that
takes `contract` and builds `declareHandler` from it, the same way
`-temporal`'s `activities` builder threads its own `contract` into
`declareActivitiesHandler`.

Simplify `main.ts` to `order-worker/src/main.ts`'s shape: readEnv + start +
runMain, no `fromSafePromise(...).match(...)` wrapper. That wrapper exists in
`order-temporal`'s `main.ts` only because it opens a `NativeConnection`
before `start` and must close it after — this file's own TSDoc already says
there is nothing to open, so the wrapper had nothing to guard.

Sync the README's code sample and reconcile its "caught once" claim about the
bare-generic trap with `packages/start-amqp`'s "caught twice" (once for
`messageUnits`, once for `declareHandler` — both in that package's own
development, not here).
…s it

"lets the broker retry an unmodelled failure" and its GIVEN ("so it is a
Defect") read as if the broker retries a Defect directly — the opposite of
this branch's doctrine, where a Defect is exactly what AMQP does NOT retry.
The four attempts happen only because `placeHandler`'s `recoverDefect` turns
it into a `RetryableError` first. Rename the spec and its comment to say so,
in the one spec that exists to pin the distinction.
The changeset said "amqp-contract is not a peer dependency — the middleware
type is structural", but @amqp-contract/worker IS a peer
(packages/start-amqp/package.json) — only @amqp-contract/contract (the
umbrella package's contract half) is dev-only. A changeset becomes the
published CHANGELOG, the one text a consumer reads before installing, so
collapsing that distinction here contradicted both READMEs. Rewrite it to
name @amqp-contract/worker and @opentelemetry/api as peers, keep
@amqp-contract/contract dev-only, and add the Defect third channel every
other doc on this branch already covers.
The two entries this branch appended (@amqp-contract/contract,
@amqp-contract/worker) sat at the end of an otherwise-alphabetical list;
move them into order. No entries removed — @amqp-contract/core,
@unthrown/standard-schema@5.5.0 and unthrown@5.5.0 look dead but are live in
the lockfile. Also broaden the catalog comment above the @amqp-contract/*
block: it named only @amqp-contract/testing but now sits above three
entries, and its content is true of all three.
Copilot AI lite review requested due to automatic review settings August 13, 2026 15:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the missing AMQP consumer runtime to the @btravstack/start ecosystem (packages/start-amqp) and extends the order example with a fourth deployment (examples/order-amqp) plus its standalone transport contract (examples/order-amqp-contract). The change fits the repo’s “one process, one runtime” model by providing the last runtime kind and proving it end-to-end via a real broker-backed example and tests.

Changes:

  • Introduces @btravstack/start-amqp (amqpRuntime, messageUnits, AmqpInfo) with drain semantics that race the kernel deadline against an indefinite worker close.
  • Adds order-amqp and order-amqp-contract example workspaces (runtime + contract split) with Docker-backed integration tests via @amqp-contract/testing.
  • Updates repo docs/specs and workspace catalog pins to reflect the new runtime and required beta dependencies.

Reviewed changes

Copilot reviewed 43 out of 47 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
README.md Updates runtime map/docs to include start-amqp and the 4th example deployment.
pnpm-workspace.yaml Pins @amqp-contract/* beta versions and adds required catalog entries (incl. @opentelemetry/api).
packages/start-amqp/vitest.config.ts New Vitest config for runtime package (container-backed global setup + 100% coverage thresholds).
packages/start-amqp/tsconfig.test-d.json Adds relaxed type-test tsconfig for *.test-d.ts.
packages/start-amqp/tsconfig.json New TS config for building/typechecking the runtime package.
packages/start-amqp/src/vitest.d.ts Registers @unthrown/vitest type augmentations for this workspace tests.
packages/start-amqp/src/test-fixtures.ts Test fixtures wiring a real TypedAmqpWorker into start(...) for runtime specs.
packages/start-amqp/src/message-units.ts Implements middleware that opens one kernel unit per delivery and injects DI context.
packages/start-amqp/src/index.ts Public exports for amqpRuntime, messageUnits, and types.
packages/start-amqp/src/amqp-runtime.ts Runtime implementation: worker creation, info publishing, and deadline race during drain/stop.
packages/start-amqp/src/amqp-runtime.test-d.ts Type-level gate asserting handlers are checked against the contract keys.
packages/start-amqp/src/amqp-runtime.spec.ts Runtime behavior specs: startup error channeling, unit boundary, drain deadline behavior.
packages/start-amqp/README.md Package README documenting usage, drain semantics, and transport boundary responsibilities.
packages/start-amqp/package.json New published-package manifest: peers, scripts, and ESM/CJS exports layout.
packages/start-amqp/LICENSE Adds MIT license file for the new package.
packages/start-amqp/CLAUDE.md Package-local public-surface and invariants documentation for start-amqp.
examples/README.md Updates examples index to eleven workspaces / four deployments and the new contract/runtime split.
examples/order-temporal/README.md Adjusts Docker/testcontainers wording to match updated integration-test policy.
examples/order-amqp/vitest.config.ts New Vitest config for AMQP deployment example (global RabbitMQ container).
examples/order-amqp/tsconfig.test-d.json Adds type-test tsconfig for *.test-d.ts in AMQP example.
examples/order-amqp/tsconfig.json New TS config for AMQP example workspace.
examples/order-amqp/src/vitest.d.ts Registers @unthrown/vitest matchers for AMQP example tests.
examples/order-amqp/src/test-fixtures.ts Example fixtures to boot the real AMQP deployment and observe behavior via broker round-trips.
examples/order-amqp/src/needs-gate.test-d.ts Type-level proof of start runtime-needs gate for the new deployment.
examples/order-amqp/src/module.ts New OrderAmqpModule composition root (same app graph, different runtime).
examples/order-amqp/src/main.ts Entry point wiring readEnv + start + runMain for the AMQP worker process.
examples/order-amqp/src/index.ts Exports the deployment module and runtime builder.
examples/order-amqp/src/env.ts Adds AMQP deployment env schema + readEnv() returning a Result.
examples/order-amqp/src/env.spec.ts Validates AMQP deployment env behavior (defaults + empty-string rejection).
examples/order-amqp/src/amqp-runtime.ts Example’s runtime “application half”: contract + handler triage into retryable/nonretryable.
examples/order-amqp/src/amqp-runtime.spec.ts Integration specs proving retry/DLQ semantics and per-delivery unit/trace IDs.
examples/order-amqp/README.md Deployment README: explains the 4th transport semantics, triage, and why Docker is needed.
examples/order-amqp/package.json New example workspace manifest, depending on start-amqp and the AMQP contract.
examples/order-amqp-contract/vitest.config.ts New Vitest config for the AMQP contract workspace.
examples/order-amqp-contract/tsconfig.test-d.json Adds type-test tsconfig for contract workspace.
examples/order-amqp-contract/tsconfig.json New TS config for contract workspace.
examples/order-amqp-contract/src/vitest.d.ts Registers @unthrown/vitest matchers for contract tests.
examples/order-amqp-contract/src/test-fixtures.ts Contract fixtures: expose contract and payload validator (contract-only proof).
examples/order-amqp-contract/src/layering.test-d.ts Compile-time enforcement that the contract package cannot depend on its worker.
examples/order-amqp-contract/src/index.ts Exports the AMQP contract and its type.
examples/order-amqp-contract/src/contract.ts Defines exchange/queue/retry+DLX policy and the placement message contract.
examples/order-amqp-contract/src/contract.spec.ts Tests contract routing config and schema validation behavior.
examples/order-amqp-contract/README.md Contract README: explains why it is separate and how it enforces layering.
examples/order-amqp-contract/package.json New contract workspace manifest with minimal runtime deps (zod, contract lib).
CLAUDE.md Updates root spec to include start-amqp, AMQP example, and revised integration-test policy.
.changeset/start-amqp.md Adds changeset for publishing @btravstack/start-amqp as a minor release.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/start-amqp/tsconfig.json Outdated
Comment thread packages/start-amqp/src/message-units.ts
Benoit Travers and others added 2 commits August 13, 2026 17:50
`@amqp-contract/core` pulls `@unthrown/standard-schema@5.5.0`, which pulls
`unthrown@5.5.0` — so installing this wave put a second copy of unthrown in the
graph beside the catalog's 5.2.0. CLAUDE.md names that hazard directly: di's
port identity and unthrown's `isResult` each compare across copies, and a
`Result` crosses the amqp-contract boundary in both directions through the
middleware.

The catalog moves to 5.5.0 and the lockfile is rebuilt from a fresh resolution,
so there is one copy again. `@unthrown/oxlint` deliberately stays at 5.2.0: its
5.5.0 drops the `prefer-ensure` rule this repo enables, which is a lint-plugin
migration and not this branch's business.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`traceId` defaulted to `messageId ?? correlationId ?? id`, and `??` guards
nullish alone — so a publisher setting `messageId: ""` would hand every
delivery the same blank trace id and defeat the ambient record exactly as a
category-as-id would. `-http` already refuses a blank `x-request-id` for this
reason and says so in its own `metaFor`; this is the same trap, one transport
over.

Mutation-verified: restoring the `??` form fails the new spec with
`blank: true`.

Also excludes `src/**/*.test-d.ts` from this package's main tsconfig, as
`packages/start` does — without it the type test compiles twice, and the
relaxed `noUnused*` in `tsconfig.test-d.json` never applies to the file it
exists for.

Found in review of PR #18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@btravers
btravers merged commit 35ca4bb into main Aug 13, 2026
13 checks passed
@btravers
btravers deleted the feat/start-amqp branch August 13, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants