From 9dafcfaae8f5caccd5fcdf68555dcdde05e2eadf Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Thu, 13 Aug 2026 23:40:59 +0200 Subject: [PATCH 1/7] feat!(examples): each example does what its transport is for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove order-worker (a start-core-only example), rename order-amqp -> order-amqp-worker and order-temporal -> order-temporal-worker, and give each surviving worker its transport's real story: - order-amqp-worker: event broadcast via a transactional outbox. OrderRepository.save writes the order row and its outbox row in one transaction; a relay unit layered onto start-amqp's runtime sweeps the outbox onto the orders exchange; the contract's consumer is one subscriber among any. Proven end to end against a real RabbitMQ, including delivery to a queue the contract never declared. - order-temporal-worker: a fulfillment saga. fulfillOrder orchestrates place -> reserveStock -> arrangeShipping and compensates in reverse (releaseStock, cancelPlacement) when a step answers a permanent no — declared errors compensate and surface typed at the client; machinery failures propagate uncompensated. Proven against the time-skipping test environment, both compensation paths included. Foundation: Outbox/StockService/ShippingService ports in order-application, OutOfStock/ShippingUnavailable in order-domain, the outbox table + transactional save + remove (compensation's persistence arm) in order-infrastructure. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 +- CLAUDE.md | 32 +- README.md | 4 +- examples/README.md | 135 ++++---- examples/order-amqp-contract/README.md | 31 +- .../order-amqp-contract/src/contract.spec.ts | 17 +- examples/order-amqp-contract/src/contract.ts | 50 +-- .../src/layering.test-d.ts | 4 +- .../order-amqp-contract/src/test-fixtures.ts | 6 +- examples/order-amqp-worker/README.md | 88 +++++ .../package.json | 4 +- .../src/amqp-runtime.spec.ts | 85 +++++ .../order-amqp-worker/src/amqp-runtime.ts | 95 ++++++ .../src/env.spec.ts | 33 +- .../src/env.ts | 6 +- .../src/index.ts | 0 .../src/main.ts | 14 +- examples/order-amqp-worker/src/module.ts | 26 ++ .../src/needs-gate.test-d.ts | 47 +++ .../order-amqp-worker/src/outbox-relay.ts | 111 ++++++ .../order-amqp-worker/src/test-fixtures.ts | 116 +++++++ .../src/vitest.d.ts | 0 .../tsconfig.json | 0 .../tsconfig.test-d.json | 0 .../vitest.config.ts | 0 examples/order-amqp/README.md | 205 ----------- examples/order-amqp/src/amqp-runtime.spec.ts | 179 ---------- examples/order-amqp/src/amqp-runtime.ts | 106 ------ examples/order-amqp/src/module.ts | 26 -- examples/order-amqp/src/needs-gate.test-d.ts | 48 --- examples/order-amqp/src/test-fixtures.ts | 198 ----------- examples/order-api/README.md | 8 +- examples/order-application/src/index.ts | 11 +- .../src/needs-gate.test-d.ts | 1 + examples/order-application/src/ports.ts | 45 +++ .../order-application/src/test-fixtures.ts | 1 + examples/order-config/README.md | 6 +- examples/order-domain/src/fulfillment.ts | 19 ++ examples/order-domain/src/index.ts | 1 + .../order-infrastructure/prisma/schema.prisma | 10 + examples/order-infrastructure/src/database.ts | 1 + examples/order-infrastructure/src/index.ts | 1 + examples/order-infrastructure/src/module.ts | 7 +- .../src/prisma-order-repository.ts | 31 +- .../src/prisma-outbox.spec.ts | 58 ++++ .../order-infrastructure/src/prisma-outbox.ts | 50 +++ .../order-infrastructure/src/test-fixtures.ts | 14 +- examples/order-temporal-contract/README.md | 21 +- .../order-temporal-contract/src/contract.ts | 95 ++++-- .../src/layering.test-d.ts | 4 +- .../src/test-fixtures.ts | 6 +- examples/order-temporal-worker/README.md | 93 +++++ .../package.json | 2 +- .../src/env.spec.ts | 0 .../src/env.ts | 0 .../order-temporal-worker/src/fulfillment.ts | 40 +++ .../src/index.ts | 0 .../src/main.ts | 2 +- examples/order-temporal-worker/src/module.ts | 29 ++ .../src/needs-gate.test-d.ts | 4 +- .../src/temporal-runtime.spec.ts | 139 ++++++++ .../src/temporal-runtime.ts | 96 +++++- .../src/test-fixtures.ts | 204 ++++++----- .../src/vitest.d.ts | 0 .../order-temporal-worker/src/workflows.ts | 104 ++++++ .../tsconfig.json | 0 .../tsconfig.test-d.json | 0 .../vitest.config.ts | 0 examples/order-temporal/README.md | 317 ------------------ examples/order-temporal/src/module.ts | 26 -- .../src/temporal-runtime.spec.ts | 227 ------------- examples/order-temporal/src/workflows.ts | 55 --- examples/order-worker/README.md | 166 --------- examples/order-worker/package.json | 35 -- examples/order-worker/src/env.spec.ts | 42 --- examples/order-worker/src/env.ts | 30 -- examples/order-worker/src/index.ts | 13 - examples/order-worker/src/main.ts | 42 --- examples/order-worker/src/module.ts | 25 -- .../order-worker/src/needs-gate.test-d.ts | 47 --- .../order-worker/src/queue-runtime.spec.ts | 225 ------------- examples/order-worker/src/queue-runtime.ts | 240 ------------- examples/order-worker/src/queue.ts | 128 ------- examples/order-worker/src/test-fixtures.ts | 221 ------------ examples/order-worker/src/vitest.d.ts | 1 - examples/order-worker/tsconfig.json | 13 - examples/order-worker/tsconfig.test-d.json | 6 - examples/order-worker/vitest.config.ts | 9 - knip.json | 3 +- pnpm-lock.yaml | 191 +++++------ pnpm-workspace.yaml | 1 + 91 files changed, 1785 insertions(+), 3049 deletions(-) create mode 100644 examples/order-amqp-worker/README.md rename examples/{order-amqp => order-amqp-worker}/package.json (92%) create mode 100644 examples/order-amqp-worker/src/amqp-runtime.spec.ts create mode 100644 examples/order-amqp-worker/src/amqp-runtime.ts rename examples/{order-amqp => order-amqp-worker}/src/env.spec.ts (58%) rename examples/{order-amqp => order-amqp-worker}/src/env.ts (79%) rename examples/{order-amqp => order-amqp-worker}/src/index.ts (100%) rename examples/{order-amqp => order-amqp-worker}/src/main.ts (73%) create mode 100644 examples/order-amqp-worker/src/module.ts create mode 100644 examples/order-amqp-worker/src/needs-gate.test-d.ts create mode 100644 examples/order-amqp-worker/src/outbox-relay.ts create mode 100644 examples/order-amqp-worker/src/test-fixtures.ts rename examples/{order-amqp => order-amqp-worker}/src/vitest.d.ts (100%) rename examples/{order-amqp => order-amqp-worker}/tsconfig.json (100%) rename examples/{order-amqp => order-amqp-worker}/tsconfig.test-d.json (100%) rename examples/{order-amqp => order-amqp-worker}/vitest.config.ts (100%) delete mode 100644 examples/order-amqp/README.md delete mode 100644 examples/order-amqp/src/amqp-runtime.spec.ts delete mode 100644 examples/order-amqp/src/amqp-runtime.ts delete mode 100644 examples/order-amqp/src/module.ts delete mode 100644 examples/order-amqp/src/needs-gate.test-d.ts delete mode 100644 examples/order-amqp/src/test-fixtures.ts create mode 100644 examples/order-domain/src/fulfillment.ts create mode 100644 examples/order-infrastructure/src/prisma-outbox.spec.ts create mode 100644 examples/order-infrastructure/src/prisma-outbox.ts create mode 100644 examples/order-temporal-worker/README.md rename examples/{order-temporal => order-temporal-worker}/package.json (96%) rename examples/{order-temporal => order-temporal-worker}/src/env.spec.ts (100%) rename examples/{order-temporal => order-temporal-worker}/src/env.ts (100%) create mode 100644 examples/order-temporal-worker/src/fulfillment.ts rename examples/{order-temporal => order-temporal-worker}/src/index.ts (100%) rename examples/{order-temporal => order-temporal-worker}/src/main.ts (98%) create mode 100644 examples/order-temporal-worker/src/module.ts rename examples/{order-temporal => order-temporal-worker}/src/needs-gate.test-d.ts (92%) create mode 100644 examples/order-temporal-worker/src/temporal-runtime.spec.ts rename examples/{order-temporal => order-temporal-worker}/src/temporal-runtime.ts (67%) rename examples/{order-temporal => order-temporal-worker}/src/test-fixtures.ts (54%) rename examples/{order-temporal => order-temporal-worker}/src/vitest.d.ts (100%) create mode 100644 examples/order-temporal-worker/src/workflows.ts rename examples/{order-temporal => order-temporal-worker}/tsconfig.json (100%) rename examples/{order-temporal => order-temporal-worker}/tsconfig.test-d.json (100%) rename examples/{order-temporal => order-temporal-worker}/vitest.config.ts (100%) delete mode 100644 examples/order-temporal/README.md delete mode 100644 examples/order-temporal/src/module.ts delete mode 100644 examples/order-temporal/src/temporal-runtime.spec.ts delete mode 100644 examples/order-temporal/src/workflows.ts delete mode 100644 examples/order-worker/README.md delete mode 100644 examples/order-worker/package.json delete mode 100644 examples/order-worker/src/env.spec.ts delete mode 100644 examples/order-worker/src/env.ts delete mode 100644 examples/order-worker/src/index.ts delete mode 100644 examples/order-worker/src/main.ts delete mode 100644 examples/order-worker/src/module.ts delete mode 100644 examples/order-worker/src/needs-gate.test-d.ts delete mode 100644 examples/order-worker/src/queue-runtime.spec.ts delete mode 100644 examples/order-worker/src/queue-runtime.ts delete mode 100644 examples/order-worker/src/queue.ts delete mode 100644 examples/order-worker/src/test-fixtures.ts delete mode 100644 examples/order-worker/src/vitest.d.ts delete mode 100644 examples/order-worker/tsconfig.json delete mode 100644 examples/order-worker/tsconfig.test-d.json delete mode 100644 examples/order-worker/vitest.config.ts diff --git a/.gitignore b/.gitignore index bab2675..184cd26 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,5 @@ docs/superpowers/ # The 64 MB Temporal time-skipping test server, downloaded on a cold cache by # the Temporal example's suite. Pinned here rather than left in the OS temp # directory so it survives across runs and is a stable path CI can cache. -# See examples/order-temporal/README.md. +# See examples/order-temporal-worker/README.md. .cache/ diff --git a/CLAUDE.md b/CLAUDE.md index 5ff3831..e001859 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,10 +22,10 @@ throws to callers: every fallible operation returns an pnpm workspace + turbo monorepo. `packages/` holds four published packages, `start` (the kernel), `start-http` (the HTTP runtime), `start-temporal` (the Temporal worker runtime) and `start-amqp` (the AMQP consumer runtime); -`examples/` holds eleven private ones — a clean-architecture application +`examples/` holds ten private ones — a clean-architecture application (`order-domain` → `order-application` → `order-infrastructure`) booted under -four different runtimes (`order-api`, `order-worker`, `order-temporal`, -`order-amqp`), with each transport's contract in a package of its own +four different runtimes (`order-api`, `order-worker`, `order-temporal-worker`, +`order-amqp-worker`), with each transport's contract in a package of its own (`order-api-contract`, `order-temporal-contract`, `order-amqp-contract`) because a client must be able to take a contract without the server. They are consumers, not fixtures: they are part of the gate, and `examples/README.md` @@ -57,7 +57,7 @@ hook). User-facing changes need a changeset. question of how two runtimes in one process share a drain deadline, or whose failure takes the process down. `StartOptions.runtime` is therefore a single value, not an array, and no future option should make it plural. - `examples/order-api`, `examples/order-worker` and `examples/order-temporal` + `examples/order-api`, `examples/order-worker` and `examples/order-temporal-worker` make this testable rather than asserted: the same `ApplicationModule` + `PersistenceModule` composition under three runtimes, with the same `DuplicateOrder` arriving as a typed `CONFLICT` on the first, a dead-letter @@ -370,12 +370,12 @@ the code. ## Toolchain & conventions - **`examples/` is part of the gate, not a folder of illustrations.** All - eleven workspaces run under the same six commands as the kernel — 93 specs - plus five `needs-gate.test-d.ts` files and four `layering.test-d.ts` ones — + ten workspaces run under the same six commands as the kernel — 79 specs + plus four `needs-gate.test-d.ts` files and four `layering.test-d.ts` ones — so an example that stops compiling, stops linting or stops passing fails CI - exactly as `packages/start-core` would. Four of the five needs-gate files pin - **`start`'s** runtime-needs gate (`order-api`, `order-worker`, - `order-temporal`, `order-amqp`); the fifth, `order-application`'s, pins + exactly as `packages/start-core` would. Three of the four needs-gate files pin + **`start`'s** runtime-needs gate (`order-api`, `order-temporal-worker`, + `order-amqp-worker`); the fourth, `order-application`'s, pins **di's** `UNSATISFIED DEPENDENCIES` gate on `Module.scoped`. They are different gates and easy to conflate. A runtime with a **non-empty `needs`** meeting a real module now exercises @@ -385,7 +385,7 @@ the code. driven by its 12 `http-runtime.spec.ts` specs. `examples/` stays the only place the gate is pinned by a **type test** — `start-http` ships no `*.test-d.ts`. -- **`examples/order-temporal` is the one workspace whose suite needs the +- **`examples/order-temporal-worker` is the one workspace whose suite needs the network, and only on a cold cache.** It runs a real `@temporalio/worker` Worker against `@temporalio/testing`'s **time-skipping test server** — a 64 MB local binary, not a container — so the whole Workflow-Task / @@ -406,14 +406,14 @@ the code. inject an `actions/cache` step into a reusable workflow's jobs. Closing it means adding a cache-path input there, not here; until then every test job pays the ~3.5 s download. -- **`packages/start-amqp` and `examples/order-amqp` are the two workspaces +- **`packages/start-amqp` and `examples/order-amqp-worker` are the two workspaces whose suites need a Docker daemon**, per the integration-test rule below. `@amqp-contract/testing` boots one real RabbitMQ container per vitest run (`globalSetup`) — the retry/dead-letter routing this package leans on is the broker's own behaviour, not something an in-memory fake or a local binary could stand in for. Measured on this machine: `packages/start-amqp` - **17.6 s cold** (image pull included), **7.3–8.0 s warm**; `examples/order-amqp` - **15.5 s cold**, **4.8–5.6 s warm** — both slower than `order-temporal`'s + **17.6 s cold** (image pull included), **7.3–8.0 s warm**; `examples/order-amqp-worker` + **15.5 s cold**, **4.8–5.6 s warm** — both slower than `order-temporal-worker`'s network-cache case, and cold only on a machine that has never pulled `rabbitmq:4.2.1-management-alpine` before. - **The Prisma client is generated at test time, and there is nothing to @@ -439,7 +439,7 @@ the code. server), a container when neither does (a broker). State the cost in the workspace's README, since a suite that needs a daemon is a fact a contributor discovers the hard way otherwise. -- **`examples/order-temporal` consumes `@btravstack/start-temporal`**, the same +- **`examples/order-temporal-worker` consumes `@btravstack/start-temporal`**, the same way `order-api` consumes `-http`: it supplies the contract, the two ports its activity resolves and the `mapErrCases` triage, and reads `{ taskQueue, namespace }` back off `Serving.info`. The Worker's lifecycle, the unit per @@ -641,8 +641,8 @@ A sixth rule is about production code that tests keep honest: deployments, and its spec pins all seven cases (absent, `""`, whitespace, `abc`, `3.5`, valid, out of range) **once**. Each deployment's own `env.ts` is then its variables and their defaults, and its own spec pins what is - genuinely its own — `order-worker`'s `CONCURRENCY` bound differs from a - port's, and `order-temporal`'s two string variables have an emptiness rule + genuinely its own — `order-amqp-worker`'s `OUTBOX_POLL_MS` bound differs from a + port's, and `order-temporal-worker`'s two string variables have an emptiness rule of their own. Triplicating the fragment was the earlier shape; it was cut in the audit that also deleted this repo's planning documents. The `` type argument is needed because `z.coerce.number()`'s input is `unknown`, which `.pipe` diff --git a/README.md b/README.md index 068c658..7a924dc 100644 --- a/README.md +++ b/README.md @@ -582,8 +582,8 @@ lines done well. ## Documentation See [`packages/start-core`](./packages/start-core) for the package README, -[`examples/`](./examples) for an eleven-package clean-architecture application -booted under four different runtimes, and [`CLAUDE.md`](./CLAUDE.md) for the +[`examples/`](./examples) for a ten-package clean-architecture application +booted under three runtimes, each doing what its transport is for: answering (HTTP), orchestrating (Temporal), broadcasting (AMQP), and [`CLAUDE.md`](./CLAUDE.md) for the authoritative spec: the theses, the public surface and the conventions. The load-bearing invariants with the test that guards each, and the internal design notes, live in diff --git a/examples/README.md b/examples/README.md index 39baa76..0474f32 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,25 +7,24 @@ transport's contract in a package of its own — and, at the same time, exercising `@btravstack/start-core` end to end from a consumer's own workspace, `workspace:*` and all. -| Package | Layer | Shows | -| ------------------------------------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`order-domain`](./order-domain) | domain | Entities and rules with no dependencies at all: branded fields, an `Entity.invariant` re-checked on every path, failures as values. | -| [`order-application`](./order-application) | use cases | Ports declared by the caller, interactors, and an `ApplicationModule` whose `OrderRepository` is deliberately an **unmet need**. | -| [`order-infrastructure`](./order-infrastructure) | adapters | A Prisma-backed repository over in-memory SQLite, translating P-codes into the domain's vocabulary and closing the application's one need. | -| [`order-config`](./order-config) | config | The one environment-variable idiom the four deployments share: a non-empty string piped into a coercion, validated as a value, with the seven cases pinned once. | -| [`order-api-contract`](./order-api-contract) | contract | The oRPC contract on its own — wire shapes and declared error codes — taken by the server that implements it **and** by any client. | -| [`order-api`](./order-api) | runtime | The first deployment: an oRPC router over `node:http`, a scope forked per request, and `Result` → `ORPCError`. | -| [`order-worker`](./order-worker) | runtime | The second deployment: an in-memory queue worker over the **same** composition, and `Result` → ack / retry / dead-letter. | -| [`order-temporal-contract`](./order-temporal-contract) | contract | The Temporal contract on its own — one workflow, one activity, two declared `nonRetryable` errors — read by the worker, the sandbox and the client. | -| [`order-temporal`](./order-temporal) | runtime | The third deployment: `@btravstack/start-temporal` driving a Temporal worker, one unit per **activity attempt**, `Result` → typed contract error. | -| [`order-amqp-contract`](./order-amqp-contract) | contract | The AMQP contract on its own — one exchange, one queue with a retry/dead-letter policy, one message — read by the worker and by any publisher. | -| [`order-amqp`](./order-amqp) | runtime | The fourth deployment: `@btravstack/start-amqp` driving a real RabbitMQ worker, one unit per **delivery**, `Result` → `RetryableError` / `NonRetryableError`. | +| Package | Layer | Shows | +| ------------------------------------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`order-domain`](./order-domain) | domain | Entities and rules with no dependencies at all: branded fields, an `Entity.invariant` re-checked on every path, failures as values. | +| [`order-application`](./order-application) | use cases | Ports declared by the caller, interactors, and an `ApplicationModule` whose `OrderRepository` is deliberately an **unmet need**. | +| [`order-infrastructure`](./order-infrastructure) | adapters | A Prisma-backed repository over in-memory SQLite, translating P-codes into the domain's vocabulary and closing the application's one need. | +| [`order-config`](./order-config) | config | The one environment-variable idiom the three deployments share: a non-empty string piped into a coercion, validated as a value, with the seven cases pinned once. | +| [`order-api-contract`](./order-api-contract) | contract | The oRPC contract on its own — wire shapes and declared error codes — taken by the server that implements it **and** by any client. | +| [`order-api`](./order-api) | runtime | The first deployment: an oRPC router over `node:http`, a scope forked per request, and `Result` → `ORPCError`. | +| [`order-temporal-contract`](./order-temporal-contract) | contract | The Temporal contract on its own — one workflow, five activities, four declared `nonRetryable` errors — read by the worker, the sandbox and the client. | +| [`order-temporal-worker`](./order-temporal-worker) | runtime | The **orchestration** deployment: a fulfillment saga on `@btravstack/start-temporal` — place, reserve, ship, and compensation in reverse on a permanent no. | +| [`order-amqp-contract`](./order-amqp-contract) | contract | The AMQP contract on its own — one exchange, one event, one subscriber queue with a retry/dead-letter policy — read by the relay and by any subscriber. | +| [`order-amqp-worker`](./order-amqp-worker) | runtime | The **broadcast** deployment: a transactional outbox relayed onto RabbitMQ by `@btravstack/start-amqp`'s worker — every committed write becomes an event. | ## The layering, and which way the arrows point ``` - order-api order-worker order-temporal order-amqp ← one runtime each; one process each - └────────────────┼─────────────────┼──────────────────┘ ─────▶ order-config ← how all four read the environment + order-api order-temporal-worker order-amqp-worker ← one runtime each; one process each + └────────────────┼──────────────────┘ ─────▶ order-config ← how all three read the environment ▼ order-infrastructure ← Prisma, SQLite, P-codes │ provides OrderRepository @@ -50,7 +49,7 @@ A transport's contract is a **shared artifact**, so each one is a package of its own: ``` - order-api any API client order-temporal any workflow client order-amqp any publisher + order-api any API client order-temporal-worker any workflow client order-amqp-worker any publisher └──────────────┬───────┘ └───────────────┬───────┘ └────────────┬────────┘ ▼ ▼ ▼ order-api-contract order-temporal-contract order-amqp-contract @@ -62,7 +61,7 @@ of contract-first design: a client is entitled to the wire shapes and the declared errors without the router or activity that implements them, the di wiring behind it, the Prisma-backed repository behind that, or the kernel booting the lot. The api and temporal contracts sat inside -`order-api/src/contract.ts` and `order-temporal/src/contract.ts` before being +`order-api/src/contract.ts` and `order-temporal-worker/src/contract.ts` before being extracted, so no client could take one without the others until then; `order-amqp-contract` started as its own package from the outset, the same shape without the detour. None of the three depends on `@btravstack/start-core`, on @@ -84,40 +83,45 @@ execution — all a Temporal client can do without a running service. same way, with no worker, no connection and no broker in scope — the check a publisher makes before sending a message. -## One application, four deployments - -`OrderApiModule`, `OrderWorkerModule`, `OrderTemporalModule` and -`OrderAmqpModule` are the same three lines: - -```ts -imports: [ApplicationModule, PersistenceModule], -exports: [PlaceOrder, FindOrder, Logger], -``` - -Nothing in `order-application` or `order-infrastructure` differs between them, -and nothing could: the use cases return a `Result`, and what a `Result` means to -a transport is the transport's business. The kernel's headline claim — several -runtime _kinds_, one per process, over the same module — is proved here rather -than asserted, and the sharpest form of the proof is that **the same `Err` -becomes four different outcomes**: - -| unthrown | `order-api` | `order-worker` | `order-temporal` | `order-amqp` | -| ---------------------- | ----------------------- | --------------------------- | --------------------------------------- | -------------------------------------------------------- | -| `Ok(order)` | the procedure's output | **ack** | the workflow's output | **ack** | -| `Err(InvalidQuantity)` | `INVALID_QUANTITY` | **dead-letter** | `InvalidQuantity`, **non-retryable** | `NonRetryableError`, **parked** | -| `Err(DuplicateOrder)` | `CONFLICT` | **dead-letter** | `OrderAlreadyPlaced`, **non-retryable** | `NonRetryableError`, **parked** | -| `Defect` | `INTERNAL_SERVER_ERROR` | **retry**, then dead-letter | **retried by the platform**, then fails | `RetryableError`, **retried by the broker**, then parked | - -The kernel appears in none of the four columns. `RunUnit` hands a runtime the -work's own `Result` and stays out of what it means. +## One application, three deployments — each doing what its transport is for + +Every composition root imports the same pair — `ApplicationModule`, +`PersistenceModule` — and exports its own selection of ports: nothing in +`order-application` or `order-infrastructure` differs between deployments, and +nothing could. What differs is what each transport is **for**: + +- **`order-api`** answers a caller: a request arrives, a typed answer leaves. +- **`order-temporal-worker`** owns a journey: the fulfillment saga runs steps + in order and compensates in reverse when one answers a permanent no — + orchestration, which needs a durable owner. +- **`order-amqp-worker`** tells everyone what happened: every committed write + leaves an `order.placed` event through a transactional outbox — broadcast, + which needs no addressee at all. + +The use cases return a `Result`, and what a `Result` means to a transport is +the transport's business — **the same `Err` becomes different outcomes** where +a caller exists to hear it: + +| unthrown | `order-api` | `order-temporal-worker` | +| ---------------------- | ----------------------- | --------------------------------------- | +| `Ok(order)` | the procedure's output | the workflow's output | +| `Err(InvalidQuantity)` | `INVALID_QUANTITY` | `InvalidQuantity`, **non-retryable** | +| `Err(DuplicateOrder)` | `CONFLICT` | `OrderAlreadyPlaced`, **non-retryable** | +| `Defect` | `INTERNAL_SERVER_ERROR` | **retried by the platform**, then fails | + +`order-amqp-worker` is deliberately absent from that table: on a broadcast +there is no caller waiting to be told, so a placement's `Err` never crosses the +broker — only the committed fact does. The kernel appears in none of the +columns either way. `RunUnit` hands a runtime the work's own `Result` and stays +out of what it means. The fourth and fifth columns carry something the second and third do not. Naming a failure on a Temporal contract decides not only what the caller sees but **whether the platform retries it** — both domain errors are declared `nonRetryable`, so Temporal asks exactly once, while an unmodelled failure -stays unnamed and the retry policy takes over. `order-worker` hand-rolls that -distinction with an attempt budget; on Temporal it is a line of contract, and -on AMQP it is too, in the broker's own vocabulary: `order-placements`'s +stays unnamed and the retry policy takes over. A hand-rolled worker spells that +distinction as an attempt budget; on Temporal it is a line of contract, and +on AMQP it is too, in the broker's own vocabulary: `order-notifications`'s `retry: { mode: "ttl-backoff", maxRetries: 3 }` is contract configuration the broker itself enforces, not a runtime constant. The count means something different, though — `maxRetries: 3` is retries **on top of** the first @@ -126,23 +130,22 @@ it is parked, not the three `maximumAttempts: 3` names on Temporal. ## What each runtime calls a "unit" -The four deployments disagree about what one piece of work is, and the kernel +The three deployments disagree about what one piece of work is, and the kernel does not care — which is the point of `RunUnit` being parameterised by nothing but `UnitMeta`: -| | one unit is | `id` | `traceId` | -| ---------------- | ------------------------ | ----------------------- | --------------------------------- | -| `order-api` | one HTTP request | a fresh `randomUUID()` | an inbound `x-request-id`, if any | -| `order-worker` | one **delivery** | `job#attempt` | the message id | -| `order-temporal` | one **activity attempt** | Temporal's task token | the workflow id | -| `order-amqp` | one **delivery** | a minted `randomUUID()` | the publisher's `messageId` | +| | one unit is | `id` | `traceId` | +| ----------------------- | ------------------------ | ----------------------- | --------------------------------- | +| `order-api` | one HTTP request | a fresh `randomUUID()` | an inbound `x-request-id`, if any | +| `order-temporal-worker` | one **activity attempt** | Temporal's task token | the workflow id | +| `order-amqp-worker` | one **delivery** | a minted `randomUUID()` | the publisher's `messageId` | -All four are answering the same obligation — `UnitMeta.id` must be unique per -unit, because `traceId` defaults to it — and all four land on "the attempt, not +All three are answering the same obligation — `UnitMeta.id` must be unique per +unit, because `traceId` defaults to it — and all three land on "the attempt, not the logical thing", because a retry is a second unit and the same trace. -`order-worker` and `order-amqp` agree on what a unit _is_ — one delivery — and +`order-worker` and `order-amqp-worker` agree on what a unit _is_ — one delivery — and disagree on how to name it: a queue job id is already unique per attempt, a -delivery tag is not (see `order-amqp`'s own README for why), so one mints and +delivery tag is not (see `order-amqp-worker`'s own README for why), so one mints and the other does not. ## The runtimes with a non-empty `needs` @@ -160,15 +163,15 @@ module here. `@btravstack/start-http`'s own `AppModule`/`Greeting` fixture way now. `examples/` stays the only place the gate is pinned by a **type test**: `start-http` ships no `*.test-d.ts`. -All four directions are pinned, in `order-api/src/needs-gate.test-d.ts`, -`order-worker/src/needs-gate.test-d.ts`, `order-temporal/src/needs-gate.test-d.ts` -and `order-amqp/src/needs-gate.test-d.ts`: the wired call is an ordinary +All three directions are pinned, in `order-api/src/needs-gate.test-d.ts`, +`order-temporal-worker/src/needs-gate.test-d.ts` +and `order-amqp-worker/src/needs-gate.test-d.ts`: the wired call is an ordinary two-argument one, and a module one port short fails on **arity**, naming the missing need. ## Why these are tests, not just illustrations -Each package reads as application code, and each is covered by real specs — 93 +Each package reads as application code, and each is covered by real specs — 79 of them, run by the repository's own `pnpm test`: ```sh @@ -181,20 +184,20 @@ Nothing is faked at the boundaries that matter. `order-infrastructure` runs against a real Prisma client over in-memory SQLite, so a `DuplicateOrder` comes from an actual `UNIQUE` index raising an actual P2002. `order-api` runs a real `node:http` server and a real oRPC client over it, so the collapse of a `Defect` -to `INTERNAL_SERVER_ERROR` happens where it really happens. `order-temporal` +to `INTERNAL_SERVER_ERROR` happens where it really happens. `order-temporal-worker` runs a real `TypedWorker` polling a real task queue, so a drain that lets an in-flight activity finish is the SDK's own `DRAINING` state and not a mock of it. The fixtures reach for the cheapest thing that tests the real behaviour: the Prisma client is generated by the `test` script itself, Temporal's time-skipping test server is a local binary rather than a container, and where neither exists — -`order-amqp` needs a real broker — the suite starts one with `testcontainers`. +`order-amqp-worker` needs a real broker — the suite starts one with `testcontainers`. -Two suites need more than a checkout. `order-temporal` needs **network access on +Two suites need more than a checkout. `order-temporal-worker` needs **network access on a cold cache** to fetch that 64 MB binary once (cached at `/.cache/temporal-test-server`, gitignored, with a year-long ttl), which costs about 3.5 s, once — see -[`order-temporal`'s README](./order-temporal#running-it--and-the-one-thing-this-example-needs-that-the-others-do-not). -`order-amqp` needs a **Docker daemon**, because a real RabbitMQ is the only +[`order-temporal-worker`'s README](./order-temporal-worker#running-it--and-the-one-thing-this-example-needs-that-the-others-do-not). +`order-amqp-worker` needs a **Docker daemon**, because a real RabbitMQ is the only honest way to test a drain against a live broker connection and real acknowledgement — what an abandoned delivery costs once the kernel's own deadline passes is _not_ redelivery, only the release of a report; the broker diff --git a/examples/order-amqp-contract/README.md b/examples/order-amqp-contract/README.md index 000da6d..356bb9e 100644 --- a/examples/order-amqp-contract/README.md +++ b/examples/order-amqp-contract/README.md @@ -1,8 +1,8 @@ # `@btravstack/start-core` example: the order AMQP contract -The AMQP contract — one exchange, one queue with a dead-letter exchange and a -retry policy, one message, one publisher, one consumer — in a package of its -own, depending on `@amqp-contract/contract` and `zod`. +The AMQP contract — one exchange, one broadcast event, one subscriber queue +with a dead-letter exchange and a retry policy — in a package of its own, +depending on `@amqp-contract/contract` and `zod`. ``` src/contract.ts the contract: exchange, queue, retry/dead-letter policy, message, publisher, consumer @@ -10,35 +10,38 @@ src/layering.test-d.ts the dependency rule, as a compile error src/test-fixtures.ts the contract itself, and its message schema as a validator returning a Result ``` -## Why it is not part of `order-amqp` +## Why it is not part of `order-amqp-worker` A contract is a **shared artifact**. Two parties read this file: the worker -that consumes `order-placements`, and any publisher that sends a placement — -neither wants a di container, a Prisma-backed repository or the kernel. +whose relay publishes `order.placed` and whose consumer reads +`order-notifications`, and any _other_ service that wants to subscribe to the +broadcast — neither wants a di container, a Prisma-backed repository or the +kernel. ``` - order-amqp any publisher of a placement + order-amqp-worker any subscriber to order.placed └──────────┬──────────┘ ▼ order-amqp-contract ← @amqp-contract/contract and zod, nothing else ``` `src/layering.test-d.ts` is that sentence as a compile error: it imports -`@btravstack/start-example-order-amqp` under a `@ts-expect-error`, so the day +`@btravstack/start-example-order-amqp-worker` under a `@ts-expect-error`, so the day this package gains a dependency on the worker it describes, `test:types` fails because the directive stops being used. ## A publisher entry is structurally required `defineEventConsumer` derives the queue's binding from the publisher it -consumes, so `defineContract` needs a publisher entry whether or not this repo -ships one. `orderPlacementRequested` is that publisher — the contract carries -the producing side even though no `order-amqp` client publishes it yet. +consumes, so `defineContract` needs a publisher entry — and here the repo +genuinely ships both sides: `orderPlacedEvent` is what the outbox relay +publishes, and the `order-notifications` consumer is one subscriber among +however many bind their own queues to the same exchange. ## The retry budget is contract configuration, not a runtime constant -`order-worker` spells its retry policy as a `MAX_ATTEMPTS` constant and an -`if` in its runtime; here it is `order-placements`'s `retry` and `deadLetter` +A hand-rolled worker spells its retry policy as a `MAX_ATTEMPTS` constant and an +`if` in its runtime; here it is `order-notifications`'s `retry` and `deadLetter` options, which the broker itself enforces — the sharper form of the same claim the Temporal contract makes with `nonRetryable`: naming a failure decides not only what the caller sees but what the platform does next. @@ -54,7 +57,7 @@ wonder why the check did not fire. ## The schema is the demonstration Where the oRPC contract's proof is a client built from it, this one's is that -the contract is **executable**: `src/contract.spec.ts` runs the placement +the contract is **executable**: `src/contract.spec.ts` runs the event message's own payload schema through `@unthrown/standard-schema`'s `fromSchema` and gets a `Result`, with no worker, no connection and no broker in scope — which is exactly the check a publisher makes before sending a diff --git a/examples/order-amqp-contract/src/contract.spec.ts b/examples/order-amqp-contract/src/contract.spec.ts index a7649f1..35c905d 100644 --- a/examples/order-amqp-contract/src/contract.spec.ts +++ b/examples/order-amqp-contract/src/contract.spec.ts @@ -3,16 +3,16 @@ import { describe, expect } from "vitest"; import { it } from "./test-fixtures.js"; describe("orderContract", () => { - it("routes placements through a queue that parks what it cannot retry", ({ contract }) => { + it("routes the broadcast through a queue that parks what it cannot retry", ({ contract }) => { // GIVEN the contract as any worker or publisher would take it // WHEN its queue is read - const queue = contract.queues["order-placements"]; + const queue = contract.queues["order-notifications"]; // THEN the retry budget and the parking bay are the contract's, not a // constant in some deployment's runtime expect(queue).toEqual( expect.objectContaining({ - name: "order-placements", + name: "order-notifications", deadLetter: expect.objectContaining({ exchange: expect.objectContaining({ name: "orders-dlx" }), }), @@ -21,11 +21,18 @@ describe("orderContract", () => { ); }); - it("validates a placement payload from the contract alone", ({ validate }) => { + it("names the event as a fact, not a command", ({ contract }) => { + // GIVEN the publisher any relay would take + // WHEN its routing key is read + // THEN it announces something that happened — past tense, no addressee + expect(contract.publishers.orderPlaced.routingKey).toBe("order.placed"); + }); + + it("validates a broadcast payload from the contract alone", ({ validate }) => { // GIVEN the contract's own schema, and nothing else — no worker, no // connection, no broker - // WHEN a caller checks the payload it is about to publish + // WHEN a relay checks the payload it is about to publish // THEN it is accepted, in the shape the wire will carry expect(validate({ orderId: "o-1", quantity: 2 })).toBeOkWith({ orderId: "o-1", quantity: 2 }); }); diff --git a/examples/order-amqp-contract/src/contract.ts b/examples/order-amqp-contract/src/contract.ts index 6c71aee..800ddaf 100644 --- a/examples/order-amqp-contract/src/contract.ts +++ b/examples/order-amqp-contract/src/contract.ts @@ -12,44 +12,52 @@ const orders = defineExchange("orders"); const parked = defineExchange("orders-dlx", { type: "direct" }); /** - * The queue, and the one place this deployment's retry budget lives. + * The event this contract exists to broadcast: a fact, past tense, about + * something that already committed. Nothing here is a command — nobody is + * asked to do anything, and the publisher does not know who is listening. + * That is what separates this deployment from the Temporal one: AMQP carries + * announcements, orchestration carries intent. + */ +const orderPlaced = defineMessage(z.object({ orderId: z.string(), quantity: z.number() })); + +const orderPlacedEvent = defineEventPublisher(orders, orderPlaced, { + routingKey: "order.placed", +}); + +/** + * One subscriber's queue, and the one place its retry budget lives. Other + * services bind their own queues to the same `orders` exchange without + * touching this contract — that is the broadcast working as intended; this + * queue exists so the repo ships one end-to-end reader. * - * `order-worker` spells the same policy as a `MAX_ATTEMPTS` constant and an - * `if` in its runtime; here it is contract configuration the broker enforces, - * which is the sharper form of the same claim the Temporal contract makes - * with `nonRetryable`: naming a failure decides not only what the caller sees - * but what the platform does next. + * The policy is contract configuration the broker enforces — the sharper form + * of the claim the Temporal contract makes with `nonRetryable`: naming a + * failure decides not only what the caller sees but what the platform does + * next. * * `externalConsumers: true` on the dead letter is required, not decorative: * `defineContract` runs a define-time routability check that rejects a DLX * nothing binds to — a queue nothing here parks messages *out of* would - * otherwise be silent message loss the check is built to catch. This - * contract has no consumer for `orders-dlx` because parking is the point; - * the flag says that deliberately, the same way `order-worker`'s own - * suite does for the identical shape. + * otherwise be silent message loss the check is built to catch. This contract + * has no consumer for `orders-dlx` because parking is the point. */ -const placements = defineQueue("order-placements", { +const notifications = defineQueue("order-notifications", { deadLetter: { exchange: parked, externalConsumers: true }, retry: { mode: "ttl-backoff", maxRetries: 3, initialDelayMs: 10 }, }); -const placeOrder = defineMessage(z.object({ orderId: z.string(), quantity: z.number() })); - -const orderPlacementRequested = defineEventPublisher(orders, placeOrder, { - routingKey: "order.placement.requested", -}); - /** * The contract, declared before any implementation exists — the same * discipline `order-api-contract` and `order-temporal-contract` follow. * * A publisher entry is **structurally required**: `defineEventConsumer` - * derives the binding from the publisher it consumes, so this package - * carries the producing side whether or not this repo ships a producer. + * derives the binding from the publisher it consumes, so this package carries + * the producing side (the outbox relay) and the consuming side (the + * notifier) as one checkable artifact. */ export const orderContract = defineContract({ - publishers: { placeOrder: orderPlacementRequested }, - consumers: { placeOrder: defineEventConsumer(orderPlacementRequested, placements) }, + publishers: { orderPlaced: orderPlacedEvent }, + consumers: { orderPlaced: defineEventConsumer(orderPlacedEvent, notifications) }, }); export type OrderContract = typeof orderContract; diff --git a/examples/order-amqp-contract/src/layering.test-d.ts b/examples/order-amqp-contract/src/layering.test-d.ts index a29ef7d..3ec3c4e 100644 --- a/examples/order-amqp-contract/src/layering.test-d.ts +++ b/examples/order-amqp-contract/src/layering.test-d.ts @@ -9,6 +9,6 @@ */ // @ts-expect-error — the contract must not be able to reach the worker that -// implements it: order-amqp-contract does not depend on order-amqp, so the +// implements it: order-amqp-contract does not depend on order-amqp-worker, so the // specifier does not resolve. -import type {} from "@btravstack/start-example-order-amqp"; +import type {} from "@btravstack/start-example-order-amqp-worker"; diff --git a/examples/order-amqp-contract/src/test-fixtures.ts b/examples/order-amqp-contract/src/test-fixtures.ts index fe9c8ee..cd12225 100644 --- a/examples/order-amqp-contract/src/test-fixtures.ts +++ b/examples/order-amqp-contract/src/test-fixtures.ts @@ -3,7 +3,7 @@ import { test, type TestAPI } from "vitest"; import { orderContract, type OrderContract } from "./contract.js"; -type PlacementPayload = typeof orderContract.consumers.placeOrder.message.payload; +type PlacedPayload = typeof orderContract.consumers.orderPlaced.message.payload; export type ContractFixtures = { /** The contract itself, as any worker or publisher would take it. */ @@ -13,7 +13,7 @@ export type ContractFixtures = { * `Result` — what a caller holding nothing but this package can check a * payload with before it ever reaches a worker. */ - readonly validate: ReturnType>; + readonly validate: ReturnType>; }; export const it: TestAPI = test.extend({ @@ -24,6 +24,6 @@ export const it: TestAPI = test.extend({ // oxlint-disable-next-line no-empty-pattern -- see above validate: async ({}, use) => { // `fromSchema` is CURRIED — it takes the schema and hands back the validator. - await use(fromSchema(orderContract.consumers.placeOrder.message.payload)); + await use(fromSchema(orderContract.consumers.orderPlaced.message.payload)); }, }); diff --git a/examples/order-amqp-worker/README.md b/examples/order-amqp-worker/README.md new file mode 100644 index 0000000..1c1fbb1 --- /dev/null +++ b/examples/order-amqp-worker/README.md @@ -0,0 +1,88 @@ +# `@btravstack/start-core` example: the order broadcast worker + +**What AMQP is for: telling everyone what happened.** This deployment +broadcasts a fact — `order.placed` — to whoever cares to listen, and it gets +that fact onto the wire without ever letting "the order committed" and "the +event was sent" disagree: the **transactional outbox** pattern, end to end. +The consuming half is served by +[`@btravstack/start-amqp`](../../packages/start-amqp) the way `order-api` is +served by `@btravstack/start-http`; the contract lives in +[`order-amqp-contract`](../order-amqp-contract), because another service +binding its own queue to the `orders` exchange needs it and needs none of this. + +``` +src/outbox-relay.ts the publishing half: sweep the outbox, publish, mark sent +src/amqp-runtime.ts the runtime: start-amqp's consumer with the relay layered on +src/module.ts OrderAmqpModule — the composition root +src/env.ts process.env validated through a schema, as a Result +src/main.ts the process: readEnv + start + runMain +src/test-fixtures.ts serve / tapped, as Vitest fixtures, against a real RabbitMQ +``` + +## The pattern, in three places + +**The write** is `OrderRepository.save` in `order-infrastructure`: the order +row and its `OutboxMessage` row commit in one `$tryTransaction`. There is no +"publish after save" call to forget, and no window where the order exists but +the fact of it is lost — the failure mode the naive `save(); publish();` +sequence carries by construction. + +**The relay** is `src/outbox-relay.ts`: an infinite sweep — pull pending rows +in commit order, `publish("orderPlaced", …)` each to the `orders` exchange, +mark what the broker confirmed. It is deliberately **at-least-once**: a crash +between publish and mark re-publishes on the next sweep, a broker outage +leaves rows pending and the sweep after the outage drains them. What is never +possible is the inverse — a committed order whose event evaporated. + +**The consumer** is one `declareHandler` on the contract's +`order-notifications` queue, reacting to the fact like any other service +would. It is intentionally the least interesting part: a broadcast's +publisher does not know it exists, and the spec proves that by binding a +_foreign_ queue to the same exchange and receiving the same event. + +## Where the relay lives + +`orderAmqpRuntime` layers the relay onto the runtime `start-amqp` hands back: +started after the consumer, stopped before it, so a relay that cannot reach +the broker fails startup the way a consumer that cannot would. `drain` stays +the consumer's alone — draining means "stop taking new work", and the relay's +work is outbound: pending rows are safer published during the drain window +than abandoned to the next boot. + +The relay's needs are ports (`Outbox`, `Logger`), resolved from the same +application context the consumer's handler resolves — `start`'s needs gate +(`src/needs-gate.test-d.ts`) proves the composition root exports both, at +compile time. + +## The environment + +| Variable | Default | What it is | +| ---------------- | ----------------------- | ------------------------------------- | +| `AMQP_URL` | `amqp://127.0.0.1:5672` | the broker, for consumer and relay | +| `PROBE_PORT` | `9000` | `/livez` / `/readyz` | +| `OUTBOX_POLL_MS` | `200` | the relay's idle sleep between sweeps | + +`OUTBOX_POLL_MS=0` is rejected at boot — a relay that never sleeps is a busy +loop, and the deployment's own spec pins that where the shared `wholeNumber` +fragment's bounds would not. + +## Running the specs + +The suite runs against a **real RabbitMQ** in a testcontainer (Docker +required): a write placed through the application's own `PlaceOrder` crosses +the outbox, the broker and the queue, and comes back as the consumer's +notification — commit order preserved, outbox drained, and the same event +delivered to a subscriber this contract never heard of. + +```bash +pnpm --filter @btravstack/start-example-order-amqp-worker test # broadcast e2e + env specs +pnpm --filter @btravstack/start-example-order-amqp-worker typecheck # the needs gate +``` + +## What this deployment deliberately is not + +It is not a command queue. Nothing here asks a worker to _do_ anything — the +event is past tense, the publisher does not address a consumer, and removing +the notifier would inconvenience nobody but the notifier's users. When the +journey needs an owner — steps in order, compensation on failure — that is +orchestration, and it lives in [`order-temporal-worker`](../order-temporal-worker). diff --git a/examples/order-amqp/package.json b/examples/order-amqp-worker/package.json similarity index 92% rename from examples/order-amqp/package.json rename to examples/order-amqp-worker/package.json index ac0281c..2af7acb 100644 --- a/examples/order-amqp/package.json +++ b/examples/order-amqp-worker/package.json @@ -1,5 +1,5 @@ { - "name": "@btravstack/start-example-order-amqp", + "name": "@btravstack/start-example-order-amqp-worker", "private": true, "description": "The fourth deployment of the clean-architecture example: the same application module, served by an AMQP consumer whose deliveries are kernel units", "license": "MIT", @@ -15,6 +15,7 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { + "@amqp-contract/client": "catalog:", "@amqp-contract/worker": "catalog:", "@btravstack/di": "catalog:", "@btravstack/start-amqp": "workspace:*", @@ -22,7 +23,6 @@ "@btravstack/start-example-order-amqp-contract": "workspace:*", "@btravstack/start-example-order-application": "workspace:*", "@btravstack/start-example-order-config": "workspace:*", - "@btravstack/start-example-order-domain": "workspace:*", "@btravstack/start-example-order-infrastructure": "workspace:*", "@opentelemetry/api": "catalog:", "@unthrown/standard-schema": "catalog:", diff --git a/examples/order-amqp-worker/src/amqp-runtime.spec.ts b/examples/order-amqp-worker/src/amqp-runtime.spec.ts new file mode 100644 index 0000000..abc6ed4 --- /dev/null +++ b/examples/order-amqp-worker/src/amqp-runtime.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect } from "vitest"; + +import { it } from "./test-fixtures.js"; + +/** + * The notification lines, with the message unit's `[trace]` prefix stripped — + * what the consumer said is the assertion; that the middleware traced it is + * the package's own concern. + */ +const notifications = (lines: readonly string[]): readonly string[] => + lines + .filter((line) => line.includes("notifying")) + .map((line) => line.slice(line.indexOf("]") + 2)); + +describe("the broadcast deployment", () => { + it("broadcasts every committed write, end to end", async ({ serve, tapped }) => { + // GIVEN the app serving: relay sweeping the outbox, consumer on the queue + await serve(tapped.module); + const { placeOrder, logger } = tapped.services(); + + // WHEN an order is placed — one ordinary write, no publish in sight + await expect(placeOrder.execute("o-1", 2)).toBeOkWith(expect.objectContaining({ id: "o-1" })); + + // THEN the fact crosses the outbox, the broker and the queue, and the + // consumer reacts — the write-side never spoke AMQP + await expect + .poll(() => notifications(tapped.services().logger.lines()), { timeout: 5_000 }) + .toContain("order o-1 placed — notifying (2 items)"); + void logger; + }); + + it("marks relayed events published, exactly once each", async ({ serve, tapped }) => { + // GIVEN a served app and a committed write + await serve(tapped.module); + const { placeOrder, outbox } = tapped.services(); + await expect(placeOrder.execute("o-2", 1)).toBeOk(); + + // WHEN the relay has swept it + await expect + .poll(() => notifications(tapped.services().logger.lines()), { timeout: 5_000 }) + .toContain("order o-2 placed — notifying (1 items)"); + + // THEN nothing is left pending — the next sweep has nothing to re-publish + await expect(outbox.pending(10)).toBeOkWith([]); + }); + + it("relays in commit order", async ({ serve, tapped }) => { + // GIVEN a served app + await serve(tapped.module); + const { placeOrder } = tapped.services(); + + // WHEN two writes commit in order + await expect(placeOrder.execute("o-3", 1)).toBeOk(); + await expect(placeOrder.execute("o-4", 1)).toBeOk(); + + // THEN the notifications arrive in the same order: the relay publishes by + // outbox id, the queue preserves it, the consumer is sequential + await expect + .poll(() => notifications(tapped.services().logger.lines()), { timeout: 5_000 }) + .toEqual([ + "order o-3 placed — notifying (1 items)", + "order o-4 placed — notifying (1 items)", + ]); + }); + + it("is a broadcast: a subscriber this repo never heard of receives it too", async ({ + serve, + tapped, + initConsumer, + }) => { + // GIVEN a served app — whose worker declares the `orders` exchange — AND + // a foreign subscriber: its own queue, bound to the same exchange, + // declared by nothing in this contract + await serve(tapped.module); + const waitForMessages = await initConsumer("orders", "order.placed"); + + // WHEN an order is placed + await expect(tapped.services().placeOrder.execute("o-5", 4)).toBeOk(); + + // THEN the foreign queue receives the same fact the notifier does — the + // publisher addressed an exchange, never a consumer + const [message] = await waitForMessages({ count: 1, timeoutMs: 5_000 }); + expect(JSON.parse(String(message?.content))).toEqual({ orderId: "o-5", quantity: 4 }); + }); +}); diff --git a/examples/order-amqp-worker/src/amqp-runtime.ts b/examples/order-amqp-worker/src/amqp-runtime.ts new file mode 100644 index 0000000..52e44bc --- /dev/null +++ b/examples/order-amqp-worker/src/amqp-runtime.ts @@ -0,0 +1,95 @@ +import { declareHandler } from "@amqp-contract/worker"; +import { + amqpRuntime, + messageUnits, + type AmqpInfo, + type MessageUnitContext, +} from "@btravstack/start-amqp"; +import type { Runtime } from "@btravstack/start-core"; +import type { OrderContract } from "@btravstack/start-example-order-amqp-contract"; +import { Logger, Outbox } from "@btravstack/start-example-order-application"; +import { OkAsync } from "unthrown"; + +import { startOutboxRelay, type RelayOptions } from "./outbox-relay.js"; + +/** + * The ports this runtime resolves out of the application context: `Outbox` + * for the relay half, `Logger` for both halves. `PlaceOrder` is deliberately + * absent — nothing on a broadcast consumes a command, and a runtime declares + * what *it* needs rather than what the module happens to export. + * + * Non-empty on purpose: it is what makes `start`'s arity gate mean something + * (`src/needs-gate.test-d.ts` pins both directions). + */ +type AmqpNeeds = typeof Outbox | typeof Logger; + +/** + * A `Runtime` broadcasting the order application's facts over AMQP — both + * halves of the outbox pattern in one process. + * + * The consuming half is `@btravstack/start-amqp`'s runtime, unchanged: the + * `order-notifications` queue, a unit per delivery, the kernel's drain. The + * publishing half is this example's own: `startOutboxRelay` is layered onto + * the runtime the package hands back, started after it and stopped before it, + * so a relay that cannot reach the broker fails startup the same way a + * consumer that cannot would. + * + * `drain` is deliberately the consumer's alone. Draining means "stop taking + * new work", and the relay's work is outbound — pending rows it has not + * published yet are *safer* published during the drain window than abandoned + * to the next boot. It stops at `stop`, before the consumer's transport goes. + */ +export const orderAmqpRuntime = ({ + contract, + relay, + ...transport +}: { + /** The contract: the exchange the relay publishes to, the queue this worker consumes. */ + readonly contract: OrderContract; + /** The broker URLs `TypedAmqpWorker` connects to — it owns the connection. */ + readonly urls: readonly string[]; + /** The relay's own knobs; its client shares the broker but not the connection. */ + readonly relay: Pick; +}): Runtime => { + const consumer = amqpRuntime({ + ...transport, + contract, + needs: [Outbox, Logger], + handlers: () => ({ orderPlaced: notifyHandler(contract) }), + middleware: (host) => messageUnits(host), + }); + + return { + name: consumer.name, + needs: consumer.needs, + start: (host) => + consumer.start(host).flatMap((serving) => + startOutboxRelay(host.ctx, contract, { urls: transport.urls, pollMs: relay.pollMs }).map( + (running) => ({ + ...serving, + stop: () => running.stop().flatMap(() => serving.stop()), + }), + ), + ), + }; +}; + +/** + * The consuming half's one handler — a subscriber like any other service + * would write, reacting to a fact somebody else committed. It has no domain + * errors to triage: notifying is a `Logger.info` here, and a real notifier's + * failures would be retryable infrastructure, not answers about the order. + */ +const notifyHandler = (contract: OrderContract) => + declareHandler>( + contract, + "orderPlaced", + (message, _raw, { context }) => { + context.ctx + .get(Logger) + .info( + `order ${message.payload.orderId} placed — notifying (${message.payload.quantity} items)`, + ); + return OkAsync(); + }, + ); diff --git a/examples/order-amqp/src/env.spec.ts b/examples/order-amqp-worker/src/env.spec.ts similarity index 58% rename from examples/order-amqp/src/env.spec.ts rename to examples/order-amqp-worker/src/env.spec.ts index 5c341f4..4c7d10a 100644 --- a/examples/order-amqp/src/env.spec.ts +++ b/examples/order-amqp-worker/src/env.spec.ts @@ -13,19 +13,31 @@ describe("readEnv", () => { // WHEN it is validated const env = readEnv(source); - // THEN both carry their defaults, the port as a number - expect(env).toBeOkWith({ PROBE_PORT: 9000, AMQP_URL: "amqp://127.0.0.1:5672" }); + // THEN all three carry their defaults, the numbers as numbers + expect(env).toBeOkWith({ + PROBE_PORT: 9000, + AMQP_URL: "amqp://127.0.0.1:5672", + OUTBOX_POLL_MS: 200, + }); }); it("reads what a deployment actually supplies", () => { - // GIVEN both set, as the strings an environment always holds - const source = { PROBE_PORT: "0", AMQP_URL: "amqp://broker.internal:5672" }; + // GIVEN all three set, as the strings an environment always holds + const source = { + PROBE_PORT: "0", + AMQP_URL: "amqp://broker.internal:5672", + OUTBOX_POLL_MS: "50", + }; // WHEN it is validated const env = readEnv(source); // THEN they arrive parsed, and `0` survives as the ephemeral bind it is - expect(env).toBeOkWith({ PROBE_PORT: 0, AMQP_URL: "amqp://broker.internal:5672" }); + expect(env).toBeOkWith({ + PROBE_PORT: 0, + AMQP_URL: "amqp://broker.internal:5672", + OUTBOX_POLL_MS: 50, + }); }); it("rejects a broker URL that is present but empty, rather than defaulting it", () => { @@ -40,4 +52,15 @@ describe("readEnv", () => { // worker that silently defaults its broker is a worker consuming nothing expect(env).toBeErrWith([expect.objectContaining({ path: ["AMQP_URL"] })]); }); + + it("rejects a poll interval of zero where a port's own bounds would allow it", () => { + // GIVEN a sweep interval that would spin the relay hot + const source = { OUTBOX_POLL_MS: "0" }; + + // WHEN it is validated + const env = readEnv(source); + + // THEN the deployment's own lower bound speaks, not the shared fragment's + expect(env).toBeErrWith([expect.objectContaining({ path: ["OUTBOX_POLL_MS"] })]); + }); }); diff --git a/examples/order-amqp/src/env.ts b/examples/order-amqp-worker/src/env.ts similarity index 79% rename from examples/order-amqp/src/env.ts rename to examples/order-amqp-worker/src/env.ts index 33a6ac1..fa1e80f 100644 --- a/examples/order-amqp/src/env.ts +++ b/examples/order-amqp-worker/src/env.ts @@ -1,12 +1,14 @@ -import { describeEnvIssues, port } from "@btravstack/start-example-order-config"; +import { describeEnvIssues, port, wholeNumber } from "@btravstack/start-example-order-config"; import { fromSchema, type SchemaIssues } from "@unthrown/standard-schema"; import type { Result } from "unthrown"; import { z } from "zod"; const environment = z.object({ PROBE_PORT: port(9000), - /** The broker this worker consumes from. */ + /** The broker this worker consumes from — and the relay publishes to. */ AMQP_URL: z.string().min(1).default("amqp://127.0.0.1:5672"), + /** The relay's idle sleep between outbox sweeps, in milliseconds. */ + OUTBOX_POLL_MS: wholeNumber(200, 1, 60_000), }); /** The validated environment: every field present, typed, and in range. */ diff --git a/examples/order-amqp/src/index.ts b/examples/order-amqp-worker/src/index.ts similarity index 100% rename from examples/order-amqp/src/index.ts rename to examples/order-amqp-worker/src/index.ts diff --git a/examples/order-amqp/src/main.ts b/examples/order-amqp-worker/src/main.ts similarity index 73% rename from examples/order-amqp/src/main.ts rename to examples/order-amqp-worker/src/main.ts index f58c646..1f7fde2 100644 --- a/examples/order-amqp/src/main.ts +++ b/examples/order-amqp-worker/src/main.ts @@ -7,11 +7,11 @@ import { describeEnvIssues, readEnv, type Env } from "./env.js"; import { OrderAmqpModule } from "./module.js"; /** - * The fourth process, and — apart from the runtime it names — the same one - * `order-worker/src/main.ts` is: validate the environment, build the graph, - * serve it, and turn the exit report into a process exit code. No connection + * The broadcast process, and — apart from the runtime it names — the same + * shape every deployment's `main.ts` is: validate the environment, build the + * graph, serve it, and turn the exit report into a process exit code. No connection * dance here — `TypedAmqpWorker` owns its own connection, so unlike - * `order-temporal`'s `main.ts` there is nothing to open before `start` and + * `order-temporal-worker`'s `main.ts` there is nothing to open before `start` and * nothing to close after it. * * Typechecked by the gate, not executed by it — the example packages are @@ -20,7 +20,11 @@ import { OrderAmqpModule } from "./module.js"; const work = (env: Env): Promise => runMain( start(OrderAmqpModule, { - runtime: orderAmqpRuntime({ contract: orderContract, urls: [env.AMQP_URL] }), + runtime: orderAmqpRuntime({ + contract: orderContract, + urls: [env.AMQP_URL], + relay: { pollMs: env.OUTBOX_POLL_MS }, + }), probes: { port: env.PROBE_PORT }, }), ); diff --git a/examples/order-amqp-worker/src/module.ts b/examples/order-amqp-worker/src/module.ts new file mode 100644 index 0000000..0566b73 --- /dev/null +++ b/examples/order-amqp-worker/src/module.ts @@ -0,0 +1,26 @@ +import { Module } from "@btravstack/di"; +import { + ApplicationModule, + Logger, + Outbox, + PlaceOrder, +} from "@btravstack/start-example-order-application"; +import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; + +/** + * The composition root of the broadcast deployment. `ApplicationModule` and + * `PersistenceModule` are booted here unchanged — the same pair every other + * deployment composes — under a runtime that relays the outbox onto a broker + * and consumes the broadcast back. + * + * The exports are this deployment's own selection: `Outbox` and `Logger` are + * what the runtime needs, `PlaceOrder` is what a writer in the same process + * (the specs; in production, `order-api` against the same database) places + * orders through. Declared here rather than imported from a sibling because + * sharing a composition root would share its transport dependency — one + * application, one root per process. + */ +export const OrderAmqpModule = Module("OrderAmqp")({ + imports: [ApplicationModule, PersistenceModule], + exports: [PlaceOrder, Outbox, Logger], +}); diff --git a/examples/order-amqp-worker/src/needs-gate.test-d.ts b/examples/order-amqp-worker/src/needs-gate.test-d.ts new file mode 100644 index 0000000..91657f5 --- /dev/null +++ b/examples/order-amqp-worker/src/needs-gate.test-d.ts @@ -0,0 +1,47 @@ +/** + * The compile-time half of the broadcast deployment: `orderAmqpRuntime` + * declares two ports in `needs`, and `start`'s phantom rest-tuple gate turns a + * module that does not export both into a call-site arity error. Type-checked + * by this package's `test:types` script, never executed. + * + * Together with `order-api`'s and `order-temporal-worker`'s, this is what + * makes the claim testable rather than asserted: runtimes with non-empty + * `needs`, all proven against the same application graph at the `start(...)` + * call site. + */ +import { Module } from "@btravstack/di"; +import { start } from "@btravstack/start-core"; +import { orderContract } from "@btravstack/start-example-order-amqp-contract"; +import { ApplicationModule, Logger, PlaceOrder } from "@btravstack/start-example-order-application"; +import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; + +import { orderAmqpRuntime } from "./amqp-runtime.js"; +import { OrderAmqpModule } from "./module.js"; + +const options = { + runtime: orderAmqpRuntime({ + contract: orderContract, + urls: ["amqp://127.0.0.1:5672"], + relay: { pollMs: 200 }, + }), + signals: false, + probes: false, +} as const; + +// Positive: the composition root exports both ports the runtime needs (and a +// writer's port it does not), so the gate collapses to an empty tuple and this +// is an ordinary two-argument call. +const _wired = start(OrderAmqpModule, options); + +// The same graph, one port short: `Outbox` is provided (the persistence layer +// carries it) but not exported, so it is not in the application context the +// runtime is handed. +const PartialAmqp = Module("PartialAmqp")({ + imports: [ApplicationModule, PersistenceModule], + exports: [PlaceOrder, Logger], +}); + +// Negative: the gate becomes a required two-element tuple naming the unmet need, +// and the call fails on arity. +// @ts-expect-error — UNSATISFIED RUNTIME NEEDS: the module does not export Outbox. +const _missingOutbox = start(PartialAmqp, options); diff --git a/examples/order-amqp-worker/src/outbox-relay.ts b/examples/order-amqp-worker/src/outbox-relay.ts new file mode 100644 index 0000000..9ac0b13 --- /dev/null +++ b/examples/order-amqp-worker/src/outbox-relay.ts @@ -0,0 +1,111 @@ +import { TypedAmqpClient } from "@amqp-contract/client"; +import type { Context } from "@btravstack/di"; +import type { OrderContract } from "@btravstack/start-example-order-amqp-contract"; +import { Logger, Outbox } from "@btravstack/start-example-order-application"; +import { P, fromSafePromise, type AsyncResult } from "unthrown"; + +/** The ports the relay resolves out of the application context. */ +export type RelayNeeds = typeof Outbox | typeof Logger; + +export type RelayOptions = { + /** The broker URLs the relay's own client connects to. */ + readonly urls: readonly string[]; + /** How long to sleep when a sweep finds the outbox empty. */ + readonly pollMs: number; +}; + +/** How many outbox rows one sweep publishes before sleeping. */ +const BATCH = 32; + +/** + * The other half of the outbox pattern: `OrderRepository.save` wrote the fact + * down, this loop says it out loud. Pull what is pending, publish it to the + * `orders` exchange, mark it sent — in commit order, forever, until told to + * stop. + * + * The loop is deliberately at-least-once. A crash between publish and + * `markPublished` re-publishes on the next sweep; a broker outage leaves rows + * pending and the sweep after the outage drains them. What is *never* possible + * is the inverse failure — an order committed whose event evaporated — because + * the event was committed by the same transaction as the order. + * + * Failure triage per event, all three channels: published → mark; a + * validation error → the row cannot ever serialize, a bug worth a log line, + * left pending so it stays visible; a defect (broker down, mid-flight close) + * → logged, left pending, retried next sweep. + */ +export const startOutboxRelay = ( + ctx: Context>, + contract: OrderContract, + { urls, pollMs }: RelayOptions, +): AsyncResult<{ readonly stop: () => AsyncResult }, never> => + TypedAmqpClient.create({ contract, urls: [...urls] }).map((client) => { + const outbox = ctx.get(Outbox); + const logger = ctx.get(Logger); + + let stopped = false; + let wake: (() => void) | undefined; + const sleep = (): Promise => + new Promise((resolve) => { + wake = resolve; + setTimeout(resolve, pollMs); + }); + + const sweep = async (): Promise => { + await outbox.pending(BATCH).match({ + ok: async (events) => { + const published: number[] = []; + for (const event of events) { + await client + .publish("orderPlaced", { orderId: event.orderId, quantity: event.quantity }) + .match({ + ok: () => { + published.push(event.id); + }, + errCases: (matcher) => + matcher.with(P.tag("@amqp-contract/MessageValidationError"), () => { + logger.info(`outbox event ${event.id} does not fit the contract; left pending`); + }), + defect: (cause) => { + logger.info( + `publishing outbox event ${event.id} failed, will retry: ${String(cause)}`, + ); + }, + }); + } + if (published.length > 0) { + await outbox.markPublished(published).match({ + ok: () => {}, + // `E = never`: the untouched builder is already exhaustive. + errCases: (matcher) => matcher, + defect: (cause) => { + logger.info(`marking outbox events published failed: ${String(cause)}`); + }, + }); + } + }, + errCases: (matcher) => matcher, + defect: (cause) => { + logger.info(`reading the outbox failed, will retry: ${String(cause)}`); + }, + }); + }; + + const running = (async () => { + while (!stopped) { + await sweep(); + if (!stopped) await sleep(); + } + })(); + + return { + stop: () => + fromSafePromise( + (async () => { + stopped = true; + wake?.(); + await running; + })(), + ).flatMap(() => client.close()), + }; + }); diff --git a/examples/order-amqp-worker/src/test-fixtures.ts b/examples/order-amqp-worker/src/test-fixtures.ts new file mode 100644 index 0000000..ad709ac --- /dev/null +++ b/examples/order-amqp-worker/src/test-fixtures.ts @@ -0,0 +1,116 @@ +import { it as amqpIt } from "@amqp-contract/testing"; +import type { AmqpTestFixtures } from "@amqp-contract/testing/extension"; +import { Module, Port, Provider, type Scope, type ServiceOf } from "@btravstack/di"; +import type { AmqpInfo } from "@btravstack/start-amqp"; +import { start, type RunningApp } from "@btravstack/start-core"; +import { orderContract } from "@btravstack/start-example-order-amqp-contract"; +import { Logger, Outbox, PlaceOrder } from "@btravstack/start-example-order-application"; +import { expect, type TestAPI } from "vitest"; + +import { orderAmqpRuntime } from "./amqp-runtime.js"; +import { OrderAmqpModule } from "./module.js"; + +type App = RunningApp; + +/** + * `X` is pinned to the three ports the composition root exports rather than + * left generic: `start`'s needs gate is a phantom rest parameter proven at the + * call site, and no proof is available inside a helper generic in the module's + * own exports. The runtime needs two of them; `PlaceOrder` is the writer's. + */ +type AmqpPorts = PlaceOrder | Outbox | Logger; + +type ServeOptions = { readonly drainTimeoutMs: number }; + +type Serve = (module: Module, options?: ServeOptions) => Promise>; + +/** + * `start` hands the application context to the runtime alone, so a spec cannot + * reach the services the way `Module.scoped` can. This captures the very + * instances the running app uses — the writer the spec places orders through + * (the same database the relay sweeps, which for `:memory:` SQLite is the + * whole point), the outbox it asserts against, and the logger the consumer + * writes its notification lines to. + */ +class ServicesTap extends Port("ServicesTap")<{ + readonly placeOrder: ServiceOf; + readonly outbox: ServiceOf; + readonly logger: ServiceOf; +}> {} + +const tappedAmqp = () => { + let services: ServiceOf | undefined; + + return { + module: Module("TappedAmqp")({ + imports: [OrderAmqpModule], + provides: [ + Provider(ServicesTap)([PlaceOrder, Outbox, Logger], { + sync: (placeOrder, outbox, logger) => { + services = { placeOrder, outbox, logger }; + return services; + }, + }), + ], + exports: [PlaceOrder, Outbox, Logger], + }), + services: (): ServiceOf => { + // oxlint-disable-next-line unthrown/no-throw -- a fixture misused before `serve` is a broken test, and the loudest possible answer is the right one + if (services === undefined) throw new Error("the app has not been served yet"); + return services; + }, + }; +}; + +export type AmqpFixtures = { + /** + * Boots an app whose relay publishes to — and whose consumer reads from — + * this test's own vhost, and registers its shutdown. The teardown runs even + * when the test fails, and it keeps the assertion the old `try`/`finally` + * blocks carried: the app exited `Ok`. + */ + readonly serve: Serve; + readonly tapped: ReturnType; +}; + +// Annotated explicitly: TS2883 otherwise refuses to name the inferred type, +// since `AmqpTestFixtures` reaches back into amqplib's `Channel` / +// `ChannelModel` / `ConsumeMessage` / `Options.Publish`. +export const it: TestAPI = amqpIt.extend({ + serve: async ({ amqpConnectionUrl }, use) => { + const shutdowns: (() => Promise)[] = []; + + const serve: Serve = async (module, options) => { + const app = start(module, { + runtime: orderAmqpRuntime({ + contract: orderContract, + urls: [amqpConnectionUrl], + // Tight on purpose: the specs wait on real broker round trips, and + // a production-sized idle sleep would be most of every test's clock. + relay: { pollMs: 25 }, + }), + signals: false, + probes: false, + preDrainDelayMs: 0, + ...options, + }); + shutdowns.push(async () => { + app.stop(); + await expect(app.exited).toBeOk(); + }); + // `runtimeInfo()` resolves once the worker is consuming — await it here + // so the caller's test body never races the worker's own startup. + await app.runtimeInfo(); + return app; + }; + + await use(serve); + + for (const shutdown of shutdowns) await shutdown(); + }, + + // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture + tapped: async ({}, use) => { + await use(tappedAmqp()); + }, +}); diff --git a/examples/order-amqp/src/vitest.d.ts b/examples/order-amqp-worker/src/vitest.d.ts similarity index 100% rename from examples/order-amqp/src/vitest.d.ts rename to examples/order-amqp-worker/src/vitest.d.ts diff --git a/examples/order-amqp/tsconfig.json b/examples/order-amqp-worker/tsconfig.json similarity index 100% rename from examples/order-amqp/tsconfig.json rename to examples/order-amqp-worker/tsconfig.json diff --git a/examples/order-amqp/tsconfig.test-d.json b/examples/order-amqp-worker/tsconfig.test-d.json similarity index 100% rename from examples/order-amqp/tsconfig.test-d.json rename to examples/order-amqp-worker/tsconfig.test-d.json diff --git a/examples/order-amqp/vitest.config.ts b/examples/order-amqp-worker/vitest.config.ts similarity index 100% rename from examples/order-amqp/vitest.config.ts rename to examples/order-amqp-worker/vitest.config.ts diff --git a/examples/order-amqp/README.md b/examples/order-amqp/README.md deleted file mode 100644 index b69f396..0000000 --- a/examples/order-amqp/README.md +++ /dev/null @@ -1,205 +0,0 @@ -# `@btravstack/start-core` example: the order AMQP worker - -The fourth deployment. The same application, the same persistence, the same -composition — driven by a real message broker instead of an HTTP server, an -in-memory queue or a durable execution engine, and served by -[`@btravstack/start-amqp`](../../packages/start-amqp) the way `order-api` is -served by `@btravstack/start-http` and `order-temporal` by -`@btravstack/start-temporal`. The contract it implements lives in -[`order-amqp-contract`](../order-amqp-contract), because a publisher that -sends a placement needs it and needs none of this. - -``` -src/amqp-runtime.ts the runtime's application half: the contract, the needs, and the handler's triage -src/module.ts OrderAmqpModule — the composition root -src/env.ts process.env validated through a schema, as a Result -src/main.ts the process: readEnv + start + runMain -src/test-fixtures.ts serve / tapped / unmodelled / gate, as Vitest fixtures, against a real RabbitMQ -``` - -## The point of this package - -`OrderAmqpModule` is `OrderApiModule`, `OrderWorkerModule` and -`OrderTemporalModule` with a different name: - -```ts -export const OrderAmqpModule = Module("OrderAmqp")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder, Logger], -}); -``` - -Nothing in `order-application` or `order-infrastructure` changed to make this -work, and nothing could have. **One process, one runtime** was already three -composition roots and three `Runtime` values; a fourth one, over a broker as -unlike a durable execution engine as it is unlike HTTP, is what turns "three" -into an unremarkable pattern. - -## The same `Err`, four transports - -`DuplicateOrder` is one value. Over HTTP there is a caller waiting to be told, -so it becomes a `CONFLICT` the client receives **as a value**. On the -in-memory queue there is no caller, so the message is **dead-lettered**. On -Temporal there is a caller again — a workflow, and behind it a client — so it -becomes a **typed contract error**. Here there is no caller either — a -publisher fired the message and moved on — so it becomes a -`NonRetryableError`: the broker's own vocabulary for "park it, do not ask -again." - -| unthrown | oRPC (`order-api`) | queue (`order-worker`) | Temporal (`order-temporal`) | AMQP (this package) | -| ---------------------- | ----------------------- | --------------------------- | --------------------------------------- | -------------------------------------------------------- | -| `Ok(order)` | the procedure's output | **ack** | the workflow's output | **ack** | -| `Err(InvalidQuantity)` | `INVALID_QUANTITY` | **dead-letter** | `InvalidQuantity`, **non-retryable** | `NonRetryableError`, **parked** | -| `Err(DuplicateOrder)` | `CONFLICT` | **dead-letter** | `OrderAlreadyPlaced`, **non-retryable** | `NonRetryableError`, **parked** | -| `Defect` | `INTERNAL_SERVER_ERROR` | **retry**, then dead-letter | **retried by the platform**, then fails | `RetryableError`, **retried by the broker**, then parked | - -The retry budget is contract configuration rather than a runtime constant — -`order-placements`'s `retry: { mode: "ttl-backoff", maxRetries: 3 }` — the -sharper form of the same claim the Temporal contract makes with -`nonRetryable`: naming a failure decides not only what happens to the message -but what the platform does next. - -## The triage, and the real signature it turned on - -```ts -const placeHandler = (contract: OrderContract) => - declareHandler>( - contract, - "placeOrder", - (message, _raw, { context }) => - context.ctx - .get(PlaceOrder) - .execute(message.payload.orderId, message.payload.quantity) - .map(() => undefined) - .mapErrCases((matcher) => - matcher.with( - P.tag("InvalidQuantity"), - P.tag("DuplicateOrder"), - (error) => new NonRetryableError(error._tag, error), - ), - ) - .recoverDefect((cause) => - ErrAsync(new RetryableError("placing the order failed", cause)), - ), - ); -``` - -Built from the `contract` `orderAmqpRuntime` itself is handed, rather than -from the module's own top-level `orderContract` — the same reason -`-temporal`'s `activities` builder threads its own `contract` into -`declareActivitiesHandler` — so the parameter is load-bearing rather than a -decorative pass-through. - -Both `InvalidQuantity` and `DuplicateOrder` collapse into the **same** arm, -unlike Temporal's two separate `errors.InvalidQuantity` / `errors.OrderAlreadyPlaced` -constructors — AMQP has no client waiting to branch on the name, so there is -nothing to preserve past "do not retry this." - -`NonRetryableError`'s constructor is `(message: string, cause?: unknown)` — a -`TaggedError`, not the free-form shape a first guess might reach for. Passing -`error` itself as the `cause` keeps the original domain error attached for -whoever reads the parked message's logs, the library's own documented pattern -(`new RetryableError('Payment failed', error)`). - -### `Defect` is not auto-retried here, unlike the other two runtimes - -`order-worker`'s `dispositionOf` explicitly folds `defect` into `retry`, and -Temporal's activity boundary re-throws a `Defect`'s cause so the platform's -_own_ retry policy picks it up. Measured directly against a real broker, -`@amqp-contract/worker` does **neither**: an `AsyncResult` that settles as a -`Defect` here is nacked once, immediately, under its original routing key, -never touching `order-placements`'s `retry` budget at all — -`routeHandlerError`'s `handleError` is reached only for a value already turned -into a `RetryableError` / `NonRetryableError`. So the `.recoverDefect(...)` -above is not decoration: without it, an infrastructure failure is parked on -the first attempt exactly like a named domain error, and "infrastructure comes -back" would be false on this transport alone. - -`retry.maxRetries: 3` also means something subtly different here than -Temporal's `maximumAttempts: 3`: it is retries **on top of** the first try, so -an unmodelled failure that never recovers is attempted **four** times in -total before it is parked — see `src/amqp-runtime.spec.ts`'s -`unmodelled.attempts()` assertion. - -## The handler and the middleware are two independent generic calls - -Unlike `temporal-contract`'s single `declareActivitiesHandler`, `amqp-contract` -separates `declareHandler` from the middleware slot entirely. **Both** need -their type arguments given explicitly: - -```ts -declareHandler>(...) -middleware: (host) => messageUnits(host) -``` - -Leave either bare and TypeScript infers `EmptyContext` from the call it is -still resolving, and `context.ctx` silently stops existing inside the handler -— caught twice already, in `packages/start-amqp`'s own suite (once for -`messageUnits`, once for `declareHandler` — a second, independent generic -call `-temporal`'s single `declareActivitiesHandler` never needed), and the -reason this package's runtime file mirrors that shape exactly rather than -reaching for `declareActivitiesHandler`'s one-generic convenience. - -## The delivery is the unit, and one line is what makes it one - -```ts -middleware: (host) => messageUnits(host), -``` - -`messageUnits` opens the kernel unit and injects the application context -through `amqp-contract`'s own per-message context channel — the same shape -`activityUnits` gives Temporal's activities. `needs` is `[PlaceOrder, Logger]` -rather than empty for the same reason as every sibling: the handler resolves -both out of the application context, and `start`'s phantom rest-tuple gate -proves the module exports them before anything runs. - -## `TypedAmqpWorker` owns its own connection - -Unlike `order-temporal`'s `main.ts`, this deployment's has no connection dance: -there is nothing to open before `start` and nothing to close after it. -`amqpRuntime` is handed the broker URLs and connects itself, so `main.ts` is -the simplest of the four — `readEnv` + `start` + `runMain`, with no `.finally`. - -## `Serving.info` with a queue in it, no port and no task queue - -```ts -const info = (await app.runtimeInfo()).get(); // { queues: ["order-placements"] } -``` - -Derived from the contract rather than configured, so it cannot disagree with -what the worker actually consumes — see `queuesOf` in -`packages/start-amqp/src/amqp-runtime.ts`. - -## Running it — and the one thing this example needs that most do not - -```bash -pnpm --filter @btravstack/start-example-order-amqp test # runtime specs + env specs -pnpm --filter @btravstack/start-example-order-amqp typecheck # the needs gate -``` - -**A Docker daemon.** `@amqp-contract/testing` boots one real RabbitMQ -container per vitest run (`globalSetup`), and every test in the suite gets its -own vhost — isolation that costs nothing per test, and the reason no test here -scopes its own queue name the way `order-temporal`'s scopes a task queue. - -`src/needs-gate.test-d.ts` pins the compile-time half: `orderAmqpRuntime` -declares `[PlaceOrder, Logger]` — two of the three ports the module exports, -because a runtime declares what _it_ needs — and a module missing either fails -`start`'s arity gate before anything runs. - -Every helper the specs need is a Vitest fixture in `src/test-fixtures.ts`, so -each file opens on `describe` and each test names its dependencies in its own -parameter list. Shutting an app down is the `serve` fixture's job, which is why -no test here has a `try`/`finally`. - -## No request/response, so the duplicate spec races nothing - -`order-temporal`'s and `order-worker`'s duplicate-order specs chain a second -call onto the first's settlement — `executeWorkflow(...).flatMap(() => -executeWorkflow(...))`, `queue.publish(...).flatMap(() => queue.publish(...))` -— because their transports hand back a result to chain on. AMQP is -fire-and-forget: nothing here settles. The spec publishes both placements -without waiting between them and lets the real database's own uniqueness -constraint decide which one wins, exactly the guarantee -`order-infrastructure`'s own suite exercises directly — the outcome is the -same regardless of which delivery the broker happens to process first. diff --git a/examples/order-amqp/src/amqp-runtime.spec.ts b/examples/order-amqp/src/amqp-runtime.spec.ts deleted file mode 100644 index 9e98379..0000000 --- a/examples/order-amqp/src/amqp-runtime.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, vi } from "vitest"; - -import { OrderAmqpModule } from "./module.js"; -import { it } from "./test-fixtures.js"; - -describe("orderAmqpRuntime", () => { - it("places an order through a real delivery", async ({ serve, tapped, publishMessage }) => { - // GIVEN the same composition the API, the queue worker and the Temporal - // worker boot — `ApplicationModule` and `PersistenceModule`, unchanged — - // consumed by a real broker instead - await serve(tapped.module); - - // WHEN a placement is published over the real exchange and routed by the - // real contract to the real queue - publishMessage( - { exchange: "orders", routingKey: "order.placement.requested" }, - { orderId: "o-1", quantity: 2 }, - { messageId: "m-1" }, - ); - await vi.waitUntil(() => tapped.lines().length === 1); - - // THEN the payload the wire carried reached the use case intact — decoded, - // routed and resolved through the real DI graph — under the publisher's - // own message id as the trace - expect(tapped.lines()).toEqual(["[m-1] placing order o-1 (quantity 2)"]); - }); - - it("parks the DuplicateOrder the API answers CONFLICT for, rather than retrying it", async ({ - serve, - publishMessage, - initConsumer, - }) => { - // GIVEN a worker consuming the real deployment, and a consumer of its own - // bound to the dead-letter exchange the contract parks to. Bound only - // after the worker has declared the topology — `orders-dlx` does not exist - // until `TypedAmqpWorker.create` declares it. - await serve(OrderAmqpModule); - const waitForParked = await initConsumer("orders-dlx", "order.placement.requested"); - - // WHEN two placements for the same order id are published. AMQP has no - // request/response to chain on the way `order-temporal`'s - // `executeWorkflow` and `order-worker`'s `queue.publish` do, so both are - // sent without waiting — the database's own uniqueness constraint decides - // which one wins regardless of delivery order, the same guarantee the real - // repository gives `order-infrastructure`'s own suite - publishMessage( - { exchange: "orders", routingKey: "order.placement.requested" }, - { orderId: "o-1", quantity: 2 }, - { messageId: "m-1" }, - ); - publishMessage( - { exchange: "orders", routingKey: "order.placement.requested" }, - { orderId: "o-1", quantity: 2 }, - { messageId: "m-2" }, - ); - - // THEN the load-bearing assertion of this whole example: the identical - // `Err` the oRPC runtime turns into an inferable CONFLICT and the queue - // worker dead-letters is here a `NonRetryableError`, parked on the DLQ - // without ever touching the retry budget. `NonRetryableError` routes - // straight to `orders-dlx` on the first attempt — never through a wait - // queue — so the parked copy still carries its original routing key. - const parked = await waitForParked(); - expect(parked.map((message) => JSON.parse(message.content.toString())) as unknown[]).toEqual([ - { orderId: "o-1", quantity: 2 }, - ]); - }); - - it("lets the broker retry a Defect recovered into a RetryableError, up to the contract's own budget", async ({ - serve, - unmodelled, - publishMessage, - initConsumer, - }) => { - // GIVEN a repository whose failure nobody modelled, so it is a `Defect` — - // which this branch's doctrine says the broker does NOT retry on its own. - // The four attempts below happen only because `placeHandler`'s - // `recoverDefect` turns it into a `RetryableError` first, and the - // dead-letter exchange it eventually lands on once retries run out - await serve(unmodelled.module); - const waitForParked = await initConsumer("orders-dlx", "order-placements"); - - // WHEN a delivery reaches it. Each `ttl-backoff` retry republishes through - // a wait queue via the default exchange, so by the time the third and - // final attempt is exhausted the message's routing key is the main - // queue's own name rather than the original one — the counterpart to - // `order-temporal`'s "lets Temporal retry an unmodelled failure" - publishMessage( - { exchange: "orders", routingKey: "order.placement.requested" }, - { orderId: "o-1", quantity: 1 }, - ); - await waitForParked(); - - // THEN the third channel takes the third route again, and this time the - // broker owns it: an unnamed failure gets `maxRetries: 3` retries on top - // of its first try — four attempts in total, unlike Temporal's - // `maximumAttempts` which counts the first try as one of its three. The - // two named failures are parked on the first attempt instead. The queue - // worker hand-rolls this with an attempt budget; here it is a line of - // contract. - expect(unmodelled.attempts()).toBe(4); - }); - - it("publishes the queue it drains on Serving.info", async ({ serve }) => { - // GIVEN a worker consuming the contract's one queue - const app = await serve(OrderAmqpModule); - - // WHEN the kernel is asked what the runtime published about itself - const info = app.runtimeInfo(); - - // THEN the same channel the API publishes `{ port, prefix }` on, the queue - // worker `{ queue, concurrency }` and Temporal `{ taskQueue, namespace }` - // carries the set of queues an operator would look at in the management UI - await expect(info).toBeOkWith({ queues: ["order-placements"] }); - }); - - it("runs each delivery in its own unit, with its own trace id", async ({ - serve, - tapped, - publishMessage, - }) => { - // GIVEN the real graph with the very `Logger` instance the use cases write to - await serve(tapped.module); - - // WHEN two placements are delivered, each carrying its own publisher-minted message id - publishMessage( - { exchange: "orders", routingKey: "order.placement.requested" }, - { orderId: "o-1", quantity: 1 }, - { messageId: "m-1" }, - ); - publishMessage( - { exchange: "orders", routingKey: "order.placement.requested" }, - { orderId: "o-2", quantity: 1 }, - { messageId: "m-2" }, - ); - await vi.waitUntil(() => tapped.traces().length === 2); - - // THEN two deliveries, two units, two distinct trace ids — each the - // publisher's own message id — and never the out-of-unit `[-]`. The unit - // id is minted per delivery, since a delivery tag restarts at 1 after a - // reconnect and cannot carry the kernel's uniqueness rule. Sorted before - // comparing: unlike a Temporal workflow execution or a single in-memory - // queue, nothing here guarantees the broker delivers two independent - // publishes in send order. - expect([...tapped.traces()].sort()).toEqual(["[m-1]", "[m-2]"]); - }); - - it("lets an in-flight delivery finish while draining", async ({ - serve, - gate, - publishMessage, - }) => { - // GIVEN a delivery held open inside the repository, through the real - // broker rather than the generic `Greeting` fixture `@btravstack/start-amqp` - // itself is tested against - const app = await serve(gate.module); - publishMessage( - { exchange: "orders", routingKey: "order.placement.requested" }, - { orderId: "o-1", quantity: 1 }, - ); - await gate.arrived; - - // WHEN the drain starts and the delivery is released only once the phase - // moved. `vi.waitUntil` synchronises rather than asserts — the drain - // samples `inFlightAtStart` in the same synchronous turn that advances the - // phase, so releasing afterwards is what makes the report exact rather - // than racy. - app.requestDrain(); - await vi.waitUntil(() => app.phase() === "draining"); - gate.release(); - - // THEN the kernel counted it as one unit that COMPLETED, through this - // deployment's own real composition rather than the package's synthetic one - const report = await app.exited; - expect(report).toBeOkWith( - expect.objectContaining({ drain: { inFlightAtStart: 1, completed: 1, abandoned: 0 } }), - ); - }); -}); diff --git a/examples/order-amqp/src/amqp-runtime.ts b/examples/order-amqp/src/amqp-runtime.ts deleted file mode 100644 index 1b61065..0000000 --- a/examples/order-amqp/src/amqp-runtime.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { declareHandler, NonRetryableError, RetryableError } from "@amqp-contract/worker"; -import { - amqpRuntime, - messageUnits, - type AmqpInfo, - type MessageUnitContext, -} from "@btravstack/start-amqp"; -import type { Runtime } from "@btravstack/start-core"; -import type { OrderContract } from "@btravstack/start-example-order-amqp-contract"; -import { Logger, PlaceOrder } from "@btravstack/start-example-order-application"; -import { ErrAsync, P } from "unthrown"; - -/** - * The ports this runtime resolves out of the application context — the same - * two the queue worker and the Temporal worker need, and for the same reason: - * `FindOrder` is not part of any delivery this worker consumes, and a runtime - * declares what *it* needs rather than what the module happens to export. - * - * Non-empty on purpose: it is what makes `start`'s arity gate mean something - * (`src/needs-gate.test-d.ts` pins both directions). - */ -type AmqpNeeds = typeof PlaceOrder | typeof Logger; - -/** - * A `Runtime` serving the order application as an AMQP consumer — and, since - * `@btravstack/start-amqp` shipped, no longer a hand-rolled one. - * - * What is left here is the application's half: the contract, the two ports the - * handler resolves, and the triage from a domain `Err` to `HandlerError`. The - * worker's lifecycle, the unit per delivery and the release at the kernel's - * deadline are the package's, which is the point — the fourth deployment - * consumes a runtime package exactly as `order-api` consumes - * `@btravstack/start-http` and `order-temporal` consumes - * `@btravstack/start-temporal`. - */ -export const orderAmqpRuntime = ({ - contract, - ...transport -}: { - /** The contract, and with it the queue this worker consumes. */ - readonly contract: OrderContract; - /** The broker URLs `TypedAmqpWorker` connects to — it owns the connection. */ - readonly urls: readonly string[]; -}): Runtime => - amqpRuntime({ - ...transport, - contract, - needs: [PlaceOrder, Logger], - // `placeHandler(contract)` rather than a pre-built constant: `contract` is - // what every caller passes in (`main.ts`, the needs-gate type test), and - // building the handler from that parameter — the same way `-temporal`'s - // `activities` builder threads its own `contract` into - // `declareActivitiesHandler` — is what makes it load-bearing rather than - // a decorative pass-through to the module's own top-level `orderContract`. - handlers: () => ({ placeOrder: placeHandler(contract) }), - middleware: (host) => messageUnits(host), - }); - -/** - * The one handler, and the fourth sibling of the same fold. `DuplicateOrder` - * is a `CONFLICT` over HTTP because a caller is waiting to be told, a - * dead-letter on the in-memory queue because none is, a typed contract error - * on Temporal because a workflow is waiting — and here a `NonRetryableError`, - * which is the broker's vocabulary for the same permanent answer: park it, do - * not ask again. - * - * `NonRetryableError`'s constructor is `(message: string, cause?: unknown)` — - * a `TaggedError`, not the free-form shape a guess might reach for. `error._tag` - * is a legible message on its own (`"InvalidQuantity"` / `"DuplicateOrder"`), - * and passing `error` itself as the cause keeps the original domain error - * attached for whoever reads the DLQ'd message's logs, the same way the - * library's own examples pair a message with a cause - * (`new RetryableError('Payment failed', error)`). - * - * Every named case is a compile error to omit. A `Defect` is a THIRD thing, - * and it is the one place this fold does not repeat itself: Temporal's own - * activity boundary re-throws a `Defect`'s cause so the platform's *native* - * retry policy picks it up, and the queue worker's `dispositionOf` explicitly - * folds `defect` into `retry`. `@amqp-contract/worker`'s own dispatch does - * neither — measured directly against a real broker, an `AsyncResult` that - * settles as a `Defect` here is nacked **once**, immediately, under its - * original routing key, never touching `order-placements`'s `retry` budget at - * all (`routeHandlerError`'s `handleError` is reached only for a value the - * matcher above already turned into a `RetryableError` / `NonRetryableError`). - * So an infrastructure failure has to be turned into a `RetryableError` - * explicitly, or "infrastructure comes back" would be false on this transport - * alone. - */ -const placeHandler = (contract: OrderContract) => - declareHandler>( - contract, - "placeOrder", - (message, _raw, { context }) => - context.ctx - .get(PlaceOrder) - .execute(message.payload.orderId, message.payload.quantity) - .map(() => undefined) - .mapErrCases((matcher) => - matcher.with( - P.tag("InvalidQuantity"), - P.tag("DuplicateOrder"), - (error) => new NonRetryableError(error._tag, error), - ), - ) - .recoverDefect((cause) => ErrAsync(new RetryableError("placing the order failed", cause))), - ); diff --git a/examples/order-amqp/src/module.ts b/examples/order-amqp/src/module.ts deleted file mode 100644 index 1dbf496..0000000 --- a/examples/order-amqp/src/module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Module } from "@btravstack/di"; -import { - ApplicationModule, - FindOrder, - Logger, - PlaceOrder, -} from "@btravstack/start-example-order-application"; -import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; - -/** - * The composition root of the fourth deployment — and, imports and exports - * alike, the same one `OrderApiModule`, `OrderWorkerModule` and - * `OrderTemporalModule` are. `ApplicationModule` and `PersistenceModule` are - * booted here unchanged, under a runtime that speaks AMQP instead of HTTP, an - * in-memory queue or a durable execution engine. - * - * Declared here rather than imported from a sibling for the reason - * `order-worker` states: sharing the module would share that deployment's - * transport dependency, and a broker consumer that installs a web server to - * reach its use cases would falsify the very thing this demonstrates. Four - * processes, four composition roots, one application. - */ -export const OrderAmqpModule = Module("OrderAmqp")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder, Logger], -}); diff --git a/examples/order-amqp/src/needs-gate.test-d.ts b/examples/order-amqp/src/needs-gate.test-d.ts deleted file mode 100644 index db07695..0000000 --- a/examples/order-amqp/src/needs-gate.test-d.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * The compile-time half of the fourth deployment: `orderAmqpRuntime` declares - * two ports in `needs`, and `start`'s phantom rest-tuple gate turns a module - * that does not export both into a call-site arity error. Type-checked by this - * package's `test:types` script, never executed. - * - * Together with `order-api`'s, `order-worker`'s and `order-temporal`'s, this is - * what makes the claim testable rather than asserted: four runtimes with - * non-empty `needs`, all proven against the same application graph at the - * `start(...)` call site. - */ -import { Module } from "@btravstack/di"; -import { start } from "@btravstack/start-core"; -import { orderContract } from "@btravstack/start-example-order-amqp-contract"; -import { - ApplicationModule, - FindOrder, - Logger, - PlaceOrder, -} from "@btravstack/start-example-order-application"; -import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; - -import { orderAmqpRuntime } from "./amqp-runtime.js"; -import { OrderAmqpModule } from "./module.js"; - -const options = { - runtime: orderAmqpRuntime({ contract: orderContract, urls: ["amqp://127.0.0.1:5672"] }), - signals: false, - probes: false, -} as const; - -// Positive: the composition root exports both ports the runtime needs (and a -// third it does not), so the gate collapses to an empty tuple and this is an -// ordinary two-argument call. -const _wired = start(OrderAmqpModule, options); - -// The same graph, one port short: `Logger` is provided (the interactors depend -// on it) but not exported, so it is not in the application context the runtime -// is handed. -const PartialAmqp = Module("PartialAmqp")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder], -}); - -// Negative: the gate becomes a required two-element tuple naming the unmet need, -// and the call fails on arity. -// @ts-expect-error — UNSATISFIED RUNTIME NEEDS: the module does not export Logger. -const _missingLogger = start(PartialAmqp, options); diff --git a/examples/order-amqp/src/test-fixtures.ts b/examples/order-amqp/src/test-fixtures.ts deleted file mode 100644 index b016a32..0000000 --- a/examples/order-amqp/src/test-fixtures.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { it as amqpIt } from "@amqp-contract/testing"; -import type { AmqpTestFixtures } from "@amqp-contract/testing/extension"; -import { Module, Port, Provider, type Scope, type ServiceOf } from "@btravstack/di"; -import type { AmqpInfo } from "@btravstack/start-amqp"; -import { start, type RunningApp } from "@btravstack/start-core"; -import { orderContract } from "@btravstack/start-example-order-amqp-contract"; -import { - ApplicationModule, - FindOrder, - Logger, - OrderRepository, - PlaceOrder, -} from "@btravstack/start-example-order-application"; -import { OrderNotFound } from "@btravstack/start-example-order-domain"; -import { ErrAsync, fromSafePromise } from "unthrown"; -import { expect, type TestAPI } from "vitest"; - -import { orderAmqpRuntime } from "./amqp-runtime.js"; -import { OrderAmqpModule } from "./module.js"; - -type App = RunningApp; - -/** - * `X` is pinned to the three ports the composition roots export rather than - * left generic: `start`'s needs gate is a phantom rest parameter proven at the - * call site, and no proof is available inside a helper generic in the module's - * own exports. The runtime needs only two of them. - */ -type AmqpPorts = PlaceOrder | FindOrder | Logger; - -/** - * The kernel options a test may override. Only the drain budget so far — a - * test that strands a delivery needs the deadline to arrive in milliseconds - * rather than in the default twenty seconds. - */ -type ServeOptions = { readonly drainTimeoutMs: number }; - -type Serve = (module: Module, options?: ServeOptions) => Promise>; - -const persistenceOf = (repository: ServiceOf) => - Module("StubPersistence")({ - provides: [Provider(OrderRepository)({ value: repository })], - exports: [OrderRepository], - }); - -/** - * A composition root shaped like the real one but with the repository swapped: - * same `ApplicationModule`, same runtime, same three exported ports, so the - * transport under test is unchanged. - */ -const amqpWith = (repository: ServiceOf) => - Module("StubAmqp")({ - imports: [ApplicationModule, persistenceOf(repository)], - exports: [PlaceOrder, FindOrder, Logger], - }); - -/** - * `start` hands the application context to the runtime alone, so a spec cannot - * reach `Logger` the way `Module.scoped` can. This publishes the very `Logger` - * service instance the use cases write to. - */ -class LoggerTap extends Port("LoggerTap")<{ readonly lines: () => readonly string[] }> {} - -const tappedAmqp = () => { - let read: () => readonly string[] = () => []; - - return { - module: Module("TappedAmqp")({ - imports: [OrderAmqpModule], - provides: [ - Provider(LoggerTap)([Logger], { - sync: (logger) => { - read = logger.lines; - return { lines: logger.lines }; - }, - }), - ], - exports: [PlaceOrder, FindOrder, Logger], - }), - /** The raw lines, unredacted — what proves a delivery's payload arrived intact. */ - lines: (): readonly string[] => read(), - traces: (): readonly string[] => read().map((line) => line.slice(0, line.indexOf("]") + 1)), - }; -}; - -/** - * A composition root whose repository fails in a way nobody modelled: no - * `qualify` triaged the rejection, so it is a `Defect` — and a defect is what - * the queue's own retry policy is for. `attempts()` reports how many times the - * broker redelivered it. - */ -const unmodelledAmqp = () => { - let attempts = 0; - - return { - module: amqpWith({ - save: () => { - attempts += 1; - return fromSafePromise(Promise.reject(new Error("the database is on fire"))); - }, - find: (id) => ErrAsync(new OrderNotFound({ id })), - }), - attempts: (): number => attempts, - }; -}; - -/** - * A repository whose `save` never settles until `release()` is called, and - * whose `arrived` promise reports the moment the delivery reached it. The - * drain spec turns on knowing a unit is genuinely in flight before the drain - * starts — polling a wall clock instead would be the flake. - */ -const gatedAmqp = () => { - let entered!: () => void; - const arrived = new Promise((resolve) => { - entered = resolve; - }); - let release!: () => void; - const held = new Promise((resolve) => { - release = resolve; - }); - - return { - module: amqpWith({ - save: (order) => { - entered(); - return fromSafePromise(held.then(() => order)); - }, - find: (id) => ErrAsync(new OrderNotFound({ id })), - }), - arrived, - release: () => release(), - }; -}; - -export type AmqpFixtures = { - /** - * Boots an app whose AMQP worker consumes the real `order-placements` queue - * of this test's own vhost, and registers its shutdown. The teardown runs - * even when the test fails, which is what a `try`/`finally` used to - * hand-roll — and it keeps the assertion those blocks carried: the app - * exited `Ok`. - */ - readonly serve: Serve; - readonly tapped: ReturnType; - readonly unmodelled: ReturnType; - readonly gate: ReturnType; -}; - -// Annotated explicitly: TS2883 otherwise refuses to name the inferred type, -// since `AmqpTestFixtures` reaches back into amqplib's `Channel` / -// `ChannelModel` / `ConsumeMessage` / `Options.Publish`. -export const it: TestAPI = amqpIt.extend({ - serve: async ({ amqpConnectionUrl }, use) => { - const shutdowns: (() => Promise)[] = []; - - const serve: Serve = async (module, options) => { - const app = start(module, { - runtime: orderAmqpRuntime({ contract: orderContract, urls: [amqpConnectionUrl] }), - signals: false, - probes: false, - preDrainDelayMs: 0, - ...options, - }); - shutdowns.push(async () => { - app.stop(); - await expect(app.exited).toBeOk(); - }); - // `runtimeInfo()` resolves once the worker is consuming — await it here - // so the caller's test body never races the worker's own startup. - await app.runtimeInfo(); - return app; - }; - - await use(serve); - - for (const shutdown of shutdowns) await shutdown(); - }, - - // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture - tapped: async ({}, use) => { - await use(tappedAmqp()); - }, - - // oxlint-disable-next-line no-empty-pattern -- see above - unmodelled: async ({}, use) => { - await use(unmodelledAmqp()); - }, - - // oxlint-disable-next-line no-empty-pattern -- see above - gate: async ({}, use) => { - const gate = gatedAmqp(); - await use(gate); - // Released on every exit path, so a delivery a test deliberately stranded - // cannot outlive the test that stranded it. - gate.release(); - }, -}); diff --git a/examples/order-api/README.md b/examples/order-api/README.md index ce252e0..bed78ad 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -30,9 +30,11 @@ adapter in between: | `Err(error)` | a returned `ORPCError` | | `Defect` | `INTERNAL_SERVER_ERROR` | -None of it is the kernel's doing — which is what [`order-worker`](../order-worker) -demonstrates by folding the very same `Result` into ack / retry / dead-letter -over the very same composition root. +None of it is the kernel's doing — which is what +[`order-temporal-worker`](../order-temporal-worker) demonstrates by folding the +very same `Result` into typed contract errors over the very same composition +root, and [`order-amqp-worker`](../order-amqp-worker) by never folding it at a +consumer at all — its writes broadcast facts instead. `handlerResult` performs that elimination, and the `mapErrCases` in front of it is the triage point — the boundary where the application's vocabulary stops: diff --git a/examples/order-application/src/index.ts b/examples/order-application/src/index.ts index 66b0514..6fd4ad8 100644 --- a/examples/order-application/src/index.ts +++ b/examples/order-application/src/index.ts @@ -1,2 +1,11 @@ export { ApplicationModule } from "./module.js"; -export { FindOrder, Logger, OrderRepository, PlaceOrder } from "./ports.js"; +export { + FindOrder, + Logger, + OrderRepository, + Outbox, + PlaceOrder, + ShippingService, + StockService, + type OrderPlacedEvent, +} from "./ports.js"; diff --git a/examples/order-application/src/needs-gate.test-d.ts b/examples/order-application/src/needs-gate.test-d.ts index fde56fb..739bbc7 100644 --- a/examples/order-application/src/needs-gate.test-d.ts +++ b/examples/order-application/src/needs-gate.test-d.ts @@ -22,6 +22,7 @@ const Wired = Module("Wired")({ value: { save: (order: Order) => ErrAsync(new DuplicateOrder({ id: order.id })), find: (id: string) => ErrAsync(new OrderNotFound({ id })), + remove: (id: string) => ErrAsync(new OrderNotFound({ id })), }, }), ], diff --git a/examples/order-application/src/ports.ts b/examples/order-application/src/ports.ts index caddeca..cbdc386 100644 --- a/examples/order-application/src/ports.ts +++ b/examples/order-application/src/ports.ts @@ -4,6 +4,8 @@ import type { InvalidQuantity, Order, OrderNotFound, + OutOfStock, + ShippingUnavailable, } from "@btravstack/start-example-order-domain"; import type { AsyncResult } from "unthrown"; @@ -11,10 +13,53 @@ import type { AsyncResult } from "unthrown"; * The port the infrastructure layer fills. It is declared here, not in the * adapter, because the use cases own the shape they need — the direction that * keeps the dependency arrow pointing inwards. + * + * `save` promises more than a row: every successful write also leaves an + * `order.placed` entry in the outbox, atomically — the write and the fact of + * the write commit or roll back together. `remove` is the compensation arm the + * fulfillment saga leans on; deleting what does not exist is `OrderNotFound`, + * a value, so a duplicate compensation is inert rather than a crash. */ export class OrderRepository extends Port("OrderRepository")<{ readonly save: (order: Order) => AsyncResult; readonly find: (id: string) => AsyncResult; + readonly remove: (id: string) => AsyncResult; +}> {} + +/** One row of the outbox: the fact that an order was placed, awaiting broadcast. */ +export type OrderPlacedEvent = { + readonly id: number; + readonly orderId: string; + readonly quantity: number; +}; + +/** + * The read side of the transactional outbox. The write side has no port at + * all — it *is* `OrderRepository.save`, which appends the event in the same + * transaction as the row. This port exists for whichever deployment relays the + * outbox onto a broker: pull what is pending, publish it, mark it sent. Both + * operations are infallible in the application's terms — a database that will + * not answer is a defect, not a domain outcome. + */ +export class Outbox extends Port("Outbox")<{ + readonly pending: (limit: number) => AsyncResult; + readonly markPublished: (ids: readonly number[]) => AsyncResult; +}> {} + +/** + * The two fulfillment ports the saga orchestrates around placement. In a real + * system they are other services reached over a wire; the deployment that + * needs them provides the adapter. `reserve` and `arrange` answer with the + * domain's own permanent failures; `release` is compensation and compensation + * must not invent new ways to fail. + */ +export class StockService extends Port("StockService")<{ + readonly reserve: (orderId: string, quantity: number) => AsyncResult; + readonly release: (orderId: string) => AsyncResult; +}> {} + +export class ShippingService extends Port("ShippingService")<{ + readonly arrange: (orderId: string) => AsyncResult; }> {} export class Logger extends Port("Logger")<{ diff --git a/examples/order-application/src/test-fixtures.ts b/examples/order-application/src/test-fixtures.ts index e884976..62734b8 100644 --- a/examples/order-application/src/test-fixtures.ts +++ b/examples/order-application/src/test-fixtures.ts @@ -25,6 +25,7 @@ const stubRepository = Provider(OrderRepository)({ const row = rows.get(id); return row === undefined ? ErrAsync(new OrderNotFound({ id })) : OkAsync(row); }, + remove: (id: string) => (rows.delete(id) ? OkAsync() : ErrAsync(new OrderNotFound({ id }))), }; }, }); diff --git a/examples/order-config/README.md b/examples/order-config/README.md index 5b18b69..582109a 100644 --- a/examples/order-config/README.md +++ b/examples/order-config/README.md @@ -10,7 +10,7 @@ src/env.spec.ts the seven cases, against the fragments themselves ## Why a package rather than a copy in each deployment -`order-api`, `order-worker` and `order-temporal` each validate `process.env` +`order-api`, `order-worker` and `order-temporal-worker` each validate `process.env` through a schema and return it as a `Result`. That much is the point, and each keeps its own schema: its variables, its defaults, its bounds. What they were also each keeping was the _fragment_ — @@ -43,8 +43,8 @@ integer, `99999` is out of range. ## What each deployment still owns Its variables, their defaults, and whatever is genuinely its own — so -`order-worker`'s spec pins that `CONCURRENCY=0` is rejected where a port's own -bounds would allow it, and `order-temporal`'s pins that a blank +`order-amqp-worker`'s spec pins that `OUTBOX_POLL_MS=0` is rejected where a port's +own bounds would allow it, and `order-temporal-worker`'s pins that a blank `TEMPORAL_NAMESPACE` is an error rather than a default. Those are facts about a deployment, not about the fragment. diff --git a/examples/order-domain/src/fulfillment.ts b/examples/order-domain/src/fulfillment.ts new file mode 100644 index 0000000..660b3e0 --- /dev/null +++ b/examples/order-domain/src/fulfillment.ts @@ -0,0 +1,19 @@ +import { TaggedError } from "unthrown"; + +/** + * The two failures fulfillment can answer with beyond placement's own. They + * live here — not in the application's ports file — for the same reason + * `DuplicateOrder` does: they are domain answers a caller is entitled to + * branch on, whatever adapter happens to produce them. + */ + +/** The stock on hand cannot cover the order. A permanent answer for this order. */ +export class OutOfStock extends TaggedError("OutOfStock")<{ + readonly id: string; + readonly quantity: number; +}> {} + +/** No carrier can take the shipment. Permanent for this order, too. */ +export class ShippingUnavailable extends TaggedError("ShippingUnavailable")<{ + readonly id: string; +}> {} diff --git a/examples/order-domain/src/index.ts b/examples/order-domain/src/index.ts index afa297c..f4a3457 100644 --- a/examples/order-domain/src/index.ts +++ b/examples/order-domain/src/index.ts @@ -1,3 +1,4 @@ +export { OutOfStock, ShippingUnavailable } from "./fulfillment.js"; export { DuplicateOrder, InvalidQuantity, diff --git a/examples/order-infrastructure/prisma/schema.prisma b/examples/order-infrastructure/prisma/schema.prisma index 3370a33..84d9711 100644 --- a/examples/order-infrastructure/prisma/schema.prisma +++ b/examples/order-infrastructure/prisma/schema.prisma @@ -18,3 +18,13 @@ model Order { orderId String @unique quantity Int } + +// The transactional outbox: one row per fact worth broadcasting, written in +// the same transaction as the write it describes. `publishedAt` is the relay's +// bookkeeping — NULL means pending. +model OutboxMessage { + id Int @id @default(autoincrement()) + orderId String + quantity Int + publishedAt DateTime? +} diff --git a/examples/order-infrastructure/src/database.ts b/examples/order-infrastructure/src/database.ts index 7b66240..dde2d1b 100644 --- a/examples/order-infrastructure/src/database.ts +++ b/examples/order-infrastructure/src/database.ts @@ -13,6 +13,7 @@ import { PrismaClient } from "./generated/prisma/client.ts"; const DDL = [ `CREATE TABLE "Order" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "orderId" TEXT NOT NULL, "quantity" INTEGER NOT NULL)`, `CREATE UNIQUE INDEX "Order_orderId_key" ON "Order"("orderId")`, + `CREATE TABLE "OutboxMessage" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "orderId" TEXT NOT NULL, "quantity" INTEGER NOT NULL, "publishedAt" DATETIME)`, ]; const createClient = () => diff --git a/examples/order-infrastructure/src/index.ts b/examples/order-infrastructure/src/index.ts index 1a8ba7d..738ce2c 100644 --- a/examples/order-infrastructure/src/index.ts +++ b/examples/order-infrastructure/src/index.ts @@ -1,3 +1,4 @@ export { openDatabase, type OrderDatabaseClient } from "./database.js"; export { PersistenceModule } from "./module.js"; export { prismaOrderRepository } from "./prisma-order-repository.js"; +export { prismaOutbox } from "./prisma-outbox.js"; diff --git a/examples/order-infrastructure/src/module.ts b/examples/order-infrastructure/src/module.ts index e072b26..a01c112 100644 --- a/examples/order-infrastructure/src/module.ts +++ b/examples/order-infrastructure/src/module.ts @@ -1,8 +1,9 @@ import { Module } from "@btravstack/di"; -import { OrderRepository } from "@btravstack/start-example-order-application"; +import { OrderRepository, Outbox } from "@btravstack/start-example-order-application"; import { orderDatabaseProvider } from "./database.js"; import { orderRepositoryProvider } from "./prisma-order-repository.js"; +import { outboxProvider } from "./prisma-outbox.js"; /** * The other half of `ApplicationModule`'s arity gate: this module provides the @@ -15,6 +16,6 @@ import { orderRepositoryProvider } from "./prisma-order-repository.js"; * root that forgets the scope does not compile. */ export const PersistenceModule = Module("Persistence")({ - provides: [orderDatabaseProvider, orderRepositoryProvider], - exports: [OrderRepository], + provides: [orderDatabaseProvider, orderRepositoryProvider, outboxProvider], + exports: [OrderRepository, Outbox], }); diff --git a/examples/order-infrastructure/src/prisma-order-repository.ts b/examples/order-infrastructure/src/prisma-order-repository.ts index 5b38f85..c085d20 100644 --- a/examples/order-infrastructure/src/prisma-order-repository.ts +++ b/examples/order-infrastructure/src/prisma-order-repository.ts @@ -1,7 +1,7 @@ import { Provider, type ServiceOf } from "@btravstack/di"; import { OrderRepository } from "@btravstack/start-example-order-application"; import { DuplicateOrder, Order, OrderNotFound } from "@btravstack/start-example-order-domain"; -import { Err, P, type Result } from "unthrown"; +import { Err, Ok, P, type Result } from "unthrown"; import { OrderDatabase, type OrderDatabaseClient } from "./database.js"; @@ -32,9 +32,19 @@ const hydrate = (row: OrderRow): Result => * which is the point: infrastructure vocabulary stops here. */ export const prismaOrderRepository = (db: OrderDatabaseClient): ServiceOf => ({ + // The transactional-outbox write: the row and the fact of the row commit + // together or not at all. `$tryTransaction`'s callback speaks `AsyncResult`, + // so a failed insert rolls the pair back and surfaces as the same value it + // would have been alone — no second bookkeeping path for the event to miss. save: (order) => - db.order - .tryCreate({ data: { orderId: order.id, quantity: order.quantity } }) + db + .$tryTransaction((tx) => + tx.order + .tryCreate({ data: { orderId: order.id, quantity: order.quantity } }) + .flatMap(() => + tx.outboxMessage.tryCreate({ data: { orderId: order.id, quantity: order.quantity } }), + ), + ) .mapErrCases((matcher, defect) => matcher .with(P.tag("UniqueConstraintViolation"), () => new DuplicateOrder({ id: order.id })) @@ -47,6 +57,21 @@ export const prismaOrderRepository = (db: OrderDatabaseClient): ServiceOf (row === null ? Err(new OrderNotFound({ id })) : hydrate(row))), + + // Compensation's persistence arm. `deleteMany` rather than `delete` so a + // missing row is a countable outcome instead of a P2025: compensating a + // placement that never landed answers `OrderNotFound`, a value the saga can + // ignore on purpose. The outbox row, if the placement committed one, stays — + // the broadcast says what happened, and a cancellation is a *further* fact, + // not an eraser (`order.cancelled` is the reader's exercise). + remove: (id) => + db.order + .tryDeleteMany({ where: { orderId: id } }) + .mapErrCases((matcher, defect) => + // This schema has no relation to violate; reaching it is a bug. + matcher.with(P.tag("ForeignKeyViolation"), (violation) => defect(violation)), + ) + .flatMap((batch) => (batch.count === 0 ? Err(new OrderNotFound({ id })) : Ok())), }); export const orderRepositoryProvider = Provider(OrderRepository)([OrderDatabase], { diff --git a/examples/order-infrastructure/src/prisma-outbox.spec.ts b/examples/order-infrastructure/src/prisma-outbox.spec.ts new file mode 100644 index 0000000..81d990e --- /dev/null +++ b/examples/order-infrastructure/src/prisma-outbox.spec.ts @@ -0,0 +1,58 @@ +import { P } from "unthrown"; +import { describe, expect } from "vitest"; + +import { it } from "./test-fixtures.js"; + +describe("the transactional outbox", () => { + it("appends an event in the same write as the order", async ({ repository, outbox, anOrder }) => { + // GIVEN a fresh database + // WHEN an order is saved + const events = await repository.save(anOrder("o-1", 3)).flatMap(() => outbox.pending(10)); + + // THEN the fact of the write is already in the outbox — no second call, + // no second chance to forget + expect(events).toBeOkWith([expect.objectContaining({ orderId: "o-1", quantity: 3 })]); + }); + + it("leaves no event behind when the write rolls back", async ({ + repository, + outbox, + anOrder, + }) => { + // GIVEN an order already stored + // WHEN the same id is saved again — a real UNIQUE violation, and the + // transaction it happened in rolls back + const events = await repository + .save(anOrder("o-1", 1)) + .flatMap(() => repository.save(anOrder("o-1", 2))) + .recoverErrCases((matcher) => matcher.with(P.tag("DuplicateOrder"), () => undefined)) + .flatMap(() => outbox.pending(10)); + + // THEN only the first placement's event exists — the duplicate's outbox + // row rolled back with its order row + expect(events).toBeOkWith([expect.objectContaining({ orderId: "o-1", quantity: 1 })]); + }); + + it("marks published events so the relay never re-reads them", async ({ + repository, + outbox, + anOrder, + }) => { + // GIVEN two placed orders and their pending events + const pending = ( + await repository + .save(anOrder("o-1", 1)) + .flatMap(() => repository.save(anOrder("o-2", 2))) + .flatMap(() => outbox.pending(10)) + ).getOrThrow(); + + // WHEN the first is marked published + const first = pending[0]; + // oxlint-disable-next-line unthrown/no-throw -- a missing row here is a broken GIVEN, and the loudest possible answer is the right one + if (first === undefined) throw new Error("expected a pending event"); + const rest = await outbox.markPublished([first.id]).flatMap(() => outbox.pending(10)); + + // THEN only the second remains pending + expect(rest).toBeOkWith([expect.objectContaining({ orderId: "o-2" })]); + }); +}); diff --git a/examples/order-infrastructure/src/prisma-outbox.ts b/examples/order-infrastructure/src/prisma-outbox.ts new file mode 100644 index 0000000..35ae965 --- /dev/null +++ b/examples/order-infrastructure/src/prisma-outbox.ts @@ -0,0 +1,50 @@ +import { Provider, type ServiceOf } from "@btravstack/di"; +import { Outbox } from "@btravstack/start-example-order-application"; +import { P } from "unthrown"; + +import { OrderDatabase, type OrderDatabaseClient } from "./database.js"; + +/** + * The outbox's read side. The write side lives inside + * `prismaOrderRepository.save` — same transaction as the order row, which is + * the entire pattern — so this adapter only ever pulls and marks. + * + * Both operations promise `never`: the port declares that a database that will + * not answer is a defect, and this adapter keeps that promise by not + * `mapErrCases`-ing anything into `E` — `tryFindMany` / `tryUpdateMany` carry + * only `DriverError`, which the safe boundary would defect anyway. + * + * Ordered by `id` so the relay publishes in commit order; filtered on + * `publishedAt: null` so a crash between publish and mark re-delivers rather + * than loses — the outbox trades exactly-once for at-least-once on purpose, + * and the consumer's idempotency is where that trade is honoured. + */ +export const prismaOutbox = (db: OrderDatabaseClient): ServiceOf => ({ + pending: (limit) => + db.outboxMessage + .tryFindMany({ where: { publishedAt: null }, orderBy: { id: "asc" }, take: limit }) + .map((rows) => + rows.map((row) => ({ id: row.id, orderId: row.orderId, quantity: row.quantity })), + ) + .mapErrCases((matcher, defect) => matcher.with(P.tag("DriverError"), (e) => defect(e))), + + markPublished: (ids) => + db.outboxMessage + .tryUpdateMany({ where: { id: { in: [...ids] } }, data: { publishedAt: new Date() } }) + .map(() => undefined) + .mapErrCases((matcher, defect) => + // No relation to violate, no unique column touched, and a driver that + // will not answer is infrastructure: every arm is a bug by this + // schema's lights, so all three keep the port's `never` honest. + matcher.with( + P.tag("DriverError"), + P.tag("ForeignKeyViolation"), + P.tag("UniqueConstraintViolation"), + (e) => defect(e), + ), + ), +}); + +export const outboxProvider = Provider(Outbox)([OrderDatabase], { + sync: prismaOutbox, +}); diff --git a/examples/order-infrastructure/src/test-fixtures.ts b/examples/order-infrastructure/src/test-fixtures.ts index ffeb37c..c5240cd 100644 --- a/examples/order-infrastructure/src/test-fixtures.ts +++ b/examples/order-infrastructure/src/test-fixtures.ts @@ -1,9 +1,14 @@ import type { ServiceOf } from "@btravstack/di"; -import type { OrderRepository } from "@btravstack/start-example-order-application"; +import type { Outbox, OrderRepository } from "@btravstack/start-example-order-application"; import { placeOrder, type Order } from "@btravstack/start-example-order-domain"; import { test } from "vitest"; -import { openDatabase, prismaOrderRepository, type OrderDatabaseClient } from "./index.js"; +import { + openDatabase, + prismaOrderRepository, + prismaOutbox, + type OrderDatabaseClient, +} from "./index.js"; export type PersistenceFixtures = { /** @@ -13,6 +18,7 @@ export type PersistenceFixtures = { */ readonly db: OrderDatabaseClient; readonly repository: ServiceOf; + readonly outbox: ServiceOf; readonly anOrder: (id: string, quantity: number) => Order; }; @@ -31,6 +37,10 @@ export const it = test.extend({ await use(prismaOrderRepository(db)); }, + outbox: async ({ db }, use) => { + await use(prismaOutbox(db)); + }, + // oxlint-disable-next-line no-empty-pattern -- see above anOrder: async ({}, use) => { await use((id, quantity) => placeOrder(id, quantity).getOrThrow()); diff --git a/examples/order-temporal-contract/README.md b/examples/order-temporal-contract/README.md index 3e8b6b6..504d9ce 100644 --- a/examples/order-temporal-contract/README.md +++ b/examples/order-temporal-contract/README.md @@ -1,8 +1,15 @@ # `@btravstack/start-core` example: the order Temporal contract -The Temporal contract — one workflow, one activity, and the two errors a caller -may branch on — in a package of its own, depending on -`@temporal-contract/contract` and `zod`. +The Temporal contract — one saga workflow, its five activities (three forward +steps, two compensations), and the four errors a caller may branch on — in a +package of its own, depending on `@temporal-contract/contract` and `zod`. + +The shape of the saga is legible in the contract alone: the forward steps +declare their permanent domain answers `nonRetryable`, and the two +compensations declare **no errors at all** — compensation is the saga +un-deciding, and a step that could answer "no" would leave it stuck half-done, +so whatever infrastructure trouble a compensation hits stays undeclared and +Temporal retries it until it works. ``` src/contract.ts the contract: schemas, declared errors, activity options, task queue @@ -10,7 +17,7 @@ src/layering.test-d.ts the dependency rule, as a compile error src/test-fixtures.ts the contract's own schema, as a validator returning a Result ``` -## Why it is not part of `order-temporal` +## Why it is not part of `order-temporal-worker` A contract is a **shared artifact**. Temporal's version of the point is sharper than oRPC's, because three parties read this file: the worker that implements @@ -19,14 +26,14 @@ the execution. Only the first of those wants a di container, a Prisma-backed repository and the kernel. ``` - order-temporal any client starting a workflow + order-temporal-worker any client starting a workflow └──────────┬──────────┘ ▼ order-temporal-contract ← @temporal-contract/contract and zod, nothing else ``` `src/layering.test-d.ts` is that sentence as a compile error: it imports -`@btravstack/start-example-order-temporal` under a `@ts-expect-error`, so the +`@btravstack/start-example-order-temporal-worker` under a `@ts-expect-error`, so the day this package gains a dependency on the worker it describes, `test:types` fails because the directive stops being used. @@ -40,7 +47,7 @@ scope — which is exactly the check a caller makes before starting an execution There is no client-side test beyond that, and that is a property of Temporal rather than an omission: a `TypedClient` needs a running service to talk to, so -"a client built from the contract alone" is what `order-temporal`'s own suite +"a client built from the contract alone" is what `order-temporal-worker`'s own suite already exercises against the time-skipping test server. `zod` is a runtime dependency because the schemas **are** the contract — they diff --git a/examples/order-temporal-contract/src/contract.ts b/examples/order-temporal-contract/src/contract.ts index 796e80a..a87b269 100644 --- a/examples/order-temporal-contract/src/contract.ts +++ b/examples/order-temporal-contract/src/contract.ts @@ -13,20 +13,21 @@ const orderView = z.object({ id: z.string(), quantity: z.number() }); /** The payload every declared error carries — which order it was about. */ const orderRef = z.object({ id: z.string() }); +const orderInput = z.object({ orderId: z.string(), quantity: z.number() }); + /** - * The activity: one call into the application layer. - * - * Its `errors` map is the transport half of the errors-as-values story, and it - * carries something neither oRPC nor a queue expresses natively — - * **`nonRetryable`**. A modeled domain failure is a permanent answer, so - * declaring it here is what stops Temporal's retry policy asking the same - * impossible thing five more times. Anything NOT declared here is retried - * according to `activityOptions.retry`, which is exactly the treatment a - * `Defect` deserves: an unmodelled failure is the infrastructure one, and - * infrastructure comes back. + * The forward steps: three calls into the application layer, one external + * service each. Their `errors` maps are the transport half of the + * errors-as-values story, and they carry something neither oRPC nor a queue + * expresses natively — **`nonRetryable`**. A modeled domain failure is a + * permanent answer, so declaring it here is what stops Temporal's retry + * policy asking the same impossible thing five more times. Anything NOT + * declared is retried according to `activityOptions.retry`, which is exactly + * the treatment a `Defect` deserves: an unmodelled failure is the + * infrastructure one, and infrastructure comes back. */ const place = defineActivity({ - input: z.object({ orderId: z.string(), quantity: z.number() }), + input: orderInput, output: orderView, errors: { InvalidQuantity: { data: orderRef, nonRetryable: true }, @@ -38,25 +39,81 @@ const place = defineActivity({ }, }); +const reserveStock = defineActivity({ + input: orderInput, + output: z.void(), + errors: { + OutOfStock: { data: orderRef, nonRetryable: true }, + }, + activityOptions: { + startToCloseTimeout: "1 minute", + retry: { maximumAttempts: 3, initialInterval: "10 milliseconds" }, + }, +}); + +const arrangeShipping = defineActivity({ + input: z.object({ orderId: z.string() }), + output: z.void(), + errors: { + ShippingUnavailable: { data: orderRef, nonRetryable: true }, + }, + activityOptions: { + startToCloseTimeout: "1 minute", + retry: { maximumAttempts: 3, initialInterval: "10 milliseconds" }, + }, +}); + +/** + * The compensations. No `errors` map on either: compensation is the saga + * *un-deciding*, and a step that could answer "no" would leave the saga stuck + * half-done. Whatever infrastructure trouble they hit is undeclared — so + * Temporal retries it until it works, which is precisely the durability the + * whole example runs on this platform to get. + */ +const releaseStock = defineActivity({ + input: z.object({ orderId: z.string() }), + output: z.void(), + activityOptions: { + startToCloseTimeout: "1 minute", + retry: { maximumAttempts: 5, initialInterval: "10 milliseconds" }, + }, +}); + +const cancelPlacement = defineActivity({ + input: z.object({ orderId: z.string() }), + output: z.void(), + activityOptions: { + startToCloseTimeout: "1 minute", + retry: { maximumAttempts: 5, initialInterval: "10 milliseconds" }, + }, +}); + /** - * The workflow re-declares the same two errors, and that is not duplication. + * The workflow: an orchestration, which is what Temporal is *for*. Place, + * reserve, ship — and when a later step answers a permanent no, walk back the + * earlier ones before answering the caller. The walk-back is the part no + * single service can own, because it spans services; a durable workflow is + * the one place the whole journey exists as code. * - * A contract error declared on an **activity** is rehydrated inside the + * The workflow re-declares the domain errors, and that is not duplication. A + * contract error declared on an **activity** is rehydrated inside the * *workflow* — it never reaches the client on its own. A contract error * declared on the **workflow** is rehydrated at the *client*. So a domain * failure that a caller is entitled to branch on has to be named at both - * boundaries, which is Temporal's version of the triage `order-api` performs - * once in `router.ts`. `workflows.ts` is where the hand-off happens. + * boundaries. `workflows.ts` is where the hand-off — and the compensation — + * happens. */ -const placeOrder = defineWorkflow({ - input: z.object({ orderId: z.string(), quantity: z.number() }), +const fulfillOrder = defineWorkflow({ + input: orderInput, output: orderView, idempotency: "allow-duplicate", errors: { InvalidQuantity: { data: orderRef, nonRetryable: true }, OrderAlreadyPlaced: { data: orderRef, nonRetryable: true }, + OutOfStock: { data: orderRef, nonRetryable: true }, + ShippingUnavailable: { data: orderRef, nonRetryable: true }, }, - activities: { place }, + activities: { place, reserveStock, arrangeShipping, releaseStock, cancelPlacement }, }); /** @@ -69,7 +126,7 @@ const placeOrder = defineWorkflow({ */ export const orderContract = defineContract({ taskQueue: "orders", - workflows: { placeOrder }, + workflows: { fulfillOrder }, }); export type OrderContract = typeof orderContract; diff --git a/examples/order-temporal-contract/src/layering.test-d.ts b/examples/order-temporal-contract/src/layering.test-d.ts index 241e48f..893789e 100644 --- a/examples/order-temporal-contract/src/layering.test-d.ts +++ b/examples/order-temporal-contract/src/layering.test-d.ts @@ -9,6 +9,6 @@ */ // @ts-expect-error — the contract must not be able to reach the worker that -// implements it: order-temporal-contract does not depend on order-temporal, so +// implements it: order-temporal-contract does not depend on order-temporal-worker, so // the specifier does not resolve. -import type {} from "@btravstack/start-example-order-temporal"; +import type {} from "@btravstack/start-example-order-temporal-worker"; diff --git a/examples/order-temporal-contract/src/test-fixtures.ts b/examples/order-temporal-contract/src/test-fixtures.ts index 0ab4669..4749061 100644 --- a/examples/order-temporal-contract/src/test-fixtures.ts +++ b/examples/order-temporal-contract/src/test-fixtures.ts @@ -3,7 +3,7 @@ import { test, type TestAPI } from "vitest"; import { orderContract } from "./contract.js"; -type PlaceOrderInput = typeof orderContract.workflows.placeOrder.input; +type FulfillOrderInput = typeof orderContract.workflows.fulfillOrder.input; export type ContractFixtures = { /** @@ -11,13 +11,13 @@ export type ContractFixtures = { * `Result` — what a caller holding nothing but this package can check a * payload with before it ever reaches a worker. */ - readonly validate: ReturnType>; + readonly validate: ReturnType>; }; export const it: TestAPI = test.extend({ // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture validate: async ({}, use) => { // `fromSchema` is CURRIED — it takes the schema and hands back the validator. - await use(fromSchema(orderContract.workflows.placeOrder.input)); + await use(fromSchema(orderContract.workflows.fulfillOrder.input)); }, }); diff --git a/examples/order-temporal-worker/README.md b/examples/order-temporal-worker/README.md new file mode 100644 index 0000000..cd1ef3a --- /dev/null +++ b/examples/order-temporal-worker/README.md @@ -0,0 +1,93 @@ +# `@btravstack/start-core` example: the order fulfillment worker + +**What Temporal is for: owning a journey.** This deployment orchestrates a +**fulfillment saga** — place the order, reserve the stock, arrange the +shipping — and when a later step answers a permanent no, it walks the earlier +steps back before answering the caller. The walk-back spans services no one of +which can own it; a durable workflow is the one place the whole journey exists +as code, and survives the process that started it. The worker is served by +[`@btravstack/start-temporal`](../../packages/start-temporal) the way +`order-api` is served by `@btravstack/start-http`; the contract lives in +[`order-temporal-contract`](../order-temporal-contract), because a client that +starts these workflows needs it and needs none of this. + +``` +src/workflows.ts fulfillOrder — the saga, in Temporal's deterministic sandbox +src/temporal-runtime.ts the runtime: five activities and their triage into contract errors +src/fulfillment.ts FulfillmentModule — the two external services, as stand-ins +src/module.ts OrderTemporalModule — the composition root +src/env.ts process.env validated through a schema, as a Result +src/main.ts the process: readEnv + connect + start + runMain +src/test-fixtures.ts serve / fulfilling / outOfStock / noShipping, against the time-skipping env +``` + +## The saga + +Three forward steps, each an activity calling into the application layer, and +two compensations the workflow runs **in reverse order of the steps they +undo**: + +``` +place ──▶ reserveStock ──▶ arrangeShipping ──▶ done + ▲ ▲ OutOfStock? ▲ ShippingUnavailable? + │ └── cancelPlacement └── releaseStock, then cancelPlacement +``` + +The triage rule per step: a **declared** error is a permanent domain answer — +compensate, then re-mint it against `context.errors` so the client branches on +it by name. Temporal's own machinery tags (an activity that exhausted its +retries unmodelled, or was cancelled) are handed back as-is and re-raised, and +compensation deliberately does **not** run for them: a step that died +mid-flight left unknown state, and un-deciding what you cannot see is a second +bug, not a remedy. + +The compensations' activities declare no errors at all. Compensation is the +saga un-deciding, and a step that could answer "no" would leave it stuck +half-done — so `cancelPlacement` absorbs `OrderNotFound` (compensating a +placement that never landed is a no-op, and an activity Temporal may re-run +has to answer the same both times), and whatever infrastructure trouble either +hits is undeclared, which means Temporal retries it until it works. + +## One subtlety worth stealing + +An `AsyncResult` is **eager** — building a step starts its activity — so every +later step in `workflows.ts` is constructed inside the `flatMap` of the one +before it. Hoist them into `const`s and the "sequence" runs as a race. + +## The external services + +`FulfillmentModule` provides `StockService` and `ShippingService` — in a real +system other teams' APIs, here in-memory stand-ins that always say yes and +leave a log line, because what this deployment demonstrates is the +orchestration. The specs swap in providers that say no; that is where both +compensation paths run, against the real application and the real persistence: +after a refusal, the spec reads the database through the same repository the +saga used and finds the placement gone. + +## The environment + +| Variable | Default | What it is | +| -------------------- | ---------------- | -------------------- | +| `TEMPORAL_ADDRESS` | `127.0.0.1:7233` | the Temporal service | +| `TEMPORAL_NAMESPACE` | `default` | must not be blank | +| `PROBE_PORT` | `9000` | `/livez` / `/readyz` | + +## Running the specs + +The suite runs against Temporal's **time-skipping test environment** — a real +server binary (downloaded once into a repo-local cache; the one example that +needs network on a cold cache), real Workflow Tasks, real Activity Tasks. The +saga fulfills, both refusals compensate, and the duplicate-order answer +arrives at the client as a typed contract error it can branch on by name. + +```bash +pnpm --filter @btravstack/start-example-order-temporal-worker test # the saga + env specs +pnpm --filter @btravstack/start-example-order-temporal-worker typecheck # the needs gate +``` + +## What this deployment deliberately is not + +It is not a broadcast. Every activity here is _addressed_ — the workflow asks +a specific step to happen next and waits for the answer, because the journey +has an owner and an order. When a fact just needs saying to whoever listens, +that is an event, and it lives in [`order-amqp-worker`](../order-amqp-worker). diff --git a/examples/order-temporal/package.json b/examples/order-temporal-worker/package.json similarity index 96% rename from examples/order-temporal/package.json rename to examples/order-temporal-worker/package.json index e900a8e..f0287c3 100644 --- a/examples/order-temporal/package.json +++ b/examples/order-temporal-worker/package.json @@ -1,5 +1,5 @@ { - "name": "@btravstack/start-example-order-temporal", + "name": "@btravstack/start-example-order-temporal-worker", "private": true, "description": "The third deployment of the clean-architecture example: the same application module, served by a Temporal worker whose activities are kernel units", "license": "MIT", diff --git a/examples/order-temporal/src/env.spec.ts b/examples/order-temporal-worker/src/env.spec.ts similarity index 100% rename from examples/order-temporal/src/env.spec.ts rename to examples/order-temporal-worker/src/env.spec.ts diff --git a/examples/order-temporal/src/env.ts b/examples/order-temporal-worker/src/env.ts similarity index 100% rename from examples/order-temporal/src/env.ts rename to examples/order-temporal-worker/src/env.ts diff --git a/examples/order-temporal-worker/src/fulfillment.ts b/examples/order-temporal-worker/src/fulfillment.ts new file mode 100644 index 0000000..4ee1979 --- /dev/null +++ b/examples/order-temporal-worker/src/fulfillment.ts @@ -0,0 +1,40 @@ +import { Module, Provider } from "@btravstack/di"; +import { Logger, ShippingService, StockService } from "@btravstack/start-example-order-application"; +import { OkAsync } from "unthrown"; + +/** + * The two external services the saga orchestrates, as in-memory stand-ins. In + * a real system each is another team's API behind an anti-corruption boundary; + * here they always say yes and leave a log line, because what this deployment + * demonstrates is the *orchestration* — the specs swap in providers that say + * no, which is where the compensation paths run. + * + * A module of its own so the swap is one import: the composition root takes + * `FulfillmentModule`, a spec takes its own failing twin, and + * `ApplicationModule` — which owns the ports — never knows the difference. + */ +export const FulfillmentModule = Module("Fulfillment")({ + provides: [ + Provider(StockService)([Logger], { + sync: (logger) => ({ + reserve: (orderId, quantity) => { + logger.info(`reserved ${quantity} items for order ${orderId}`); + return OkAsync(); + }, + release: (orderId) => { + logger.info(`released the reservation for order ${orderId}`); + return OkAsync(); + }, + }), + }), + Provider(ShippingService)([Logger], { + sync: (logger) => ({ + arrange: (orderId) => { + logger.info(`arranged shipping for order ${orderId}`); + return OkAsync(); + }, + }), + }), + ], + exports: [StockService, ShippingService], +}); diff --git a/examples/order-temporal/src/index.ts b/examples/order-temporal-worker/src/index.ts similarity index 100% rename from examples/order-temporal/src/index.ts rename to examples/order-temporal-worker/src/index.ts diff --git a/examples/order-temporal/src/main.ts b/examples/order-temporal-worker/src/main.ts similarity index 98% rename from examples/order-temporal/src/main.ts rename to examples/order-temporal-worker/src/main.ts index 9f24643..57e2297 100644 --- a/examples/order-temporal/src/main.ts +++ b/examples/order-temporal-worker/src/main.ts @@ -14,7 +14,7 @@ import { temporalWorkerRuntime } from "./temporal-runtime.js"; * environment, build the graph, serve it, and turn the exit report into a * process exit code. * - * The connection is opened here for the reason `order-worker` creates its queue + * The connection is opened here for the reason `order-api` binds its port * here: it is the *transport*, and a runtime is handed one rather than owning * its lifetime. `workflowsPathFromURL` points Temporal at the workflow module * so it can bundle it for the sandbox; a spec hands over a prebuilt bundle diff --git a/examples/order-temporal-worker/src/module.ts b/examples/order-temporal-worker/src/module.ts new file mode 100644 index 0000000..3b595eb --- /dev/null +++ b/examples/order-temporal-worker/src/module.ts @@ -0,0 +1,29 @@ +import { Module } from "@btravstack/di"; +import { + ApplicationModule, + Logger, + OrderRepository, + PlaceOrder, + ShippingService, + StockService, +} from "@btravstack/start-example-order-application"; +import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; + +import { FulfillmentModule } from "./fulfillment.js"; + +/** + * The composition root of the orchestration deployment. `ApplicationModule` + * and `PersistenceModule` are booted here unchanged — the same pair every + * other deployment composes — plus `FulfillmentModule`, the two external + * services only this deployment orchestrates. + * + * The exports are the five ports the saga's activities resolve: the placement + * use case, the repository (its `remove` is `cancelPlacement`'s persistence + * arm), the two fulfillment services, and the logger. Declared here rather + * than imported from a sibling because sharing a composition root would share + * its transport dependency — one application, one root per process. + */ +export const OrderTemporalModule = Module("OrderTemporal")({ + imports: [ApplicationModule, PersistenceModule, FulfillmentModule], + exports: [PlaceOrder, OrderRepository, StockService, ShippingService, Logger], +}); diff --git a/examples/order-temporal/src/needs-gate.test-d.ts b/examples/order-temporal-worker/src/needs-gate.test-d.ts similarity index 92% rename from examples/order-temporal/src/needs-gate.test-d.ts rename to examples/order-temporal-worker/src/needs-gate.test-d.ts index a01afcf..ee58a9b 100644 --- a/examples/order-temporal/src/needs-gate.test-d.ts +++ b/examples/order-temporal-worker/src/needs-gate.test-d.ts @@ -1,10 +1,10 @@ /** - * The compile-time half of the third deployment: `temporalWorkerRuntime` + * The compile-time half of the orchestration deployment: `temporalWorkerRuntime` * declares two ports in `needs`, and `start`'s phantom rest-tuple gate turns a * module that does not export both into a call-site arity error. Type-checked * by this package's `test:types` script, never executed. * - * Together with `order-api`'s and `order-worker`'s, this is what makes the + * Together with `order-api`'s and `order-amqp-worker`'s, this is what makes the * claim testable rather than asserted: three runtimes with non-empty `needs`, * all proven against the same application graph at the `start(...)` call site. */ diff --git a/examples/order-temporal-worker/src/temporal-runtime.spec.ts b/examples/order-temporal-worker/src/temporal-runtime.spec.ts new file mode 100644 index 0000000..2389b6c --- /dev/null +++ b/examples/order-temporal-worker/src/temporal-runtime.spec.ts @@ -0,0 +1,139 @@ +import { + tagPatterns, + WORKFLOW_RESULT_ERROR_TAGS, + WORKFLOW_START_ERROR_TAGS, +} from "@temporal-contract/client"; +import { describe, expect } from "vitest"; + +import { it } from "./test-fixtures.js"; + +describe("the fulfillment saga", () => { + it("fulfills an order: place, reserve, ship, in order", async ({ serve, fulfilling }) => { + // GIVEN the same composition `main.ts` boots, under the time-skipping env + const { client } = await serve(fulfilling.module); + + // WHEN the workflow is driven to completion + // THEN the order came back through a real Workflow Task, three real + // Activity Tasks, and the use case and services behind them + await expect( + client.executeWorkflow("fulfillOrder", { + workflowId: "wf-fulfill-1", + args: { orderId: "o-1", quantity: 2 }, + }), + ).toBeOkWith({ id: "o-1", quantity: 2 }); + + // AND the journey ran in the declared order, each step a log line — the + // `[workflowId]` prefix is the activity unit's trace, stripped here + // because the order of the steps is the assertion, not the tracing + const { repository, logger } = fulfilling.services(); + expect(logger.lines().map((line) => line.slice(line.indexOf("]") + 2))).toEqual([ + "placing order o-1 (quantity 2)", + "reserved 2 items for order o-1", + "arranged shipping for order o-1", + ]); + + // AND the placement is durably there + await expect(repository.find("o-1")).toBeOkWith( + expect.objectContaining({ id: "o-1", quantity: 2 }), + ); + }); + + it("compensates a stock refusal: the placement is walked back", async ({ serve, outOfStock }) => { + // GIVEN stock that answers a permanent no + const { client } = await serve(outOfStock.module); + + // WHEN the workflow runs + const outcome = await client + .executeWorkflow("fulfillOrder", { + workflowId: "wf-oos-1", + args: { orderId: "o-2", quantity: 5 }, + }) + .match({ + ok: () => "WRONGLY FULFILLED", + // THEN the client can branch on the refusal by name — the saga's + // answer is a typed contract error, not a broken execution + errCases: (matcher) => + matcher + .with({ errorName: "OutOfStock" }, (error) => `out-of-stock:${error.data.id}`) + .with({ errorName: "InvalidQuantity" }, () => "WRONG ERROR") + .with({ errorName: "OrderAlreadyPlaced" }, () => "WRONG ERROR") + .with({ errorName: "ShippingUnavailable" }, () => "WRONG ERROR") + .with(...tagPatterns(WORKFLOW_START_ERROR_TAGS), (error) => `start:${error._tag}`) + .with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => `result:${error._tag}`), + defect: () => "DEFECT", + }); + expect(outcome).toBe("out-of-stock:o-2"); + + // AND the placement the saga made before the refusal is gone — the + // compensation ran, and the database agrees with the answer + await expect(outOfStock.services().repository.find("o-2")).toBeErrTagged("OrderNotFound", { + id: "o-2", + }); + }); + + it("compensates a shipping refusal in reverse order: release, then cancel", async ({ + serve, + noShipping, + }) => { + // GIVEN shipping that answers a permanent no, and stock that records what + // the saga asks of it + const { client } = await serve(noShipping.module); + + // WHEN the workflow runs + await expect( + client.executeWorkflow("fulfillOrder", { + workflowId: "wf-ship-1", + args: { orderId: "o-3", quantity: 1 }, + }), + ).toBeErr(); + + // THEN the reservation was released — the walk-back reached the earlier + // step, not just the placement + expect(noShipping.released()).toEqual(["o-3"]); + + // AND the placement is gone too + await expect(noShipping.services().repository.find("o-3")).toBeErrTagged("OrderNotFound", { + id: "o-3", + }); + }); + + it("hands the client the OrderAlreadyPlaced the API answers CONFLICT for, as a typed contract error", async ({ + serve, + fulfilling, + }) => { + // GIVEN an order already fulfilled by a first execution + const { client } = await serve(fulfilling.module); + + // WHEN a second execution asks for the same order id — chained, so the + // first execution's result is consumed and a failure there cannot be + // mistaken for the duplicate + const outcome = await client + .executeWorkflow("fulfillOrder", { + workflowId: "wf-dup-1", + args: { orderId: "o-4", quantity: 2 }, + }) + .flatMap(() => + client.executeWorkflow("fulfillOrder", { + workflowId: "wf-dup-2", + args: { orderId: "o-4", quantity: 2 }, + }), + ) + .match({ + ok: () => "WRONGLY PLACED", + // THEN the identical `Err` the oRPC runtime turns into an inferable + // CONFLICT is here a **branchable value at the client**, rehydrated by + // name with its payload intact. + errCases: (matcher) => + matcher + .with({ errorName: "OrderAlreadyPlaced" }, (error) => `conflict:${error.data.id}`) + .with({ errorName: "InvalidQuantity" }, () => "WRONG ERROR") + .with({ errorName: "OutOfStock" }, () => "WRONG ERROR") + .with({ errorName: "ShippingUnavailable" }, () => "WRONG ERROR") + .with(...tagPatterns(WORKFLOW_START_ERROR_TAGS), (error) => `start:${error._tag}`) + .with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => `result:${error._tag}`), + defect: () => "DEFECT", + }); + + expect(outcome).toBe("conflict:o-4"); + }); +}); diff --git a/examples/order-temporal/src/temporal-runtime.ts b/examples/order-temporal-worker/src/temporal-runtime.ts similarity index 67% rename from examples/order-temporal/src/temporal-runtime.ts rename to examples/order-temporal-worker/src/temporal-runtime.ts index a5286e1..8e1c951 100644 --- a/examples/order-temporal/src/temporal-runtime.ts +++ b/examples/order-temporal-worker/src/temporal-runtime.ts @@ -1,5 +1,11 @@ import type { Runtime } from "@btravstack/start-core"; -import { Logger, PlaceOrder } from "@btravstack/start-example-order-application"; +import { + Logger, + OrderRepository, + PlaceOrder, + ShippingService, + StockService, +} from "@btravstack/start-example-order-application"; import type { OrderContract } from "@btravstack/start-example-order-temporal-contract"; import { activityUnits, @@ -39,15 +45,20 @@ const SHUTDOWN_GRACE = "10 seconds"; const SHUTDOWN_FORCE = "15 seconds"; /** - * The ports this runtime resolves out of the application context — the same two - * the queue worker needs, and for the same reason: `FindOrder` is not part of - * any activity this worker registers, and a runtime declares what *it* needs - * rather than what the module happens to export. + * The ports this runtime resolves out of the application context — one per + * concern the saga's activities touch. `FindOrder` is not among them, and a + * runtime declares what *it* needs rather than what the module happens to + * export. * * Non-empty on purpose: it is what makes `start`'s arity gate mean something * (`src/needs-gate.test-d.ts` pins both directions). */ -type TemporalNeeds = typeof PlaceOrder | typeof Logger; +type TemporalNeeds = + | typeof PlaceOrder + | typeof OrderRepository + | typeof StockService + | typeof ShippingService + | typeof Logger; /** * A `Runtime` serving the order application as a Temporal worker — and, since @@ -93,12 +104,20 @@ export const temporalWorkerRuntime = ({ temporalRuntime({ ...transport, taskQueue: contract.taskQueue, - needs: [PlaceOrder, Logger], + needs: [PlaceOrder, OrderRepository, StockService, ShippingService, Logger], activities: (host) => declareActivitiesHandler({ contract, middleware: activityUnits(host), - activities: { placeOrder: { place: placeActivity } }, + activities: { + fulfillOrder: { + place: placeActivity, + reserveStock: reserveStockActivity, + arrangeShipping: arrangeShippingActivity, + releaseStock: releaseStockActivity, + cancelPlacement: cancelPlacementActivity, + }, + }, }), gracePeriod: SHUTDOWN_GRACE, forceAfter: SHUTDOWN_FORCE, @@ -108,7 +127,7 @@ export const temporalWorkerRuntime = ({ * The one activity, and the hinge of this whole example. * * The `mapErrCases` is the triage point, and the third sibling of - * `order-api`'s into `ORPCError` codes and `order-worker`'s into + * `order-api`'s into `ORPCError` codes and a queue consumer's into * ack/dead-letter. The same `Err` lands somewhere else again: `DuplicateOrder` * is a `CONFLICT` over HTTP because a caller is waiting to be told, and a * dead-letter on a queue because none is. Here there *is* a caller — a @@ -130,7 +149,7 @@ export const temporalWorkerRuntime = ({ */ const placeActivity: ActivityImplementationFor< OrderContract, - "placeOrder", + "fulfillOrder", "place", ActivityUnitContext > = (args, { context, errors }) => @@ -143,3 +162,60 @@ const placeActivity: ActivityImplementationFor< .with(P.tag("InvalidQuantity"), (error) => errors.InvalidQuantity({ id: error.id })) .with(P.tag("DuplicateOrder"), (error) => errors.OrderAlreadyPlaced({ id: error.id })), ); + +/** + * The forward steps against the two external services — same triage, one case + * each: the domain's permanent no becomes the declared contract error, which + * `nonRetryable` in `contract.ts` turns into "stop asking". + */ +const reserveStockActivity: ActivityImplementationFor< + OrderContract, + "fulfillOrder", + "reserveStock", + ActivityUnitContext +> = (args, { context, errors }) => + context.ctx + .get(StockService) + .reserve(args.orderId, args.quantity) + .mapErrCases((matcher) => + matcher.with(P.tag("OutOfStock"), (error) => errors.OutOfStock({ id: error.id })), + ); + +const arrangeShippingActivity: ActivityImplementationFor< + OrderContract, + "fulfillOrder", + "arrangeShipping", + ActivityUnitContext +> = (args, { context, errors }) => + context.ctx + .get(ShippingService) + .arrange(args.orderId) + .mapErrCases((matcher) => + matcher.with(P.tag("ShippingUnavailable"), (error) => + errors.ShippingUnavailable({ id: error.id }), + ), + ); + +/** + * The compensations. `releaseStock`'s port already promises `never`; nothing + * to triage. `cancelPlacement` absorbs `OrderNotFound` on purpose — undoing a + * placement that never landed is the no-op a *repeated* compensation performs, + * and an activity Temporal may re-run has to answer the same both times. + */ +const releaseStockActivity: ActivityImplementationFor< + OrderContract, + "fulfillOrder", + "releaseStock", + ActivityUnitContext +> = (args, { context }) => context.ctx.get(StockService).release(args.orderId); + +const cancelPlacementActivity: ActivityImplementationFor< + OrderContract, + "fulfillOrder", + "cancelPlacement", + ActivityUnitContext +> = (args, { context }) => + context.ctx + .get(OrderRepository) + .remove(args.orderId) + .recoverErrCases((matcher) => matcher.with(P.tag("OrderNotFound"), () => undefined)); diff --git a/examples/order-temporal/src/test-fixtures.ts b/examples/order-temporal-worker/src/test-fixtures.ts similarity index 54% rename from examples/order-temporal/src/test-fixtures.ts rename to examples/order-temporal-worker/src/test-fixtures.ts index fd684b0..131813a 100644 --- a/examples/order-temporal/src/test-fixtures.ts +++ b/examples/order-temporal-worker/src/test-fixtures.ts @@ -5,12 +5,14 @@ import { Module, Port, Provider, type Scope, type ServiceOf } from "@btravstack/ import { start, type RunningApp } from "@btravstack/start-core"; import { ApplicationModule, - FindOrder, Logger, OrderRepository, PlaceOrder, + ShippingService, + StockService, } from "@btravstack/start-example-order-application"; -import { OrderNotFound } from "@btravstack/start-example-order-domain"; +import { OutOfStock, ShippingUnavailable } from "@btravstack/start-example-order-domain"; +import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; import { orderContract, type OrderContract, @@ -25,10 +27,10 @@ import { withTaskQueue, } from "@temporal-contract/testing/workflow-bundle"; import type { TestWorkflowEnvironment } from "@temporalio/testing"; -import { ErrAsync, fromSafePromise } from "unthrown"; +import { ErrAsync, OkAsync } from "unthrown"; import { expect } from "vitest"; -import { OrderTemporalModule } from "./module.js"; +import { FulfillmentModule } from "./fulfillment.js"; import { temporalWorkerRuntime } from "./temporal-runtime.js"; /** @@ -57,12 +59,12 @@ mkdirSync(downloadDir, { recursive: true }); type App = RunningApp; /** - * `X` is pinned to the three ports the composition roots export rather than + * `X` is pinned to the five ports the composition root exports rather than * left generic: `start`'s needs gate is a phantom rest parameter proven at the * call site, and no proof is available inside a helper generic in the module's - * own exports. The runtime needs only two of them. + * own exports. */ -type TemporalPorts = PlaceOrder | FindOrder | Logger; +type TemporalPorts = PlaceOrder | OrderRepository | StockService | ShippingService | Logger; /** One booted deployment: the kernel's handle, and a client that can reach it. */ type Deployment = { @@ -70,110 +72,109 @@ type Deployment = { readonly client: ContractClient; }; +type Serve = (module: Module) => Promise>; + /** - * The kernel options a test may override. Only the drain budget so far — a test - * that strands an activity needs the deadline to arrive in milliseconds rather - * than in the default twenty seconds. + * `start` hands the application context to the runtime alone, so a spec cannot + * reach the services the way `Module.scoped` can. This captures the very + * instances the running app uses — the repository the compensation assertions + * read through, and the logger the stub services write to. */ -type ServeOptions = { readonly drainTimeoutMs: number }; - -type Serve = ( - module: Module, - options?: ServeOptions, -) => Promise>; - -const persistenceOf = (repository: ServiceOf) => - Module("StubPersistence")({ - provides: [Provider(OrderRepository)({ value: repository })], - exports: [OrderRepository], +class ServicesTap extends Port("ServicesTap")<{ + readonly repository: ServiceOf; + readonly logger: ServiceOf; +}> {} + +const tapProvider = (capture: (services: ServiceOf) => void) => + Provider(ServicesTap)([OrderRepository, Logger], { + sync: (repository, logger) => { + const services = { repository, logger }; + capture(services); + return services; + }, }); /** - * A composition root shaped like the real one but with the repository swapped: - * same `ApplicationModule`, same runtime, same three exported ports, so the - * transport under test is unchanged. + * A composition root shaped like the real one, with this test's fulfillment + * module swapped in: same `ApplicationModule`, same `PersistenceModule`, same + * runtime, same five exported ports, so the orchestration under test is + * unchanged and only the external services' answers differ. */ -const temporalWith = (repository: ServiceOf) => +const rootWith = ( + fulfillment: typeof FulfillmentModule, + capture: (services: ServiceOf) => void, +) => Module("StubTemporal")({ - imports: [ApplicationModule, persistenceOf(repository)], - exports: [PlaceOrder, FindOrder, Logger], + imports: [ApplicationModule, PersistenceModule, fulfillment], + provides: [tapProvider(capture)], + exports: [PlaceOrder, OrderRepository, StockService, ShippingService, Logger], }); -/** - * `start` hands the application context to the runtime alone, so a spec cannot - * reach `Logger` the way `Module.scoped` can. This publishes the very `Logger` - * service instance the use cases write to. - */ -class LoggerTap extends Port("LoggerTap")<{ readonly lines: () => readonly string[] }> {} - -const tappedTemporal = () => { - let read: () => readonly string[] = () => []; +const deployment = (fulfillment: typeof FulfillmentModule) => { + let services: ServiceOf | undefined; return { - module: Module("TappedTemporal")({ - imports: [OrderTemporalModule], - provides: [ - Provider(LoggerTap)([Logger], { - sync: (logger) => { - read = logger.lines; - return { lines: logger.lines }; - }, - }), - ], - exports: [PlaceOrder, FindOrder, Logger], + module: rootWith(fulfillment, (captured) => { + services = captured; }), - traces: (): readonly string[] => read().map((line) => line.slice(0, line.indexOf("]") + 1)), + services: (): ServiceOf => { + // oxlint-disable-next-line unthrown/no-throw -- a fixture misused before `serve` is a broken test, and the loudest possible answer is the right one + if (services === undefined) throw new Error("the app has not been served yet"); + return services; + }, }; }; -/** - * A composition root whose repository fails in a way nobody modelled: no - * `qualify` triaged the rejection, so it is a defect — and a defect is what - * Temporal's own retry policy is for. `attempts()` reports how many times the - * platform came back. - */ -const unmodelledTemporal = () => { - let attempts = 0; +/** The real thing: the same fulfillment module `main.ts` boots. */ +const fulfillingTemporal = () => deployment(FulfillmentModule); - return { - module: temporalWith({ - save: () => { - attempts += 1; - return fromSafePromise(Promise.reject(new Error("the database is on fire"))); - }, - find: (id) => ErrAsync(new OrderNotFound({ id })), +/** Stock says a permanent no; everything else is the real composition. */ +const outOfStockTemporal = () => + deployment( + Module("Fulfillment")({ + provides: [ + Provider(StockService)({ + value: { + reserve: (orderId, quantity) => ErrAsync(new OutOfStock({ id: orderId, quantity })), + release: () => OkAsync(), + }, + }), + Provider(ShippingService)({ + value: { arrange: () => OkAsync() }, + }), + ], + exports: [StockService, ShippingService], }), - attempts: (): number => attempts, - }; -}; + ); /** - * A repository whose `save` never settles until `release()` is called, and whose - * `arrived` promise reports the moment the activity reached it. The drain spec - * turns on knowing a unit is genuinely in flight before the drain starts — - * polling a wall clock instead would be the flake. + * Shipping says a permanent no, and the stock stub records what the saga asks + * of it — `released()` is the walk-back's witness. */ -const gatedTemporal = () => { - let entered!: () => void; - const arrived = new Promise((resolve) => { - entered = resolve; - }); - let release!: () => void; - const held = new Promise((resolve) => { - release = resolve; - }); +const noShippingTemporal = () => { + const released: string[] = []; - return { - module: temporalWith({ - save: (order) => { - entered(); - return fromSafePromise(held.then(() => order)); - }, - find: (id) => ErrAsync(new OrderNotFound({ id })), + const base = deployment( + Module("Fulfillment")({ + provides: [ + Provider(StockService)({ + value: { + reserve: () => OkAsync(), + release: (orderId) => { + released.push(orderId); + return OkAsync(); + }, + }, + }), + Provider(ShippingService)({ + value: { arrange: (orderId) => ErrAsync(new ShippingUnavailable({ id: orderId })) }, + }), + ], + exports: [StockService, ShippingService], }), - arrived, - release: () => release(), - }; + ); + + return { ...base, released: (): readonly string[] => released }; }; export type TemporalFixtures = { @@ -185,9 +186,9 @@ export type TemporalFixtures = { * assertion those blocks carried: the app exited `Ok`. */ readonly serve: Serve; - readonly tapped: ReturnType; - readonly unmodelled: ReturnType; - readonly gate: ReturnType; + readonly fulfilling: ReturnType; + readonly outOfStock: ReturnType; + readonly noShipping: ReturnType; }; export const it = createTimeSkippingTest({ @@ -200,7 +201,7 @@ export const it = createTimeSkippingTest({ const workflowBundle = await bundleFor(fixturePath(import.meta.url, "workflows")); const shutdowns: (() => Promise)[] = []; - const serve: Serve = async (module, options) => { + const serve: Serve = async (module) => { // A queue of this test's own: the environment is shared by every test in // the worker process, and two workers polling one queue would race for // each other's tasks. @@ -215,7 +216,6 @@ export const it = createTimeSkippingTest({ signals: false, probes: false, preDrainDelayMs: 0, - ...options, }); shutdowns.push(async () => { @@ -237,21 +237,17 @@ export const it = createTimeSkippingTest({ }, // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture - tapped: async ({}, use) => { - await use(tappedTemporal()); + fulfilling: async ({}, use) => { + await use(fulfillingTemporal()); }, // oxlint-disable-next-line no-empty-pattern -- see above - unmodelled: async ({}, use) => { - await use(unmodelledTemporal()); + outOfStock: async ({}, use) => { + await use(outOfStockTemporal()); }, // oxlint-disable-next-line no-empty-pattern -- see above - gate: async ({}, use) => { - const gate = gatedTemporal(); - await use(gate); - // Released on every exit path, so an activity a test deliberately stranded - // cannot outlive the test that stranded it. - gate.release(); + noShipping: async ({}, use) => { + await use(noShippingTemporal()); }, }); diff --git a/examples/order-temporal/src/vitest.d.ts b/examples/order-temporal-worker/src/vitest.d.ts similarity index 100% rename from examples/order-temporal/src/vitest.d.ts rename to examples/order-temporal-worker/src/vitest.d.ts diff --git a/examples/order-temporal-worker/src/workflows.ts b/examples/order-temporal-worker/src/workflows.ts new file mode 100644 index 0000000..5dfdbde --- /dev/null +++ b/examples/order-temporal-worker/src/workflows.ts @@ -0,0 +1,104 @@ +import { orderContract } from "@btravstack/start-example-order-temporal-contract"; +import { + ACTIVITY_CANCELLED_ERROR_TAG, + ACTIVITY_ERROR_TAG, + declareWorkflow, + propagateActivityFailure, +} from "@temporal-contract/worker/workflow"; +import { ErrAsync, P } from "unthrown"; + +/** + * The workflow, in its own module — and it has to be. + * + * Workflow code runs inside a deterministic V8 sandbox that webpack bundles + * separately from the worker's own module graph, so it may not sit in a spec + * file, and it must be free of side effects at module scope: `TypedWorker.create` + * also imports it in the main thread to check that every declared workflow is + * exported. Nothing here reaches the di container or the database — those live + * behind the *activities*, which is where this example's kernel units open. + * + * This is the orchestration the deployment exists to demonstrate: place, then + * reserve, then ship — and when a later step answers a permanent no, walk the + * earlier ones back before answering the caller. The walk-back is a **saga**, + * and it lives here because it spans services no one of which can own it; a + * durable workflow is the one place the whole journey exists as code, and + * survives the process that started it. + * + * The triage rule per step is the same one `contract.ts` describes: a + * *declared* error is a permanent domain answer — compensate, then re-mint it + * against `context.errors` so the client sees it typed. The two machinery tags + * are Temporal's own vocabulary for an activity that failed *unmodelled* (its + * retries already exhausted) or was cancelled — those are handed back as-is, + * so `propagateActivityFailure` re-raises the platform's original failure and + * the execution fails the way an untyped workflow would. Compensation is + * deliberately NOT run on machinery failures: a step that died mid-flight left + * unknown state, and un-deciding what you cannot see is a second bug, not a + * remedy. + * + * Compensations run in reverse order of the steps they undo, and their + * activities declare no errors at all — `cancelPlacement`'s `OrderNotFound` + * is absorbed inside the activity, because compensating a placement that never + * landed is a no-op, not a failure. Every case in every matcher is named — + * this repo bans `P._`. + */ +export const fulfillOrder = declareWorkflow({ + workflowName: "fulfillOrder", + contract: orderContract, + implementation: (context, args) => { + // An `AsyncResult` is eager — building a step IS starting its activity — + // so every later step is constructed inside the `flatMap` of the one + // before it, or the "sequence" would run as a race. + const order = { orderId: args.orderId }; + + return propagateActivityFailure( + context.activities + .place({ orderId: args.orderId, quantity: args.quantity }) + .mapErrCases((matcher) => + matcher + .with({ errorName: "InvalidQuantity" }, (error) => + context.errors.InvalidQuantity({ id: error.data.id }), + ) + .with({ errorName: "OrderAlreadyPlaced" }, (error) => + context.errors.OrderAlreadyPlaced({ id: error.data.id }), + ) + .with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => error), + ) + .flatMap((placed) => + context.activities + .reserveStock({ orderId: args.orderId, quantity: args.quantity }) + .flatMapErrCases((matcher) => + matcher + // The first walk-back: stock said a permanent no, so the + // placement is un-decided before the caller hears it. + .with({ errorName: "OutOfStock" }, (error) => + context.activities + .cancelPlacement(order) + .flatMap(() => ErrAsync(context.errors.OutOfStock({ id: error.data.id }))), + ) + .with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => + ErrAsync(error), + ), + ) + .flatMap(() => + context.activities.arrangeShipping(order).flatMapErrCases((matcher) => + matcher + // The deeper walk-back, in reverse order of the steps it + // undoes: release the reservation, then the placement. + .with({ errorName: "ShippingUnavailable" }, (error) => + context.activities + .releaseStock(order) + .flatMap(() => context.activities.cancelPlacement(order)) + .flatMap(() => + ErrAsync(context.errors.ShippingUnavailable({ id: error.data.id })), + ), + ) + .with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => + ErrAsync(error), + ), + ), + ) + .map(() => placed), + ), + ); + }, +}); diff --git a/examples/order-temporal/tsconfig.json b/examples/order-temporal-worker/tsconfig.json similarity index 100% rename from examples/order-temporal/tsconfig.json rename to examples/order-temporal-worker/tsconfig.json diff --git a/examples/order-temporal/tsconfig.test-d.json b/examples/order-temporal-worker/tsconfig.test-d.json similarity index 100% rename from examples/order-temporal/tsconfig.test-d.json rename to examples/order-temporal-worker/tsconfig.test-d.json diff --git a/examples/order-temporal/vitest.config.ts b/examples/order-temporal-worker/vitest.config.ts similarity index 100% rename from examples/order-temporal/vitest.config.ts rename to examples/order-temporal-worker/vitest.config.ts diff --git a/examples/order-temporal/README.md b/examples/order-temporal/README.md deleted file mode 100644 index 9866475..0000000 --- a/examples/order-temporal/README.md +++ /dev/null @@ -1,317 +0,0 @@ -# `@btravstack/start-core` example: the order Temporal worker - -The third deployment. The same application, the same persistence, the same -composition — driven by a durable execution engine instead of an HTTP server or -a queue, and served by -[`@btravstack/start-temporal`](../../packages/start-temporal) the way -`order-api` is served by `@btravstack/start-http`. The contract it implements lives in -[`order-temporal-contract`](../order-temporal-contract), because a client that -starts these workflows needs it and needs none of this. - -``` -src/workflows.ts the workflow body, in its own module because the sandbox is bundled separately -src/temporal-runtime.ts the runtime's application half: the contract, the needs, and the activity implementation -src/module.ts OrderTemporalModule — the composition root -src/env.ts process.env validated through a schema, as a Result -src/main.ts the process: readEnv + connect + start + runMain -src/test-fixtures.ts testEnv / serve / gate / tapped / unmodelled, as Vitest fixtures -``` - -## The point of this package - -`OrderTemporalModule` is `OrderApiModule` and `OrderWorkerModule` with a -different name: - -```ts -export const OrderTemporalModule = Module("OrderTemporal")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder, Logger], -}); -``` - -Nothing in `order-application` or `order-infrastructure` changed to make this -work, and nothing could have. **One process, one runtime** was already two -composition roots and two `Runtime` values; a third one, over a transport as -unlike HTTP as durable execution is, is what turns "two" into a pattern. - -## The same `Err`, three transports - -`DuplicateOrder` is one value. Over HTTP there is a caller waiting to be told, -so it becomes a `CONFLICT` the client receives **as a value**. On a queue there -is no caller, so the message is **parked**. Here there is a caller again — a -workflow, and behind it a client — so it becomes a **typed contract error**, -rehydrated by name with its payload intact. - -| unthrown | oRPC (`order-api`) | queue (`order-worker`) | Temporal (this package) | -| ---------------------- | ----------------------- | --------------------------- | --------------------------------------- | -| `Ok(order)` | the procedure's output | **ack** | the workflow's output | -| `Err(InvalidQuantity)` | `INVALID_QUANTITY` | **dead-letter** | `InvalidQuantity`, **non-retryable** | -| `Err(DuplicateOrder)` | `CONFLICT` | **dead-letter** | `OrderAlreadyPlaced`, **non-retryable** | -| `Defect` | `INTERNAL_SERVER_ERROR` | **retry**, then dead-letter | **retried by the platform**, then fails | - -The last column carries something the other two do not. Naming a failure here -decides not only what the caller sees but **whether the platform retries it**: -the contract declares both domain errors `nonRetryable`, so Temporal asks -exactly once. An unmodelled failure stays unnamed and the contract's -`retry.maximumAttempts` takes over — the platform doing for free what -`order-worker` hand-rolls with an attempt budget, and the sharpest form of "a -defect is the infrastructure failure, and infrastructure comes back". - -## The mapping happens twice, and that is Temporal's shape - -An activity-declared contract error is rehydrated **inside the workflow** and -never reaches the client on its own. A workflow-declared one is rehydrated **at -the client**. So a domain failure a caller is entitled to branch on has to be -named at both boundaries — where `order-api` triages once in `router.ts`. - -Boundary one, in `src/temporal-runtime.ts` — the domain's vocabulary stops here: - -```ts -context.ctx - .get(PlaceOrder) - .execute(args.orderId, args.quantity) - .map((order) => ({ id: order.id, quantity: order.quantity })) - .mapErrCases((matcher) => - matcher - .with(P.tag("InvalidQuantity"), (error) => - errors.InvalidQuantity({ id: error.id }), - ) - .with(P.tag("DuplicateOrder"), (error) => - errors.OrderAlreadyPlaced({ id: error.id }), - ), - ); -``` - -Boundary two, in `src/workflows.ts` — the workflow re-mints the two domain -failures against its own declared errors and hands everything else to -`propagateActivityFailure`, which re-raises Temporal's original failure so the -platform classifies the execution exactly as it would have if the activity call -had thrown: - -```ts -propagateActivityFailure( - context.activities - .place({ orderId: args.orderId, quantity: args.quantity }) - .mapErrCases((matcher) => - matcher - .with({ errorName: "InvalidQuantity" }, (e) => - context.errors.InvalidQuantity({ id: e.data.id }), - ) - .with({ errorName: "OrderAlreadyPlaced" }, (e) => - context.errors.OrderAlreadyPlaced({ id: e.data.id }), - ) - .with( - P.tag(ACTIVITY_ERROR_TAG), - P.tag(ACTIVITY_CANCELLED_ERROR_TAG), - (e) => e, - ), - ), -); -``` - -Every case is named at both — this repo bans `P._`, and there is no -`.otherwise()`. The two activity-machinery tags share a handler, so they are -**grouped** into one arm rather than duplicated: grouping is what -`no-catch-all-pattern` steers you toward instead of a wildcard, and it is still -an enumeration — a third machinery tag would not compile. A new domain error is -a compile error in **both** files, plus -`order-api/src/router.ts` and `order-worker/src/queue-runtime.ts`: four places, -each of which has to decide what it means. - -The client's half is what makes the claim testable rather than asserted: - -```ts -await client.executeWorkflow("placeOrder", { workflowId, args }).match({ - ok: () => "placed", - errCases: (matcher) => - matcher - .with( - { errorName: "InvalidQuantity" }, - (error) => `invalid:${error.data.id}`, - ) - .with( - { errorName: "OrderAlreadyPlaced" }, - (error) => `conflict:${error.data.id}`, - ) - .with( - ...tagPatterns(WORKFLOW_START_ERROR_TAGS), - (error) => `start:${error._tag}`, - ) - .with( - ...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), - (error) => `result:${error._tag}`, - ), - defect: (cause) => `defect:${String(cause)}`, -}); -``` - -## The activity is the unit, and one line is what makes it one - -A Temporal worker polls two kinds of task. Workflow code runs in a -deterministic V8 sandbox and may not touch a database, a clock or a di -container; the **activity** is where the process leaves that sandbox and reaches -real services. So the activity is where a kernel unit belongs — and since -`@btravstack/start-temporal` shipped, opening it is one line of middleware -rather than a wrapper this package writes: - -```ts -activities: (host) => - declareActivitiesHandler({ - contract: options.contract, - middleware: activityUnits(host), - activities: { placeOrder: { place: placeActivity } }, - }), -``` - -`activityUnits` opens the unit and injects the application context through -`temporal-contract`'s own per-invocation channel, which is why `placeActivity` -reads `context.ctx` and never sees the `RuntimeHost` at all. The type argument -is not decoration: TypeScript infers the injected context from the middleware's -type and infers nothing from a generic call it is still resolving, so bare and -inline it would leave `context` empty. - -That is also why `needs` is `[PlaceOrder, Logger]` rather than empty: the -activity resolves both out of the application context, and `start`'s phantom -rest-tuple gate proves the module exports them before anything runs. - -## A task token is the unit; the workflow id is the trace - -The package mints the meta, and this is what it mints: - -```ts -{ kind: "activity", id: info.base64TaskToken, traceId: info.workflowExecution?.workflowId ?? info.activityId } -``` - -`UnitMeta.id` must be unique per unit, and the obvious candidate — the workflow -id — is wrong twice over: an activity is retried under the same execution, and -Temporal lets a workflow id be reused once an execution has closed. `order-worker` -answers the same problem by making the _delivery_ the id rather than the -message; here the platform already mints exactly that value. A **task token** -identifies one activity task attempt, so its uniqueness is Temporal's guarantee -rather than an argument of ours. - -The workflow id becomes the `traceId`, which is what `traceId` is for: the -correlation id, minted outside this process by whoever started the execution, -and stable across every retry so all three attempts join up in the log. - -## Draining is real here - -This is the first runtime in the repository where `Serving.drain` meets a -transport with genuine drain semantics of its own, and it is exactly why -`@btravstack/start-temporal` is a package rather than code that lives here. - -`worker.shutdown()` moves the worker to `DRAINING` **immediately**: polling for -new Workflow and Activity Tasks stops at once, in-flight activities run to -completion, and `run()` resolves when the last of them has. So `drain` is -`shutdown()` plus the wait, and it is a genuine wait rather than a courtesy — -`order-api`'s and `order-worker`'s drains stop accepting and have nothing left -to wait for. - -The wait is raced against the kernel's deadline signal, because waiting on -`run()` alone cannot honour it: `run()` settles on Temporal's own -`shutdownForceTime`, so an activity that never finishes would hold `stop()` -well past the kernel's `drainTimeoutMs`. `@temporalio/worker` offers no public -forced shutdown to escalate to, so the escalation is to stop waiting — the -kernel gets its thread back on time and the worker keeps winding down -underneath. The package's README explains the mechanism; what this package -does is pass `forceAfter`/`gracePeriod` (15 s and 10 s), both at or below the -kernel's `drainTimeoutMs` default of 20 s. - -The spec asserts both halves directly — the drain that completes: - -``` -{"type":"drained","report":{"inFlightAtStart":1,"completed":1,"abandoned":0}} -``` - -and the drain that runs out of time, where the exit still arrives on the -kernel's deadline rather than Temporal's: - -``` -{"type":"drained","report":{"inFlightAtStart":1,"completed":0,"abandoned":1}} -``` - -## `Serving.info` with no port and no queue in it - -```ts -const info = (await app.runtimeInfo()).get(); // { taskQueue: "orders", namespace: "default" } -``` - -`order-api` publishes `{ port }` — `@btravstack/start-http`'s own `HttpInfo`, -since the runtime is the package's now — and `order-worker` publishes -`{ queue, concurrency }`. No two of the three shapes share a field, which is -exactly why `Info` is the runtime's own type parameter rather than anything the -kernel models. A Temporal worker's identity **is** its task queue and namespace: -the pair an operator needs to find it in the Web UI, and the pair that decides -which work it will ever be handed. - -## Running it — and the one thing this example needs that the others do not - -```bash -pnpm --filter @btravstack/start-example-order-temporal test # 8 runtime specs + 7 env specs -pnpm --filter @btravstack/start-example-order-temporal test:types # the needs gate -``` - -**No Docker.** A real `@temporalio/worker` Worker polls a real task queue against -`@temporalio/testing`'s time-skipping test server, which is a local binary -rather than a container — the whole worker loop, real Workflow Tasks and real -Activity Tasks, with the Docker daemon quit. - -**But it does need the network once.** That binary is 64 MB, downloaded on -first use and keyed by the `@temporalio` SDK version. The alternative — -`testcontainers` against a real Temporal cluster — is allowed here (see -`CLAUDE.md`'s integration-test rule, and `order-amqp`, which does exactly that -for a broker); it is simply slower for the same coverage, needing a **network -pull _and_ a daemon** where this needs a pull alone. A cold cache with no -network fails loudly at environment creation, naming the URL. - -Two things keep that cost to once: - -```ts -createTimeSkippingTest({ - server: { executable: { type: "cached-download", downloadDir, ttl: "365d" } }, -}); -``` - -`downloadDir` is `/.cache/temporal-test-server` — gitignored, and a stable -path rather than the OS temp directory, which CI wipes between jobs and macOS -purges on its own schedule. `ttl` is a year rather than the default one day, so -a developer who runs the suite on Monday and again on Wednesday does not -download it twice. - -Measured on this machine, with no container running: - -| | Wall clock | -| ------------------------------------ | ------------- | -| Cold cache (64 MB download included) | **7.4 s** | -| Warm | **3.8–3.9 s** | - -So the binary costs about 3.5 s, once. Warm, this package is the slowest in the -repository and still under four seconds. - -`src/needs-gate.test-d.ts` pins the compile-time half: `temporalWorkerRuntime` -declares `[PlaceOrder, Logger]` — two of the three ports the module exports, -because a runtime declares what _it_ needs — and a module missing either fails -`start`'s arity gate before anything runs. - -Every helper the specs need is a Vitest fixture in `src/test-fixtures.ts`, so -each file opens on `describe` and each test names its dependencies in its own -parameter list. Shutting an app down is the `serve` fixture's job, which is why -no test here has a `try`/`finally`. `serve` also scopes a **fresh task queue per -test** (`withTaskQueue` + `nextTaskQueueId`): the time-skipping environment is -shared by every test in the worker process, and two workers polling one queue -would race for each other's tasks. - -`src/main.ts` is the process itself. It opens the `NativeConnection`, because a -runtime is handed its transport rather than owning its lifetime — the same -reason `order-worker`'s `main.ts` creates its queue. Like both siblings' it is -typechecked by the gate rather than executed by it. - -It also **closes** it, in a `.finally` on `runMain`'s promise. Whoever opens it -closes it: the runtime is handed a connection it did not open and has no claim -on, and `src/test-fixtures.ts` is the proof — every test in the file boots a -fresh worker against the _one_ `testEnv.nativeConnection` it shares, so a -runtime closing what it was given would tear the environment down under the next -test. `.finally` rather than a `flatTap` because an open `NativeConnection` -holds the event loop, so the defect path is exactly the one that must still -close it; and a close that fails is written to stderr rather than surfaced, so -that teardown cannot rewrite the exit code `runMain` just set. diff --git a/examples/order-temporal/src/module.ts b/examples/order-temporal/src/module.ts deleted file mode 100644 index 9dcb6f6..0000000 --- a/examples/order-temporal/src/module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Module } from "@btravstack/di"; -import { - ApplicationModule, - FindOrder, - Logger, - PlaceOrder, -} from "@btravstack/start-example-order-application"; -import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; - -/** - * The composition root of the third deployment — and, imports and exports - * alike, the same one `OrderApiModule` and `OrderWorkerModule` are. That is the - * claim this package exists to make: `ApplicationModule` and `PersistenceModule` - * are booted here unchanged, under a runtime that speaks a durable execution - * engine instead of HTTP or a queue. - * - * Declared here rather than imported from a sibling for the reason - * `order-worker` states: sharing the module would share that deployment's - * transport dependency, and a Temporal worker that installs a web server to - * reach its use cases would falsify the very thing this demonstrates. Three - * processes, three composition roots, one application. - */ -export const OrderTemporalModule = Module("OrderTemporal")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder, Logger], -}); diff --git a/examples/order-temporal/src/temporal-runtime.spec.ts b/examples/order-temporal/src/temporal-runtime.spec.ts deleted file mode 100644 index ff03d25..0000000 --- a/examples/order-temporal/src/temporal-runtime.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { - tagPatterns, - WORKFLOW_RESULT_ERROR_TAGS, - WORKFLOW_START_ERROR_TAGS, -} from "@temporal-contract/client"; -import { describe, expect, vi } from "vitest"; - -import { OrderTemporalModule } from "./module.js"; -import { it } from "./test-fixtures.js"; - -describe("temporalWorkerRuntime", () => { - it("places an order through a workflow and an activity", async ({ serve }) => { - // GIVEN the same composition the API and the queue worker boot — - // `ApplicationModule` and `PersistenceModule`, unchanged — under a Temporal - // worker instead - const { client } = await serve(OrderTemporalModule); - - // WHEN a workflow execution is driven to completion - // THEN the order came back through a real Workflow Task, a real Activity - // Task, and the use case behind both - await expect( - client.executeWorkflow("placeOrder", { - workflowId: "wf-place-1", - args: { orderId: "o-1", quantity: 2 }, - }), - ).toBeOkWith({ id: "o-1", quantity: 2 }); - }); - - it("hands the client the DuplicateOrder the API answers CONFLICT for, as a typed contract error", async ({ - serve, - }) => { - // GIVEN an order already placed by a first execution - const { client } = await serve(OrderTemporalModule); - - // WHEN a second execution asks for the same order id — chained, so the - // first execution's result is consumed and a failure there cannot be - // mistaken for the duplicate - const outcome = await client - .executeWorkflow("placeOrder", { - workflowId: "wf-dup-1", - args: { orderId: "o-1", quantity: 2 }, - }) - .flatMap(() => - client.executeWorkflow("placeOrder", { - workflowId: "wf-dup-2", - args: { orderId: "o-1", quantity: 2 }, - }), - ) - .match({ - ok: () => "WRONGLY PLACED", - // THEN the load-bearing assertion of this package: the identical `Err` - // the oRPC runtime turns into an inferable CONFLICT and the queue - // worker parks is here a **branchable value at the client**, rehydrated - // by name with its payload intact. Folded through an exhaustive match - // rather than shape-matched, because "the client can branch on it" is - // the claim and this is what branching on it looks like. - errCases: (matcher) => - matcher - .with({ errorName: "InvalidQuantity" }, (error) => `invalid:${error.data.id}`) - .with({ errorName: "OrderAlreadyPlaced" }, (error) => `conflict:${error.data.id}`) - .with(...tagPatterns(WORKFLOW_START_ERROR_TAGS), (error) => `start:${error._tag}`) - .with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => `result:${error._tag}`), - defect: (cause) => `defect:${String(cause)}`, - }); - - expect(outcome).toBe("conflict:o-1"); - }); - - it("hands the client the InvalidQuantity the domain rejects, by its own name", async ({ - serve, - }) => { - // GIVEN a quantity the domain invariant rejects - const { client } = await serve(OrderTemporalModule); - - // WHEN the execution runs - const outcome = await client - .executeWorkflow("placeOrder", { - workflowId: "wf-invalid-1", - args: { orderId: "o-1", quantity: 0 }, - }) - .match({ - ok: () => "WRONGLY PLACED", - errCases: (matcher) => - matcher - .with({ errorName: "InvalidQuantity" }, (error) => `invalid:${error.data.id}`) - .with({ errorName: "OrderAlreadyPlaced" }, (error) => `conflict:${error.data.id}`) - .with(...tagPatterns(WORKFLOW_START_ERROR_TAGS), (error) => `start:${error._tag}`) - .with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => `result:${error._tag}`), - defect: (cause) => `defect:${String(cause)}`, - }); - - // THEN it arrives under its own name rather than the duplicate's or an - // opaque failure, carrying the order it was about - expect(outcome).toBe("invalid:o-1"); - }); - - it("lets Temporal retry an unmodelled failure, which is what a retry policy is for", async ({ - serve, - unmodelled, - }) => { - // GIVEN a repository whose failure nobody modelled, so it is a `Defect` - const { client } = await serve(unmodelled.module); - - // WHEN an execution reaches it and runs out of attempts - const placed = await client.executeWorkflow("placeOrder", { - workflowId: "wf-defect-1", - args: { orderId: "o-1", quantity: 1 }, - }); - - // THEN the third channel takes a third route again, and this time the - // platform owns it: an unnamed failure is retried up to the contract's - // `maximumAttempts` of 3, where the two named ones are declared - // `nonRetryable` and are asked exactly once. The queue worker hand-rolls - // this with an attempt budget; here it is a line of contract. - expect({ failed: placed.isErr(), attempts: unmodelled.attempts() }).toEqual({ - failed: true, - attempts: 3, - }); - }); - - it("publishes the task queue and namespace it polls on Serving.info", async ({ serve }) => { - // GIVEN a worker on a task queue of this test's own - const { app } = await serve(OrderTemporalModule); - - // WHEN the kernel is asked what the runtime published about itself - const info = app.runtimeInfo(); - - // THEN the same channel the API publishes `{ port, prefix }` on and the - // queue worker `{ queue, concurrency }` carries the pair that identifies a - // Temporal worker — and no two of the three shapes share a field - await expect(info).toBeOkWith({ - taskQueue: expect.stringMatching(/^orders-\d+$/u), - namespace: "default", - }); - }); - - it("runs each workflow execution in its own unit, with its own trace id", async ({ - serve, - tapped, - }) => { - // GIVEN the real graph with the very `Logger` instance the use cases write to - const { client } = await serve(tapped.module); - - // WHEN two executions are driven to completion — chained, so neither result - // is dropped - const placed = await client - .executeWorkflow("placeOrder", { - workflowId: "wf-trace-1", - args: { orderId: "o-1", quantity: 1 }, - }) - .flatMap(() => - client.executeWorkflow("placeOrder", { - workflowId: "wf-trace-2", - args: { orderId: "o-2", quantity: 1 }, - }), - ); - - // THEN two executions, two units, two distinct trace ids — each the workflow - // id its activity ran under, and never the out-of-unit `[-]`. The unit id is - // Temporal's own task token, unique per *attempt*; the workflow id is the - // correlation id, which deliberately is not. - expect(placed.map(() => tapped.traces())).toBeOkWith(["[wf-trace-1]", "[wf-trace-2]"]); - }); - - it("lets an in-flight activity finish while draining", async ({ serve, gate }) => { - // GIVEN an activity held open inside the repository. `startWorkflow`, not - // `executeWorkflow`: a drained worker stops polling Workflow Tasks too, so - // the execution never reaches a terminal state and awaiting its result would - // hang the test on a fact that is not under test. - const { app, client } = await serve(gate.module); - const started = client.startWorkflow("placeOrder", { - workflowId: "wf-drain-1", - args: { orderId: "o-1", quantity: 1 }, - }); - await gate.arrived; - - // WHEN the drain starts and the activity is released only once the phase - // moved. `vi.waitUntil` synchronises rather than asserts — the drain samples - // `inFlightAtStart` in the same synchronous turn that advances the phase, so - // releasing afterwards is what makes the report exact rather than racy. - app.requestDrain(); - await vi.waitUntil(() => app.phase() === "draining"); - gate.release(); - - // THEN the payoff, and the first time the kernel's drain contract meets a - // real worker's shutdown semantics: `worker.shutdown()` stops polling for - // new tasks the instant it is called, `run()` resolves only once the - // in-flight activity has finished, and the kernel's own accounting agrees — - // nothing abandoned. Read through the started execution so its `Result` is - // consumed rather than dropped. - const report = await started.flatMap(() => app.exited); - - expect(report).toBeOkWith( - expect.objectContaining({ drain: { inFlightAtStart: 1, completed: 1, abandoned: 0 } }), - ); - }); - - it("releases the runtime at the kernel's deadline when an activity will not finish", async ({ - serve, - gate, - }) => { - // GIVEN an activity held open with nothing to release it, and a drain - // budget of a tenth of a second — so `worker.run()` cannot settle inside - // it and the only way out is the deadline signal - const { app, client } = await serve(gate.module, { drainTimeoutMs: 100 }); - const started = client.startWorkflow("placeOrder", { - workflowId: "wf-stuck-1", - args: { orderId: "o-1", quantity: 1 }, - }); - await gate.arrived; - - // WHEN the drain runs out of time - app.requestDrain(); - const askedAt = Date.now(); - const report = await started.flatMap(() => app.exited); - const elapsedMs = Date.now() - askedAt; - - // THEN the kernel's exit is not held hostage by a worker that cannot stop: - // the activity is reported abandoned and the process is released on the - // kernel's own deadline rather than on Temporal's own `shutdownForceTime`, - // which is what `Serving.drain(signal)` promises the kernel. - expect(report.map((exit) => ({ drain: exit.drain, promptly: elapsedMs < 5_000 }))).toBeOkWith({ - drain: { inFlightAtStart: 1, completed: 0, abandoned: 1 }, - promptly: true, - }); - }); -}); diff --git a/examples/order-temporal/src/workflows.ts b/examples/order-temporal/src/workflows.ts deleted file mode 100644 index d709b0b..0000000 --- a/examples/order-temporal/src/workflows.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { orderContract } from "@btravstack/start-example-order-temporal-contract"; -import { - ACTIVITY_CANCELLED_ERROR_TAG, - ACTIVITY_ERROR_TAG, - declareWorkflow, - propagateActivityFailure, -} from "@temporal-contract/worker/workflow"; -import { P } from "unthrown"; - -/** - * The workflow, in its own module — and it has to be. - * - * Workflow code runs inside a deterministic V8 sandbox that webpack bundles - * separately from the worker's own module graph, so it may not sit in a spec - * file, and it must be free of side effects at module scope: `TypedWorker.create` - * also imports it in the main thread to check that every declared workflow is - * exported. Nothing here reaches the di container or the database — those live - * behind the *activity*, which is where this example's kernel unit is opened. - * - * The body is the second half of the triage `contract.ts` describes. An - * activity-declared contract error is rehydrated **here**, and the client never - * sees it; a workflow-declared one is rehydrated **at the client**. So the two - * domain failures are re-minted against `context.errors` and everything else is - * handed to `propagateActivityFailure`, which re-raises Temporal's original - * failure so the platform classifies the execution exactly as it would have if - * the activity call had thrown. - * - * Every case is named — this repo bans `P._`, and the two activity-machinery - * arms are as much a decision as the two domain ones. - */ -export const placeOrder = declareWorkflow({ - workflowName: "placeOrder", - contract: orderContract, - implementation: (context, args) => - propagateActivityFailure( - context.activities - .place({ orderId: args.orderId, quantity: args.quantity }) - .mapErrCases((matcher) => - matcher - .with({ errorName: "InvalidQuantity" }, (error) => - context.errors.InvalidQuantity({ id: error.data.id }), - ) - .with({ errorName: "OrderAlreadyPlaced" }, (error) => - context.errors.OrderAlreadyPlaced({ id: error.data.id }), - ) - // The activity failed for a reason nobody declared, and Temporal - // has already exhausted the contract's retry policy. Handing the - // wrapper straight back lets `propagateActivityFailure` re-raise - // the failure underneath it, so the execution fails the way an - // untyped Temporal workflow would. Both machinery tags are named, - // grouped into one arm because they share a handler. - .with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => error), - ), - ), -}); diff --git a/examples/order-worker/README.md b/examples/order-worker/README.md deleted file mode 100644 index 0775169..0000000 --- a/examples/order-worker/README.md +++ /dev/null @@ -1,166 +0,0 @@ -# `@btravstack/start-core` example: the order worker - -The second deployment. The same application, the same persistence, the same -composition — consumed off a queue instead of served over HTTP. - -``` -src/queue.ts the broker, reduced to what a worker needs of one -src/queue-runtime.ts the Runtime: start / drain / stop, and the ack/retry/dead-letter mapping -src/module.ts OrderWorkerModule — the composition root -src/env.ts process.env validated through a schema, as a Result -src/main.ts the process: readEnv + start + runMain -src/test-fixtures.ts serve / queue / gate / tapped, as Vitest fixtures -``` - -## The point of this package - -`OrderWorkerModule` is `OrderApiModule` with a different name: - -```ts -export const OrderWorkerModule = Module("OrderWorker")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder, Logger], -}); -``` - -Nothing in `order-application` or `order-infrastructure` changed to make this -work, and nothing could have: the use cases return a `Result`, and what a -`Result` means to a transport is the transport's business. **One process, one -runtime** is not a slogan the kernel makes you take on trust — it is two -composition roots, two `Runtime` values, and one application underneath. - -## The same `Err`, two transports - -`DuplicateOrder` is one value. Over HTTP there is a caller waiting to be told, -so it becomes a `CONFLICT` the client receives **as a value**. On a queue there -is no caller, so the message is **parked** for a human instead. - -| unthrown | oRPC (`order-api`) | queue (this package) | -| ---------------------- | ----------------------- | --------------------------- | -| `Ok(order)` | the procedure's output | **ack** | -| `Err(InvalidQuantity)` | `INVALID_QUANTITY` | **dead-letter** | -| `Err(DuplicateOrder)` | `CONFLICT` | **dead-letter** | -| `Defect` | `INTERNAL_SERVER_ERROR` | **retry**, then dead-letter | - -The third row is the sharp one and the fourth is its mirror. An unmodelled -failure is the infrastructure one — a dropped connection, a pool timeout — and -infrastructure comes back, so it is the one thing worth another delivery. A -modelled error never is: a redelivery would ask the same impossible thing again. - -```ts -ctx - .get(PlaceOrder) - .execute(job.orderId, job.quantity) - .match({ - ok: () => ack, - errCases: (matcher) => - matcher.with(P.tag("InvalidQuantity"), P.tag("DuplicateOrder"), (error) => - deadLetter(error._tag), - ), - defect: (cause) => retry(String(cause)), - }); -``` - -Every case is named — this repo bans `P._`, and there is no `.otherwise()`. The -two that share a handler are **grouped** into one arm rather than duplicated, -which is what `no-catch-all-pattern` steers you toward instead of a wildcard; -`error` stays the narrowed union of the two, so `error._tag` still names which -one it was. A new domain error is a compile error here **and** in -`order-api/src/router.ts`, at the two places that have to decide what it means. - -## Acking is flushing - -The disposition is applied **inside the unit**, exactly as the API writes its -response inside the unit: - -```ts -host.run(metaFor(delivery), (ctx, _signal) => - dispositionOf(ctx, delivery.job).flatMap((disposition) => - dispose(ctx.get(Logger), queue, delivery, disposition), - ), -); -``` - -A unit closes the instant its `Result` settles, and an idle registry is the -kernel's permission to call `Serving.stop()`. Settling the message afterwards -would race that: the message would be neither acked nor requeued when the -process went away. Flushing a response and acking a message are the same -obligation, wearing different clothes. - -## A delivery is the unit; the message is the trace - -```ts -const metaFor = (delivery: Delivery): UnitMeta => ({ - kind: "job", - id: `${delivery.job.id}#${delivery.attempt}`, - traceId: delivery.job.id, -}); -``` - -`UnitMeta.id` must be unique per unit, and a message id is not one: a retried -message is delivered twice and is two units. So the **delivery** is the id, and -the message id becomes the `traceId` — which is exactly what `traceId` is for. -It is the correlation id, minted outside this process, and holding it steady -across attempts is what joins three deliveries into one trace. - -## A publish resolves on a **worker**, not on a broker - -`OrderQueue.publish` hands the producer the consumer's outcome: - -```ts -await expect(queue.publish(aJob("job-1", "o-1", 2))).toBeOkWith({ - jobId: "job-1", - outcome: "acked", - attempts: 1, -}); -``` - -That is a test convenience, and it carries a precondition worth stating: a real -AMQP `publish` resolves on the **broker's** ack and the producer never learns -how the message ended. Here it resolves when a **running worker settles** the -job — so the attempt budget, which bounds the retries of a job a worker has -claimed, says nothing about a job being claimed at all. Publish with no worker -running, or leave a message behind when one drains, and the returned -`AsyncResult` **never settles**: awaiting it waits forever. - -That is not a bug in the queue — it is what a broker does with an unconsumed -message, and `Serving.drain` stopping at _claiming_ is the right shape (the -next worker on the queue takes it). It is a bug waiting to happen in a spec, so -two of them pin it, racing the publish against one macrotask turn rather than -awaiting it: _"never settles a job published with no worker running"_ and -_"leaves a job the drain never claimed unsettled, without waiting for it"_. Both -fail in a millisecond instead of hanging until Vitest's timeout. - -## `Serving.info` with no port in it - -```ts -const info = (await app.runtimeInfo()).get(); // { queue: "orders", concurrency: 1 } -``` - -The API publishes `{ port, prefix }` on the same channel. That is why `Info` is -the runtime's own type parameter rather than a port number baked into the -kernel: a queue consumer has none, and what an operator wants to know about one -is which queue it is on and how many messages it will take at a time. - -## Running it - -```bash -pnpm --filter @btravstack/start-example-order-worker test # 9 runtime specs + 6 env specs -pnpm --filter @btravstack/start-example-order-worker test:types # the needs gate -``` - -`src/needs-gate.test-d.ts` pins the compile-time half: `queueWorkerRuntime` -declares `[PlaceOrder, Logger]` — two of the three ports the module exports, -because a runtime declares what _it_ needs — and a module missing either fails -`start`'s arity gate before anything runs. - -Every helper the specs need is a Vitest fixture in `src/test-fixtures.ts`, so -each file opens on `describe` and each test names its dependencies in its own -parameter list. Shutting an app down is the `serve` fixture's job, which is why -no test here has a `try`/`finally`. - -`src/main.ts` is the process itself. It creates the queue, because this -example's broker is a plain in-memory object; a real deployment builds an AMQP -channel from the environment instead, and nothing above that line changes. Like -`order-api`'s, it is typechecked by the gate rather than executed by it — the -example packages are source-only, and every spec drives `start` directly. diff --git a/examples/order-worker/package.json b/examples/order-worker/package.json deleted file mode 100644 index 0d6faf0..0000000 --- a/examples/order-worker/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@btravstack/start-example-order-worker", - "private": true, - "description": "The second deployment of the clean-architecture example: the same application module, consumed by an in-memory queue worker instead of an oRPC server", - "license": "MIT", - "author": "Benoit TRAVERS ", - "type": "module", - "main": "./src/index.ts", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "test": "vitest run", - "test:types": "tsc --noEmit -p tsconfig.test-d.json", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" - }, - "dependencies": { - "@btravstack/di": "catalog:", - "@btravstack/start-core": "workspace:*", - "@btravstack/start-example-order-application": "workspace:*", - "@btravstack/start-example-order-config": "workspace:*", - "@btravstack/start-example-order-domain": "workspace:*", - "@btravstack/start-example-order-infrastructure": "workspace:*", - "@unthrown/standard-schema": "catalog:", - "unthrown": "catalog:", - "zod": "catalog:" - }, - "devDependencies": { - "@btravstack/tsconfig": "catalog:", - "@types/node": "catalog:", - "@unthrown/vitest": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/examples/order-worker/src/env.spec.ts b/examples/order-worker/src/env.spec.ts deleted file mode 100644 index 455acaf..0000000 --- a/examples/order-worker/src/env.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { readEnv } from "./env.js"; - -// The seven cases the shared fragments have to survive are pinned once, in -// `order-config`. What is this deployment's own is which variables it reads, -// what they default to, and that concurrency is bounded differently from a port. -describe("readEnv", () => { - it("falls back to the documented defaults when nothing is set", () => { - // GIVEN an environment with neither variable set - const source = {}; - - // WHEN it is validated - const env = readEnv(source); - - // THEN both carry their defaults: one probe port, one consumer - expect(env).toBeOkWith({ PROBE_PORT: 9000, CONCURRENCY: 1 }); - }); - - it("reads what a deployment actually supplies", () => { - // GIVEN both set, as the strings an environment always holds - const source = { PROBE_PORT: "0", CONCURRENCY: "8" }; - - // WHEN it is validated - const env = readEnv(source); - - // THEN they arrive parsed, and `0` survives as the ephemeral bind it is - expect(env).toBeOkWith({ PROBE_PORT: 0, CONCURRENCY: 8 }); - }); - - it("refuses a concurrency of zero, which a port's own bounds would allow", () => { - // GIVEN the one value that is legal for a port and absurd for a worker - const source = { CONCURRENCY: "0" }; - - // WHEN it is validated - const env = readEnv(source); - - // THEN the bound that differs between the two variables is the one doing - // the work: a worker consuming nothing is a deployment mistake - expect(env).toBeErrWith([expect.objectContaining({ path: ["CONCURRENCY"] })]); - }); -}); diff --git a/examples/order-worker/src/env.ts b/examples/order-worker/src/env.ts deleted file mode 100644 index 2ad4887..0000000 --- a/examples/order-worker/src/env.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describeEnvIssues, port, wholeNumber } from "@btravstack/start-example-order-config"; -import { fromSchema, type SchemaIssues } from "@unthrown/standard-schema"; -import type { Result } from "unthrown"; -import { z } from "zod"; - -const environment = z.object({ - PROBE_PORT: port(9000), - CONCURRENCY: wholeNumber(1, 1, 64), -}); - -/** The validated environment: every field present, typed, and in range. */ -export type Env = z.infer; - -// `fromSchema` is CURRIED — it takes the schema and hands back the validator. -const validate = fromSchema(environment); - -/** - * Validates the process environment **as a value**. - * - * A schema's own `.parse()` throws, which `unthrown/no-throw` bans and which - * would contradict the example it appears in. `@unthrown/standard-schema` makes - * the issues the modeled `E`, so the entry point folds a bad environment the - * same way it folds any other anticipated failure. `wholeNumber` and the issue - * formatter are the shared ones — see `order-config` for why the non-empty - * string in front of the coercion is load-bearing. - */ -export const readEnv = (source: typeof process.env = process.env): Result => - validate(source); - -export { describeEnvIssues }; diff --git a/examples/order-worker/src/index.ts b/examples/order-worker/src/index.ts deleted file mode 100644 index c4bdd78..0000000 --- a/examples/order-worker/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { OrderWorkerModule } from "./module.js"; -export { - createOrderQueue, - type Delivery, - type OrderQueue, - type PlaceOrderJob, - type Settlement, -} from "./queue.js"; -export { - queueWorkerRuntime, - type OrderWorkerInfo, - type QueueWorkerOptions, -} from "./queue-runtime.js"; diff --git a/examples/order-worker/src/main.ts b/examples/order-worker/src/main.ts deleted file mode 100644 index 2abc933..0000000 --- a/examples/order-worker/src/main.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { runMain, start } from "@btravstack/start-core"; -import { P } from "unthrown"; - -import { describeEnvIssues, readEnv, type Env } from "./env.js"; -import { OrderWorkerModule } from "./module.js"; -import { queueWorkerRuntime } from "./queue-runtime.js"; -import { createOrderQueue } from "./queue.js"; - -/** - * The second process, and — apart from the runtime it names — the same one - * `order-api/src/main.ts` is: validate the environment, build the graph, serve - * it, and turn the exit report into a process exit code. - * - * The queue is created here because this example's broker is a plain in-memory - * object; a real deployment builds an AMQP channel from the environment - * instead, and nothing above this line changes. Typechecked by the gate, not - * executed by it — the example packages are source-only, and every spec drives - * `start` directly. - */ -const work = (env: Env): Promise => - runMain( - start(OrderWorkerModule, { - runtime: queueWorkerRuntime({ - queue: createOrderQueue(), - concurrency: env.CONCURRENCY, - }), - probes: { port: env.PROBE_PORT }, - }), - ); - -/** sysexits(3) `EX_CONFIG`: the deployment is wrong, not the code. */ -const abort = (reason: string): void => { - process.stderr.write(`${reason}\n`); - process.exitCode = 78; -}; - -await readEnv().match({ - ok: work, - // oxlint-disable-next-line unthrown/no-catch-all-pattern -- `E` is the issues array: one type with no discriminant, so there is nothing to enumerate and the single arm IS the enumeration - errCases: (matcher) => matcher.with(P._, (issues) => abort(describeEnvIssues(issues))), - defect: (cause) => abort(`the environment could not be validated: ${String(cause)}`), -}); diff --git a/examples/order-worker/src/module.ts b/examples/order-worker/src/module.ts deleted file mode 100644 index 2920ac9..0000000 --- a/examples/order-worker/src/module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from "@btravstack/di"; -import { - ApplicationModule, - FindOrder, - Logger, - PlaceOrder, -} from "@btravstack/start-example-order-application"; -import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; - -/** - * The composition root of the second deployment — and, imports and exports - * alike, the same one `OrderApiModule` is. That is the claim this package - * exists to make: `ApplicationModule` and `PersistenceModule` are booted here - * unchanged, under a runtime that speaks a queue instead of HTTP. - * - * It is declared here rather than imported from `order-api` on purpose. Sharing - * the module would also share the API's oRPC dependency, and a worker - * deployment that installs a web server to reach its use cases would falsify - * the very thing this is demonstrating. Two processes, two composition roots, - * one application. - */ -export const OrderWorkerModule = Module("OrderWorker")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder, Logger], -}); diff --git a/examples/order-worker/src/needs-gate.test-d.ts b/examples/order-worker/src/needs-gate.test-d.ts deleted file mode 100644 index d4bc5df..0000000 --- a/examples/order-worker/src/needs-gate.test-d.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * The compile-time half of the second deployment: `queueWorkerRuntime` declares - * two ports in `needs`, and `start`'s phantom rest-tuple gate turns a module - * that does not export both into a call-site arity error. Type-checked by this - * package's `test:types` script, never executed. - * - * Together with `order-api`'s, this is what makes the claim testable rather - * than asserted: two runtimes with different, non-empty `needs`, both proven - * against the same application graph at the `start(...)` call site. - */ -import { Module } from "@btravstack/di"; -import { start } from "@btravstack/start-core"; -import { - ApplicationModule, - FindOrder, - Logger, - PlaceOrder, -} from "@btravstack/start-example-order-application"; -import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; - -import { OrderWorkerModule } from "./module.js"; -import { queueWorkerRuntime } from "./queue-runtime.js"; -import { createOrderQueue } from "./queue.js"; - -const options = { - runtime: queueWorkerRuntime({ queue: createOrderQueue() }), - signals: false, - probes: false, -} as const; - -// Positive: the composition root exports both ports the runtime needs (and a -// third it does not), so the gate collapses to an empty tuple and this is an -// ordinary two-argument call. -const _wired = start(OrderWorkerModule, options); - -// The same graph, one port short: `Logger` is provided (the interactors depend -// on it) but not exported, so it is not in the application context the runtime -// is handed. -const PartialWorker = Module("PartialWorker")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder], -}); - -// Negative: the gate becomes a required two-element tuple naming the unmet need, -// and the call fails on arity. -// @ts-expect-error — UNSATISFIED RUNTIME NEEDS: the module does not export Logger. -const _missingLogger = start(PartialWorker, options); diff --git a/examples/order-worker/src/queue-runtime.spec.ts b/examples/order-worker/src/queue-runtime.spec.ts deleted file mode 100644 index 6f090dd..0000000 --- a/examples/order-worker/src/queue-runtime.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, vi } from "vitest"; - -import { OrderWorkerModule } from "./module.js"; -import { it } from "./test-fixtures.js"; - -describe("queueWorkerRuntime", () => { - it("acks a job whose order the use case places", async ({ serve, queue, aJob }) => { - // GIVEN the same composition the API boots — `ApplicationModule` and - // `PersistenceModule`, unchanged — under a queue runtime instead - serve(OrderWorkerModule); - - // WHEN a job is published - // THEN it reached the use case behind the transport, and the message is done - await expect(queue.publish(aJob("job-1", "o-1", 2))).toBeOkWith({ - jobId: "job-1", - outcome: "acked", - attempts: 1, - }); - }); - - it("settles both publishes when a message id is reused while still pending", async ({ - serve, - queue, - aJob, - }) => { - // GIVEN two concurrent publishes carrying the same message id — the - // duplicate this example's whole story is about, arriving on the producer - // side rather than the domain one - serve(OrderWorkerModule); - - // WHEN both are sent before either settles — chaining them would let the - // first clear the map and hide the overwrite entirely - const first = queue.publish(aJob("job-1", "o-1", 2)); - const second = queue.publish(aJob("job-1", "o-2", 2)); - const both = first.flatMap((one) => second.map((two) => [one, two])); - - // THEN neither producer is stranded. Keeping one resolver per id would let - // the second publish overwrite the first, whose `AsyncResult` then never - // settles — an awaiting producer hangs with no error and no timeout. - await expect(both).toBeOkWith([ - expect.objectContaining({ jobId: "job-1" }), - expect.objectContaining({ jobId: "job-1" }), - ]); - }); - - it("dead-letters the DuplicateOrder the API answers CONFLICT for", async ({ - serve, - queue, - aJob, - }) => { - // GIVEN an order already placed by a first job - serve(OrderWorkerModule); - - // WHEN a second job asks for the same order id — chained, so the first - // job's settlement is consumed and a failure there cannot be mistaken for - // the duplicate. A second job is a second unit, over the same database: - // the application scope is opened once, by the kernel. - const settled = await queue - .publish(aJob("job-1", "o-1", 2)) - .flatMap(() => queue.publish(aJob("job-2", "o-1", 2))); - - // THEN the load-bearing assertion of this whole example: the identical - // `Err` the oRPC runtime turns into an inferable CONFLICT is parked here, - // because a queue has no caller waiting to be told. One `Result`, two - // transports, and the kernel involved in neither mapping. - expect(settled).toBeOkWith({ - jobId: "job-2", - outcome: "dead-lettered", - reason: "DuplicateOrder", - attempts: 1, - }); - }); - - it("dead-letters a job the domain rejects, rather than redelivering it", async ({ - serve, - queue, - aJob, - }) => { - // GIVEN a quantity the domain invariant rejects - serve(OrderWorkerModule); - - // WHEN the job is published - // THEN it is parked on the first attempt: a redelivery would ask the same - // impossible thing again - await expect(queue.publish(aJob("job-1", "o-1", 0))).toBeOkWith({ - jobId: "job-1", - outcome: "dead-lettered", - reason: "InvalidQuantity", - attempts: 1, - }); - }); - - it("redelivers an unmodelled failure, then parks it once the attempts run out", async ({ - serve, - queue, - aJob, - unmodelled, - }) => { - // GIVEN a repository whose failure nobody modelled, so it is a `Defect` - serve(unmodelled); - - // WHEN a job reaches it - // THEN the third channel takes the third route: infrastructure comes back, - // so a defect is worth another delivery — three of them, and then the - // message is parked carrying the cause - await expect(queue.publish(aJob("job-1", "o-1", 1))).toBeOkWith({ - jobId: "job-1", - outcome: "dead-lettered", - reason: "Error: the database is on fire", - attempts: 3, - }); - }); - - it("publishes the queue it consumes on Serving.info", async ({ serve }) => { - // GIVEN a worker consuming the fixture's queue - const app = serve(OrderWorkerModule); - - // WHEN the kernel is asked what the runtime published about itself - const info = app.runtimeInfo(); - - // THEN the same channel the API publishes `{ port, prefix }` on carries a - // shape with no port in it at all - await expect(info).toBeOkWith({ queue: "orders", concurrency: 1 }); - }); - - it("runs each job in its own unit, with its own trace id", async ({ - serve, - queue, - aJob, - tapped, - }) => { - // GIVEN the real graph with the very `Logger` instance the use cases and - // the disposition write to - serve(tapped.worker); - - // WHEN two jobs are consumed — chained, so neither settlement is dropped - const settled = await queue - .publish(aJob("job-1", "o-1", 1)) - .flatMap(() => queue.publish(aJob("job-2", "o-2", 1))); - - // THEN two jobs, two interactor lines plus two disposition lines, each - // carrying the message id its delivery correlated to and never the - // out-of-unit `[-]`. The unit id is `job-1#1`, the trace id is `job-1`: - // a redelivery is a new unit and the same trace. - expect(settled.map(() => tapped.traces())).toBeOkWith([ - "[job-1]", - "[job-1]", - "[job-2]", - "[job-2]", - ]); - }); - - it("never settles a job published with no worker running", async ({ - queue, - aJob, - withinATurn, - }) => { - // GIVEN a job published with nothing serving — no `serve`, so nothing will - // ever claim it - const published = queue.publish(aJob("job-1", "o-1", 1)); - - // WHEN it is given a full turn to settle - const outcome = await withinATurn(published); - - // THEN it is still pending, which is the precondition `publish` documents: - // a settlement comes from a *worker*, and the attempt budget bounds the - // retries of a claimed job, not the wait for a claim. Awaiting it here - // would hang the suite — racing a turn is what makes it a failure instead - expect(outcome).toBe("unsettled"); - }); - - it("leaves a job the drain never claimed unsettled, without waiting for it", async ({ - serve, - queue, - aJob, - gate, - withinATurn, - }) => { - // GIVEN a worker at its concurrency limit — one delivery held open inside - // the repository, and a second job queued behind it - const app = serve(gate.worker); - const held = queue.publish(aJob("job-1", "o-1", 1)); - await gate.arrived; - const queued = queue.publish(aJob("job-2", "o-2", 1)); - - // WHEN the drain runs to completion, the in-flight delivery released only - // once the phase moved - app.requestDrain(); - await vi.waitUntil(() => app.phase() === "draining"); - gate.release(); - await vi.waitUntil(() => app.phase() === "exited"); - - // THEN the drain waited for the delivery it had claimed and not for the one - // it had not: `Serving.drain` stops claiming, so an unclaimed message stays - // in the queue for the next worker — and this producer waits forever - expect({ inFlight: await withinATurn(held), unclaimed: await withinATurn(queued) }).toEqual({ - inFlight: "settled", - unclaimed: "unsettled", - }); - }); - - it("waits for the in-flight job while draining", async ({ serve, queue, aJob, gate }) => { - // GIVEN a job held open inside the repository - const app = serve(gate.worker); - const settled = queue.publish(aJob("job-1", "o-1", 1)); - await gate.arrived; - - // WHEN the drain starts and the job is released only once the phase moved. - // `vi.waitUntil` synchronises rather than asserts — the drain samples - // `inFlightAtStart` in the same synchronous turn that advances the phase, - // so releasing afterwards is what makes the report exact rather than racy. - app.requestDrain(); - await vi.waitUntil(() => app.phase() === "draining"); - gate.release(); - - // THEN the drain waited for the delivery to be acked — read through the - // settlement, so its own `Result` is consumed and a job that never settled - // could not be reported as completed - const report = await settled.flatMap(() => app.exited); - - expect(report).toBeOkWith( - expect.objectContaining({ drain: { inFlightAtStart: 1, completed: 1, abandoned: 0 } }), - ); - }); -}); diff --git a/examples/order-worker/src/queue-runtime.ts b/examples/order-worker/src/queue-runtime.ts deleted file mode 100644 index 129087b..0000000 --- a/examples/order-worker/src/queue-runtime.ts +++ /dev/null @@ -1,240 +0,0 @@ -import type { Context, ServiceOf } from "@btravstack/di"; -import type { Runtime, RuntimeHost, Serving, UnitMeta } from "@btravstack/start-core"; -import { Logger, PlaceOrder } from "@btravstack/start-example-order-application"; -import { OkAsync, P, fromSafePromise, type AsyncResult } from "unthrown"; - -import type { Delivery, OrderQueue, PlaceOrderJob } from "./queue.js"; - -/** - * What the worker publishes about itself once it is consuming, read back - * through `RunningApp.runtimeInfo()`. - * - * Nothing like the API's `{ port, prefix }`, which is the point: `Serving.info` - * is the runtime's own shape and deliberately not modelled as a port number. A - * queue consumer has no port, and what an operator wants to know about one is - * which queue it is on and how many messages it will take at a time. - */ -export type OrderWorkerInfo = { readonly queue: string; readonly concurrency: number }; - -/** - * How many times a message may be delivered before it is parked. Fixed rather - * than an option: nothing here ever set it, and a knob no caller turns - * demonstrates only that `??` has a right-hand side. - */ -const MAX_ATTEMPTS = 3; - -export type QueueWorkerOptions = { - readonly queue: OrderQueue; - /** How many deliveries may be in flight at once. Default `1`. */ - readonly concurrency?: number; -}; - -/** - * The ports this runtime resolves out of the application context — two of the - * three the module exports, because `FindOrder` is not part of any job this - * worker consumes. A runtime declares what *it* needs, not what the module has. - * - * Non-empty on purpose: it is what makes `start`'s arity gate mean something. - * A module that does not export both fails to compile at the `start(...)` call, - * before anything runs (`src/needs-gate.test-d.ts` pins both directions). - */ -type WorkerNeeds = typeof PlaceOrder | typeof Logger; - -/** - * A `Runtime` consuming order jobs off a queue. - * - * - `start` subscribes to the queue and begins pumping. There is nothing to - * bind, so the error channel is empty here — a worker holding a real broker - * connection would report a failed connect as `Err(RuntimeStartFailed)`, - * exactly as the oRPC runtime reports a failed bind. - * - `Serving.drain` stops *claiming*. Deliveries already in flight are the - * kernel's to time out, and the kernel's deadline signal has nothing to - * cancel here. A message not yet claimed stays in the queue **unsettled**, - * and the drain does not wait for it: draining hands nothing back to a - * broker, it stops taking more, and the next worker on the queue takes it. - * So a producer awaiting that message's settlement — the convenience - * `OrderQueue.publish` offers — waits forever. - * - `Serving.stop` is the same act with nothing left to add: an in-memory queue - * has no connection to close. - */ -export const queueWorkerRuntime = ( - options: QueueWorkerOptions, -): Runtime => ({ - name: "queue-worker", - needs: [PlaceOrder, Logger], - start: (host) => OkAsync(consume(host, options)), -}); - -/** - * How a delivery ends. This is the transport mapping, and the whole reason this - * package exists beside `order-api`: the same three channels, folded into a - * queue's vocabulary instead of HTTP's. - */ -type Disposition = - | { readonly kind: "ack" } - | { readonly kind: "dead-letter"; readonly reason: string } - | { readonly kind: "retry"; readonly reason: string }; - -const ack: Disposition = { kind: "ack" }; -const deadLetter = (reason: string): Disposition => ({ kind: "dead-letter", reason }); -const retry = (reason: string): Disposition => ({ kind: "retry", reason }); - -/** - * The triage point — the boundary where the application's vocabulary stops, - * and the mirror of `order-api`'s `mapErrCases` into `ORPCError` codes. - * - * The same `Err` lands somewhere else entirely. `DuplicateOrder` is a `CONFLICT` - * over HTTP because there is a caller waiting to be told; a queue has no caller, - * so the message is **parked** for a human instead. `InvalidQuantity` likewise: - * a redelivery would ask the same impossible thing again. What *is* worth - * another delivery is a `Defect` — an unmodelled failure is the infrastructure - * one, and infrastructure comes back. - * - * Every case is named — grouped into one arm, not collapsed into a wildcard: - * both park the message, and `error._tag` is still the narrowed union of the two - * so the reason names which one it was. A new domain error is a compile error - * here, at the one place that has to decide what happens to the message. - */ -const dispositionOf = ( - ctx: Context>, - job: PlaceOrderJob, -): AsyncResult => - fromSafePromise( - ctx - .get(PlaceOrder) - .execute(job.orderId, job.quantity) - .match({ - ok: () => ack, - errCases: (matcher) => - matcher.with(P.tag("InvalidQuantity"), P.tag("DuplicateOrder"), (error) => - deadLetter(error._tag), - ), - defect: (cause) => retry(String(cause)), - }), - ); - -/** - * Applying the disposition, **inside the unit**. - * - * This is the queue-shaped form of the contract a runtime owes: a unit closes - * the instant its `Result` settles, and an idle registry is the kernel's - * permission to call `Serving.stop()`. Settling the message after the unit - * returned would race that — the message would be neither acked nor requeued - * when the process went away. Flushing a response and acking a message are the - * same obligation. - */ -const dispose = ( - logger: ServiceOf, - queue: OrderQueue, - delivery: Delivery, - disposition: Disposition, -): AsyncResult => { - const { job, attempt } = delivery; - - if (disposition.kind === "retry" && attempt < MAX_ATTEMPTS) { - logger.info(`job ${job.id} retried after attempt ${attempt}: ${disposition.reason}`); - queue.requeue(delivery); - return OkAsync(); - } - - logger.info(`job ${job.id} ${disposition.kind === "ack" ? "acked" : "dead-lettered"}`); - queue.settle( - disposition.kind === "ack" - ? { jobId: job.id, outcome: "acked", attempts: attempt } - : { jobId: job.id, outcome: "dead-lettered", reason: disposition.reason, attempts: attempt }, - ); - return OkAsync(); -}; - -const deliver = ( - host: RuntimeHost, - queue: OrderQueue, - delivery: Delivery, -): AsyncResult => - host.run(metaFor(delivery), (ctx, _signal) => - dispositionOf(ctx, delivery.job).flatMap((disposition) => - dispose(ctx.get(Logger), queue, delivery, disposition), - ), - ); - -const consume = ( - host: RuntimeHost, - options: QueueWorkerOptions, -): Serving => { - const { queue } = options; - const concurrency = options.concurrency ?? 1; - - let accepting = true; - let inFlight = 0; - - const pump = (): void => { - while (accepting && inFlight < concurrency) { - const delivery = queue.claim(); - if (delivery === undefined) return; - - inFlight += 1; - // The unit's outcome is FOLDED to a value here rather than dropped: - // `AsyncResult` has an empty *error* channel, but a `Defect` - // can still be present. - void deliver(host, queue, delivery).match({ - ok: () => released(), - // Nothing can land in the error channel — a job's own failure became a - // disposition inside the unit — so the matcher has no case to name. - errCases: (matcher) => matcher, - // Reached only if the disposition machinery itself failed, which leaves - // the message neither acked nor requeued. Parking it is the one - // remaining courtesy: a producer waiting on it gets an answer. - defect: (cause) => { - queue.settle({ - jobId: delivery.job.id, - outcome: "dead-lettered", - reason: String(cause), - attempts: delivery.attempt, - }); - released(); - }, - }); - } - }; - - const released = (): void => { - inFlight -= 1; - pump(); - }; - - const unsubscribe = queue.subscribe(pump); - - const stopClaiming = (): void => { - accepting = false; - unsubscribe(); - }; - - pump(); - - return { - info: { queue: queue.name, concurrency }, - drain: (signal) => { - void signal; - stopClaiming(); - return OkAsync(); - }, - stop: () => { - stopClaiming(); - return OkAsync(); - }, - }; -}; - -/** - * `UnitMeta.id` must be unique per unit, and a *message* id is not: a retried - * message is delivered twice and is two units. The delivery is the unit, so the - * attempt is part of the id — and the message id becomes the `traceId`, which - * is exactly what `traceId` is for. It is the correlation id, minted outside - * this process, and it stays the same across every attempt, so all three - * deliveries of a message join up in the log. - */ -const metaFor = (delivery: Delivery): UnitMeta => ({ - kind: "job", - id: `${delivery.job.id}#${delivery.attempt}`, - traceId: delivery.job.id, -}); diff --git a/examples/order-worker/src/queue.ts b/examples/order-worker/src/queue.ts deleted file mode 100644 index 5967e3c..0000000 --- a/examples/order-worker/src/queue.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { fromSafePromise, type AsyncResult } from "unthrown"; - -/** - * One message: place this order, this many items. - * - * `id` is the **message** id, minted by whoever published it, and it is - * deliberately not the order id — a message can be delivered more than once, - * and correlating those deliveries is exactly what it is for. - */ -export type PlaceOrderJob = { - readonly id: string; - readonly orderId: string; - readonly quantity: number; -}; - -/** One **delivery** of a message: the job, and which attempt this is. */ -export type Delivery = { readonly job: PlaceOrderJob; readonly attempt: number }; - -/** - * How a message ended, once it is no longer the broker's problem. A retry is - * not here on purpose: it is not an ending, it is another delivery. - */ -export type Settlement = - | { readonly jobId: string; readonly outcome: "acked"; readonly attempts: number } - | { - readonly jobId: string; - readonly outcome: "dead-lettered"; - readonly reason: string; - readonly attempts: number; - }; - -/** - * The broker, reduced to the four things a worker actually needs of one — plus - * `publish`, which is the producer's half. - * - * It is a plain in-memory object rather than a di port because it is the - * *transport*, not an application capability: the runtime owns it exactly as - * the oRPC runtime owns its `node:http` server, and nothing in the application - * or persistence layers can name it. A real deployment swaps this for an AMQP - * channel and the runtime above is unchanged. - */ -export type OrderQueue = { - readonly name: string; - /** - * Publishes a job and resolves when a **running worker settles** it — acked - * or dead-lettered, however many deliveries that took. - * - * That is a precondition, not a guarantee. The attempt budget bounds the - * retries of a job a worker has *claimed*; nothing bounds the wait for a - * claim. A job published with no worker running, or left in the queue when - * one stops or drains, is never settled and **awaiting it waits forever** — - * which is what a broker does with an unconsumed message, rather than a - * defect of this one. `queue-runtime.spec.ts` pins both halves. - * - * The empty error channel is honest about something narrower: publishing - * itself cannot fail, and a dead-letter is a settlement rather than an error. - * - * Resolving on the *consumer's* outcome is a deliberate test convenience: a - * real AMQP `publish` resolves on the broker's ack and the producer never - * learns how the message ended. It is what lets a spec be the producer half - * and assert the disposition in one `expect` — and it is why awaiting one is - * only ever safe under a serving worker. - */ - readonly publish: (job: PlaceOrderJob) => AsyncResult; - /** Consumer side: take the next delivery, or `undefined` if there is none. */ - readonly claim: () => Delivery | undefined; - /** Consumer side: hand a delivery back for one more attempt. */ - readonly requeue: (delivery: Delivery) => void; - /** Consumer side: this message is finished with. */ - readonly settle: (settlement: Settlement) => void; - /** Consumer side: wake up when there is something to claim. Unsubscribes. */ - readonly subscribe: (listener: () => void) => () => void; -}; - -export const createOrderQueue = (name = "orders"): OrderQueue => { - const pending: Delivery[] = []; - // Every waiter for an id, not the latest one. A message id is the producer's - // to mint, so two publishes can carry the same one before either settles — - // and a single-resolver map would silently overwrite the first, whose - // `AsyncResult` then never settles at all. An awaiting producer would hang - // with no error and no timeout, which is the worst shape a bug can take here. - const settlements = new Map void)[]>(); - const listeners = new Set<() => void>(); - - const notify = (): void => { - for (const listener of listeners) listener(); - }; - - return { - name, - - publish: (job) => - fromSafePromise( - // The executor runs synchronously, so the message is queued before - // `publish` returns — a worker already pumping picks it up on this - // very tick. - new Promise((resolve) => { - // Appended in place rather than rebuilt: reusing an id is the case - // this map now exists to support, and copying the array on each - // publish would make that path quadratic for no benefit. - const waiters = settlements.get(job.id); - if (waiters === undefined) settlements.set(job.id, [resolve]); - else waiters.push(resolve); - - pending.push({ job, attempt: 1 }); - notify(); - }), - ), - - claim: () => pending.shift(), - - requeue: (delivery) => { - pending.push({ job: delivery.job, attempt: delivery.attempt + 1 }); - notify(); - }, - - settle: (settlement) => { - const waiters = settlements.get(settlement.jobId) ?? []; - settlements.delete(settlement.jobId); - for (const resolve of waiters) resolve(settlement); - }, - - subscribe: (listener) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }; -}; diff --git a/examples/order-worker/src/test-fixtures.ts b/examples/order-worker/src/test-fixtures.ts deleted file mode 100644 index 847b29f..0000000 --- a/examples/order-worker/src/test-fixtures.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { Module, Port, Provider, type Scope, type ServiceOf } from "@btravstack/di"; -import { start, type RunningApp } from "@btravstack/start-core"; -import { - ApplicationModule, - FindOrder, - Logger, - OrderRepository, - PlaceOrder, -} from "@btravstack/start-example-order-application"; -import { OrderNotFound } from "@btravstack/start-example-order-domain"; -import { ErrAsync, fromSafePromise, type AsyncResult } from "unthrown"; -import { expect, test } from "vitest"; - -import { OrderWorkerModule } from "./module.js"; -import { queueWorkerRuntime, type OrderWorkerInfo } from "./queue-runtime.js"; -import { createOrderQueue, type OrderQueue, type PlaceOrderJob, type Settlement } from "./queue.js"; - -type App = RunningApp; - -/** - * `X` is pinned to the three ports the composition roots export rather than - * left generic: `start`'s needs gate is a phantom rest parameter proven at the - * call site, and no proof is available inside a helper generic in the module's - * own exports. The runtime needs only two of them. - */ -type WorkerPorts = PlaceOrder | FindOrder | Logger; - -type Serve = (module: Module) => App; - -/** A message id that is deliberately not the order id — see `PlaceOrderJob`. */ -const jobOf = (id: string, orderId: string, quantity: number): PlaceOrderJob => ({ - id, - orderId, - quantity, -}); - -/** - * Reports whether a publish settled within one macrotask turn. - * - * `publish` only resolves when a running worker settles the job, so awaiting - * one that nobody will claim hangs the test until Vitest's timeout — a broken - * suite where the fact under test is a legitimate state. The turn boundary is - * a bound rather than a guess: the delivery path is microtasks end to end, with - * no timer anywhere in it, so anything a serving worker was going to settle has - * settled by the time the immediate fires. - */ -const settledWithinATurn = ( - published: AsyncResult, -): Promise<"settled" | "unsettled"> => - Promise.race([ - published.match({ - ok: () => "settled" as const, - errCases: (matcher) => matcher, - defect: () => "settled" as const, - }), - new Promise<"unsettled">((resolve) => { - setImmediate(() => resolve("unsettled")); - }), - ]); - -const persistenceOf = (repository: ServiceOf) => - Module("StubPersistence")({ - provides: [Provider(OrderRepository)({ value: repository })], - exports: [OrderRepository], - }); - -/** - * A composition root shaped like the real one but with the repository swapped: - * same `ApplicationModule`, same runtime, same three exported ports, so the - * transport under test is unchanged. - */ -const workerWith = (repository: ServiceOf) => - Module("StubWorker")({ - imports: [ApplicationModule, persistenceOf(repository)], - exports: [PlaceOrder, FindOrder, Logger], - }); - -/** - * `start` hands the application context to the runtime alone, so a spec cannot - * reach `Logger` the way `Module.scoped` can. This publishes the very `Logger` - * service instance the use cases and the disposition write to. - */ -class LoggerTap extends Port("LoggerTap")<{ readonly lines: () => readonly string[] }> {} - -const tappedWorker = () => { - let read: () => readonly string[] = () => []; - - return { - worker: Module("TappedWorker")({ - imports: [OrderWorkerModule], - provides: [ - Provider(LoggerTap)([Logger], { - sync: (logger) => { - read = logger.lines; - return { lines: logger.lines }; - }, - }), - ], - exports: [PlaceOrder, FindOrder, Logger], - }), - traces: (): readonly string[] => read().map((line) => line.slice(0, line.indexOf("]") + 1)), - }; -}; - -/** - * A composition root whose repository fails in a way nobody modelled: no - * `qualify` triaged the rejection, so it is a defect — and a defect is what the - * worker retries. - */ -const unmodelledWorker = () => - workerWith({ - save: () => fromSafePromise(Promise.reject(new Error("the database is on fire"))), - find: (id) => ErrAsync(new OrderNotFound({ id })), - }); - -/** - * A repository whose `save` never settles until `release()` is called, and - * whose `arrived` promise reports the moment the job reached it. The drain spec - * turns on knowing a unit is genuinely in flight before the drain starts — - * polling a wall clock instead would be the flake. - */ -const gatedWorker = () => { - let entered!: () => void; - const arrived = new Promise((resolve) => { - entered = resolve; - }); - let release!: () => void; - const held = new Promise((resolve) => { - release = resolve; - }); - - return { - worker: workerWith({ - save: (order) => { - entered(); - return fromSafePromise(held.then(() => order)); - }, - find: (id) => ErrAsync(new OrderNotFound({ id })), - }), - arrived, - release: () => release(), - }; -}; - -export type WorkerFixtures = { - /** - * The very queue the runtime consumes, so a spec is the producer half. - * - * A publish is awaited only under a `serve`d worker — that is the - * precondition `OrderQueue.publish` documents, and `settledWithinATurn` is - * how the two specs that go without one stay a failure instead of a hang. - */ - readonly queue: OrderQueue; - /** - * Starts an app on that queue and registers its shutdown. The teardown runs - * even when the test fails, which is what a `try`/`finally` used to - * hand-roll — and it keeps the assertion those blocks carried: the app - * exited `Ok`. - */ - readonly serve: Serve; - readonly aJob: typeof jobOf; - /** Races a publish against one macrotask turn — see `settledWithinATurn`. */ - readonly withinATurn: typeof settledWithinATurn; - readonly unmodelled: ReturnType; - readonly gate: ReturnType; - readonly tapped: ReturnType; -}; - -export const it = test.extend({ - // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture - queue: async ({}, use) => { - await use(createOrderQueue()); - }, - - serve: async ({ queue }, use) => { - const shutdowns: (() => Promise)[] = []; - - const serve: Serve = (module) => { - const app = start(module, { - runtime: queueWorkerRuntime({ queue }), - signals: false, - probes: false, - preDrainDelayMs: 0, - }); - shutdowns.push(async () => { - app.stop(); - await expect(app.exited).toBeOk(); - }); - return app; - }; - - await use(serve); - - for (const shutdown of shutdowns) await shutdown(); - }, - - // oxlint-disable-next-line no-empty-pattern -- see above - aJob: async ({}, use) => { - await use(jobOf); - }, - - // oxlint-disable-next-line no-empty-pattern -- see above - withinATurn: async ({}, use) => { - await use(settledWithinATurn); - }, - - // oxlint-disable-next-line no-empty-pattern -- see above - unmodelled: async ({}, use) => { - await use(unmodelledWorker()); - }, - - // oxlint-disable-next-line no-empty-pattern -- see above - gate: async ({}, use) => { - await use(gatedWorker()); - }, - - // oxlint-disable-next-line no-empty-pattern -- see above - tapped: async ({}, use) => { - await use(tappedWorker()); - }, -}); diff --git a/examples/order-worker/src/vitest.d.ts b/examples/order-worker/src/vitest.d.ts deleted file mode 100644 index ad36daf..0000000 --- a/examples/order-worker/src/vitest.d.ts +++ /dev/null @@ -1 +0,0 @@ -import type {} from "@unthrown/vitest"; diff --git a/examples/order-worker/tsconfig.json b/examples/order-worker/tsconfig.json deleted file mode 100644 index 3faf372..0000000 --- a/examples/order-worker/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "@btravstack/tsconfig/base.json", - "compilerOptions": { - "noEmit": true, - "types": ["node"], - // Inherited from the persistence layer this package composes: the generated - // Prisma client imports its own files with explicit `.ts` extensions, and - // those files are part of this program too. `noEmit` makes that legal. - "allowImportingTsExtensions": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "src/**/*.test-d.ts"] -} diff --git a/examples/order-worker/tsconfig.test-d.json b/examples/order-worker/tsconfig.test-d.json deleted file mode 100644 index 619908b..0000000 --- a/examples/order-worker/tsconfig.test-d.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { "noUnusedLocals": false, "noUnusedParameters": false }, - "include": ["src/**/*.test-d.ts"], - "exclude": ["node_modules"] -} diff --git a/examples/order-worker/vitest.config.ts b/examples/order-worker/vitest.config.ts deleted file mode 100644 index fb76260..0000000 --- a/examples/order-worker/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["src/**/*.spec.ts"], - setupFiles: ["@unthrown/vitest"], - }, -}); diff --git a/knip.json b/knip.json index e9fc074..279bcad 100644 --- a/knip.json +++ b/knip.json @@ -5,7 +5,6 @@ "ignoreDependencies": ["@btravstack/lefthook", "@btravstack/oxlint"], "workspaces": { "examples/order-api": { "entry": ["src/main.ts"] }, - "examples/order-worker": { "entry": ["src/main.ts"] }, - "examples/order-temporal": { "entry": ["src/main.ts"] } + "examples/order-temporal-worker": { "entry": ["src/main.ts"] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b8327a..84c1fcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + '@amqp-contract/client': + specifier: 3.0.0-beta.6 + version: 3.0.0-beta.6 '@amqp-contract/contract': specifier: 3.0.0-beta.6 version: 3.0.0-beta.6 @@ -180,8 +183,42 @@ importers: specifier: 'catalog:' version: 2.10.8 - examples/order-amqp: + examples/order-amqp-contract: dependencies: + '@amqp-contract/contract': + specifier: 'catalog:' + version: 3.0.0-beta.6 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/standard-schema': + specifier: 'catalog:' + version: 5.5.0 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.5.0(unthrown@5.5.0)(vitest@4.1.10) + typescript: + specifier: 'catalog:' + version: 7.0.2 + unthrown: + specifier: 'catalog:' + version: 5.5.0 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + + examples/order-amqp-worker: + dependencies: + '@amqp-contract/client': + specifier: 'catalog:' + version: 3.0.0-beta.6(@opentelemetry/api@1.9.1)(unthrown@5.5.0) '@amqp-contract/worker': specifier: 'catalog:' version: 3.0.0-beta.6(@opentelemetry/api@1.9.1)(unthrown@5.5.0) @@ -203,9 +240,6 @@ importers: '@btravstack/start-example-order-config': specifier: workspace:* version: link:../order-config - '@btravstack/start-example-order-domain': - specifier: workspace:* - version: link:../order-domain '@btravstack/start-example-order-infrastructure': specifier: workspace:* version: link:../order-infrastructure @@ -241,37 +275,6 @@ importers: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) - examples/order-amqp-contract: - dependencies: - '@amqp-contract/contract': - specifier: 'catalog:' - version: 3.0.0-beta.6 - zod: - specifier: 'catalog:' - version: 4.4.3 - devDependencies: - '@btravstack/tsconfig': - specifier: 'catalog:' - version: 0.2.0 - '@types/node': - specifier: 'catalog:' - version: 26.1.2 - '@unthrown/standard-schema': - specifier: 'catalog:' - version: 5.5.0 - '@unthrown/vitest': - specifier: 'catalog:' - version: 5.5.0(unthrown@5.5.0)(vitest@4.1.10) - typescript: - specifier: 'catalog:' - version: 7.0.2 - unthrown: - specifier: 'catalog:' - version: 5.5.0 - vitest: - specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) - examples/order-api: dependencies: '@btravstack/di': @@ -497,79 +500,6 @@ importers: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) - examples/order-temporal: - dependencies: - '@btravstack/di': - specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) - '@btravstack/start-core': - specifier: workspace:* - version: link:../../packages/start-core - '@btravstack/start-example-order-application': - specifier: workspace:* - version: link:../order-application - '@btravstack/start-example-order-config': - specifier: workspace:* - version: link:../order-config - '@btravstack/start-example-order-domain': - specifier: workspace:* - version: link:../order-domain - '@btravstack/start-example-order-infrastructure': - specifier: workspace:* - version: link:../order-infrastructure - '@btravstack/start-example-order-temporal-contract': - specifier: workspace:* - version: link:../order-temporal-contract - '@btravstack/start-temporal': - specifier: workspace:* - version: link:../../packages/start-temporal - '@temporal-contract/client': - specifier: 'catalog:' - version: 8.0.0-beta.5(@temporalio/client@1.22.0)(@temporalio/common@1.22.0)(unthrown@5.5.0) - '@temporal-contract/worker': - specifier: 'catalog:' - version: 8.0.0-beta.5(@temporalio/common@1.22.0)(@temporalio/worker@1.22.0)(@temporalio/workflow@1.22.0)(unthrown@5.5.0) - '@temporalio/client': - specifier: 'catalog:' - version: 1.22.0 - '@temporalio/worker': - specifier: 'catalog:' - version: 1.22.0 - '@temporalio/workflow': - specifier: 'catalog:' - version: 1.22.0 - '@unthrown/standard-schema': - specifier: 'catalog:' - version: 5.5.0 - unthrown: - specifier: 'catalog:' - version: 5.5.0 - zod: - specifier: 'catalog:' - version: 4.4.3 - devDependencies: - '@btravstack/tsconfig': - specifier: 'catalog:' - version: 0.2.0 - '@temporal-contract/testing': - specifier: 'catalog:' - version: 8.0.0-beta.5(@temporal-contract/client@8.0.0-beta.5(@temporalio/client@1.22.0)(@temporalio/common@1.22.0)(unthrown@5.5.0))(@temporal-contract/contract@8.0.0-beta.5(unthrown@5.5.0))(@temporal-contract/worker@8.0.0-beta.5(@temporalio/common@1.22.0)(@temporalio/worker@1.22.0)(@temporalio/workflow@1.22.0)(unthrown@5.5.0))(@temporalio/client@1.22.0)(@temporalio/testing@1.22.0)(@temporalio/worker@1.22.0)(testcontainers@12.0.4)(unthrown@5.5.0)(vitest@4.1.10) - '@temporalio/testing': - specifier: 'catalog:' - version: 1.22.0 - '@types/node': - specifier: 'catalog:' - version: 26.1.2 - '@unthrown/vitest': - specifier: 'catalog:' - version: 5.5.0(unthrown@5.5.0)(vitest@4.1.10) - typescript: - specifier: 'catalog:' - version: 7.0.2 - vitest: - specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) - examples/order-temporal-contract: dependencies: '@temporal-contract/contract': @@ -601,7 +531,7 @@ importers: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) - examples/order-worker: + examples/order-temporal-worker: dependencies: '@btravstack/di': specifier: 'catalog:' @@ -621,6 +551,27 @@ importers: '@btravstack/start-example-order-infrastructure': specifier: workspace:* version: link:../order-infrastructure + '@btravstack/start-example-order-temporal-contract': + specifier: workspace:* + version: link:../order-temporal-contract + '@btravstack/start-temporal': + specifier: workspace:* + version: link:../../packages/start-temporal + '@temporal-contract/client': + specifier: 'catalog:' + version: 8.0.0-beta.5(@temporalio/client@1.22.0)(@temporalio/common@1.22.0)(unthrown@5.5.0) + '@temporal-contract/worker': + specifier: 'catalog:' + version: 8.0.0-beta.5(@temporalio/common@1.22.0)(@temporalio/worker@1.22.0)(@temporalio/workflow@1.22.0)(unthrown@5.5.0) + '@temporalio/client': + specifier: 'catalog:' + version: 1.22.0 + '@temporalio/worker': + specifier: 'catalog:' + version: 1.22.0 + '@temporalio/workflow': + specifier: 'catalog:' + version: 1.22.0 '@unthrown/standard-schema': specifier: 'catalog:' version: 5.5.0 @@ -634,6 +585,12 @@ importers: '@btravstack/tsconfig': specifier: 'catalog:' version: 0.2.0 + '@temporal-contract/testing': + specifier: 'catalog:' + version: 8.0.0-beta.5(@temporal-contract/client@8.0.0-beta.5(@temporalio/client@1.22.0)(@temporalio/common@1.22.0)(unthrown@5.5.0))(@temporal-contract/contract@8.0.0-beta.5(unthrown@5.5.0))(@temporal-contract/worker@8.0.0-beta.5(@temporalio/common@1.22.0)(@temporalio/worker@1.22.0)(@temporalio/workflow@1.22.0)(unthrown@5.5.0))(@temporalio/client@1.22.0)(@temporalio/testing@1.22.0)(@temporalio/worker@1.22.0)(testcontainers@12.0.4)(unthrown@5.5.0)(vitest@4.1.10) + '@temporalio/testing': + specifier: 'catalog:' + version: 1.22.0 '@types/node': specifier: 'catalog:' version: 26.1.2 @@ -820,6 +777,12 @@ importers: packages: + '@amqp-contract/client@3.0.0-beta.6': + resolution: {integrity: sha512-LlCuzVu3i9kSZeMtj1vDizXwehgT56d6Jw04SbR9VACbXsRl7pIE5bfZaTw111FNLXk5ci8wfeqpFN7hmvwVxQ==} + engines: {node: '>=22.19'} + peerDependencies: + unthrown: ^5.3.0 + '@amqp-contract/contract@3.0.0-beta.6': resolution: {integrity: sha512-9eDFKrdUo2MfM0tB4vJTmvK2wgEXmJYi2iCX0MsMG1yLESL4w4iXp9cHg+RypicHsHlhOg8wjcRHGkLEtj7KoA==} engines: {node: '>=22.19'} @@ -4761,6 +4724,16 @@ packages: snapshots: + '@amqp-contract/client@3.0.0-beta.6(@opentelemetry/api@1.9.1)(unthrown@5.5.0)': + dependencies: + '@amqp-contract/contract': 3.0.0-beta.6 + '@amqp-contract/core': 3.0.0-beta.6(@opentelemetry/api@1.9.1)(unthrown@5.5.0) + '@standard-schema/spec': 1.1.0 + '@unthrown/standard-schema': 5.5.0 + unthrown: 5.5.0 + transitivePeerDependencies: + - '@opentelemetry/api' + '@amqp-contract/contract@3.0.0-beta.6': dependencies: '@standard-schema/spec': 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ce05156..f36e7b0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ catalog: # `latest` dist-tag is the 2.4.0 line, which peers on `unthrown@^4` while # this repo pins 5.5.0. The exact beta is the contract until v3 goes stable; # raise it deliberately, not on a bot bump. + "@amqp-contract/client": 3.0.0-beta.6 "@amqp-contract/contract": 3.0.0-beta.6 "@amqp-contract/testing": 3.0.0-beta.6 "@amqp-contract/worker": 3.0.0-beta.6 From 8b1211786924b8d1dd445ddd07a05b6f484da1a1 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Thu, 13 Aug 2026 23:44:25 +0200 Subject: [PATCH 2/7] fix(examples): order-api fixture stubs gain the repository's remove Co-Authored-By: Claude Fable 5 --- examples/order-api/src/test-fixtures.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/order-api/src/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index c63d733..81dfbdb 100644 --- a/examples/order-api/src/test-fixtures.ts +++ b/examples/order-api/src/test-fixtures.ts @@ -90,6 +90,7 @@ const unmodelledApi = () => apiWith({ save: (order) => OkAsync(order), find: () => fromSafePromise(Promise.reject(new Error("the database is on fire"))), + remove: () => OkAsync(), }); /** @@ -115,6 +116,7 @@ const gatedApi = () => { entered(); return fromSafePromise(held.then(() => anOrder(id, 1))); }, + remove: () => OkAsync(), }), arrived, release: () => release(), From 19308249805783248afb3e3be3b418fdf5d2f471 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Thu, 13 Aug 2026 23:51:44 +0200 Subject: [PATCH 3/7] fix(examples): review follow-ups on the reshape The relay's idle sleep clears its timer on early wake and unrefs it (a stray timeout could pin the event loop past stop() for up to pollMs); the shipping-refusal spec pins the typed ShippingUnavailable at the client instead of any-error; two stale order-worker references dropped from order-config's README and CLAUDE.md. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 5 +++-- .../order-amqp-worker/src/outbox-relay.ts | 11 ++++++++-- examples/order-config/README.md | 4 ++-- .../src/temporal-runtime.spec.ts | 22 +++++++++++++++---- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e001859..2050f00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,9 @@ pnpm workspace + turbo monorepo. `packages/` holds four published packages, Temporal worker runtime) and `start-amqp` (the AMQP consumer runtime); `examples/` holds ten private ones — a clean-architecture application (`order-domain` → `order-application` → `order-infrastructure`) booted under -four different runtimes (`order-api`, `order-worker`, `order-temporal-worker`, -`order-amqp-worker`), with each transport's contract in a package of its own +three runtimes (`order-api`, `order-temporal-worker`, `order-amqp-worker`), +each doing what its transport is for — answering, orchestrating, +broadcasting — with each transport's contract in a package of its own (`order-api-contract`, `order-temporal-contract`, `order-amqp-contract`) because a client must be able to take a contract without the server. They are consumers, not fixtures: they are part of the gate, and `examples/README.md` diff --git a/examples/order-amqp-worker/src/outbox-relay.ts b/examples/order-amqp-worker/src/outbox-relay.ts index 9ac0b13..0765063 100644 --- a/examples/order-amqp-worker/src/outbox-relay.ts +++ b/examples/order-amqp-worker/src/outbox-relay.ts @@ -47,8 +47,15 @@ export const startOutboxRelay = ( let wake: (() => void) | undefined; const sleep = (): Promise => new Promise((resolve) => { - wake = resolve; - setTimeout(resolve, pollMs); + // The timer is cleared on an early wake and `unref`ed besides: a + // stray timeout would keep the event loop alive past `stop()` for up + // to `pollMs`, and an idle relay must not pin the process on its own. + const timer = setTimeout(resolve, pollMs); + timer.unref(); + wake = () => { + clearTimeout(timer); + resolve(); + }; }); const sweep = async (): Promise => { diff --git a/examples/order-config/README.md b/examples/order-config/README.md index 582109a..796a82a 100644 --- a/examples/order-config/README.md +++ b/examples/order-config/README.md @@ -10,8 +10,8 @@ src/env.spec.ts the seven cases, against the fragments themselves ## Why a package rather than a copy in each deployment -`order-api`, `order-worker` and `order-temporal-worker` each validate `process.env` -through a schema and return it as a `Result`. That much is the point, and each +`order-api`, `order-amqp-worker` and `order-temporal-worker` each validate +`process.env` through a schema and return it as a `Result`. That much is the point, and each keeps its own schema: its variables, its defaults, its bounds. What they were also each keeping was the _fragment_ — diff --git a/examples/order-temporal-worker/src/temporal-runtime.spec.ts b/examples/order-temporal-worker/src/temporal-runtime.spec.ts index 2389b6c..7fdb628 100644 --- a/examples/order-temporal-worker/src/temporal-runtime.spec.ts +++ b/examples/order-temporal-worker/src/temporal-runtime.spec.ts @@ -80,12 +80,26 @@ describe("the fulfillment saga", () => { const { client } = await serve(noShipping.module); // WHEN the workflow runs - await expect( - client.executeWorkflow("fulfillOrder", { + const outcome = await client + .executeWorkflow("fulfillOrder", { workflowId: "wf-ship-1", args: { orderId: "o-3", quantity: 1 }, - }), - ).toBeErr(); + }) + .match({ + ok: () => "WRONGLY FULFILLED", + // THEN the refusal reaches the client typed, after the compensation — + // any other failure here is a different bug, and this fold names it + errCases: (matcher) => + matcher + .with({ errorName: "ShippingUnavailable" }, (error) => `no-shipping:${error.data.id}`) + .with({ errorName: "InvalidQuantity" }, () => "WRONG ERROR") + .with({ errorName: "OrderAlreadyPlaced" }, () => "WRONG ERROR") + .with({ errorName: "OutOfStock" }, () => "WRONG ERROR") + .with(...tagPatterns(WORKFLOW_START_ERROR_TAGS), (error) => `start:${error._tag}`) + .with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => `result:${error._tag}`), + defect: () => "DEFECT", + }); + expect(outcome).toBe("no-shipping:o-3"); // THEN the reservation was released — the walk-back reached the earlier // step, not just the placement From eb1348ce83d03798c9d1fb9efc1d336452cbc796 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 00:01:57 +0200 Subject: [PATCH 4/7] refactor(examples): the amqp runtime imports its contract Every caller passed the same orderContract constant, so the parameter was ceremony; order-temporal-worker keeps its own because its specs pass a genuinely different value (withTaskQueue). Documents why the relay's client is created rather than injected, and why the relay is not a di provider. Co-Authored-By: Claude Fable 5 --- examples/order-amqp-worker/README.md | 16 +++++++++++++ .../order-amqp-worker/src/amqp-runtime.ts | 23 +++++++++++-------- examples/order-amqp-worker/src/main.ts | 2 -- .../src/needs-gate.test-d.ts | 2 -- .../order-amqp-worker/src/outbox-relay.ts | 22 +++++++++++++++--- .../order-amqp-worker/src/test-fixtures.ts | 2 -- 6 files changed, 49 insertions(+), 18 deletions(-) diff --git a/examples/order-amqp-worker/README.md b/examples/order-amqp-worker/README.md index 1c1fbb1..735376f 100644 --- a/examples/order-amqp-worker/README.md +++ b/examples/order-amqp-worker/README.md @@ -40,6 +40,22 @@ would. It is intentionally the least interesting part: a broadcast's publisher does not know it exists, and the spec proves that by binding a _foreign_ queue to the same exchange and receiving the same event. +## Where the relay lives, and what it takes from di + +The relay resolves `Outbox` and `Logger` from the application context — the +boundary that matters — but creates its own `TypedAmqpClient` rather than +receiving one as a port, and is not itself a di provider. Both are deliberate: +a transport connection is a **runtime** concern in this repo (configured from +the environment in `main.ts`, exactly as `start-amqp` creates its worker and +`order-temporal-worker` opens its `NativeConnection`), while a di provider +holds something the _application_ depends on — which is why `OrderDatabase` is +one and this publisher is not. Nothing in the graph resolves the relay, and a +provider exists to be resolved. + +It is not a second connection either: `@amqp-contract/core` pools by URL and +reference-counts leases, so the relay's client and the consumer's worker share +one TCP connection, and `close()` releases a lease rather than the socket. + ## Where the relay lives `orderAmqpRuntime` layers the relay onto the runtime `start-amqp` hands back: diff --git a/examples/order-amqp-worker/src/amqp-runtime.ts b/examples/order-amqp-worker/src/amqp-runtime.ts index 52e44bc..44ef821 100644 --- a/examples/order-amqp-worker/src/amqp-runtime.ts +++ b/examples/order-amqp-worker/src/amqp-runtime.ts @@ -6,7 +6,7 @@ import { type MessageUnitContext, } from "@btravstack/start-amqp"; import type { Runtime } from "@btravstack/start-core"; -import type { OrderContract } from "@btravstack/start-example-order-amqp-contract"; +import { orderContract, type OrderContract } from "@btravstack/start-example-order-amqp-contract"; import { Logger, Outbox } from "@btravstack/start-example-order-application"; import { OkAsync } from "unthrown"; @@ -38,24 +38,29 @@ type AmqpNeeds = typeof Outbox | typeof Logger; * new work", and the relay's work is outbound — pending rows it has not * published yet are *safer* published during the drain window than abandoned * to the next boot. It stops at `stop`, before the consumer's transport goes. + * + * The contract is **imported, not a parameter**: this deployment implements + * exactly one, and every caller would pass the same `orderContract` constant. + * (`order-temporal-worker`'s runtime does take one, because its specs pass a + * genuinely different value — `withTaskQueue(orderContract, …)` scopes each + * test to its own task queue. Here the specs get their isolation from a + * per-test vhost in the URL, so nothing varies and the parameter would be + * ceremony.) */ export const orderAmqpRuntime = ({ - contract, relay, ...transport }: { - /** The contract: the exchange the relay publishes to, the queue this worker consumes. */ - readonly contract: OrderContract; - /** The broker URLs `TypedAmqpWorker` connects to — it owns the connection. */ + /** The broker URLs the worker and the relay both connect to. */ readonly urls: readonly string[]; - /** The relay's own knobs; its client shares the broker but not the connection. */ + /** The relay's own knobs. */ readonly relay: Pick; }): Runtime => { const consumer = amqpRuntime({ ...transport, - contract, + contract: orderContract, needs: [Outbox, Logger], - handlers: () => ({ orderPlaced: notifyHandler(contract) }), + handlers: () => ({ orderPlaced: notifyHandler(orderContract) }), middleware: (host) => messageUnits(host), }); @@ -64,7 +69,7 @@ export const orderAmqpRuntime = ({ needs: consumer.needs, start: (host) => consumer.start(host).flatMap((serving) => - startOutboxRelay(host.ctx, contract, { urls: transport.urls, pollMs: relay.pollMs }).map( + startOutboxRelay(host.ctx, { urls: transport.urls, pollMs: relay.pollMs }).map( (running) => ({ ...serving, stop: () => running.stop().flatMap(() => serving.stop()), diff --git a/examples/order-amqp-worker/src/main.ts b/examples/order-amqp-worker/src/main.ts index 1f7fde2..ce3ed2e 100644 --- a/examples/order-amqp-worker/src/main.ts +++ b/examples/order-amqp-worker/src/main.ts @@ -1,5 +1,4 @@ import { runMain, start } from "@btravstack/start-core"; -import { orderContract } from "@btravstack/start-example-order-amqp-contract"; import { P } from "unthrown"; import { orderAmqpRuntime } from "./amqp-runtime.js"; @@ -21,7 +20,6 @@ const work = (env: Env): Promise => runMain( start(OrderAmqpModule, { runtime: orderAmqpRuntime({ - contract: orderContract, urls: [env.AMQP_URL], relay: { pollMs: env.OUTBOX_POLL_MS }, }), diff --git a/examples/order-amqp-worker/src/needs-gate.test-d.ts b/examples/order-amqp-worker/src/needs-gate.test-d.ts index 91657f5..ea807c4 100644 --- a/examples/order-amqp-worker/src/needs-gate.test-d.ts +++ b/examples/order-amqp-worker/src/needs-gate.test-d.ts @@ -11,7 +11,6 @@ */ import { Module } from "@btravstack/di"; import { start } from "@btravstack/start-core"; -import { orderContract } from "@btravstack/start-example-order-amqp-contract"; import { ApplicationModule, Logger, PlaceOrder } from "@btravstack/start-example-order-application"; import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; @@ -20,7 +19,6 @@ import { OrderAmqpModule } from "./module.js"; const options = { runtime: orderAmqpRuntime({ - contract: orderContract, urls: ["amqp://127.0.0.1:5672"], relay: { pollMs: 200 }, }), diff --git a/examples/order-amqp-worker/src/outbox-relay.ts b/examples/order-amqp-worker/src/outbox-relay.ts index 0765063..5e0447a 100644 --- a/examples/order-amqp-worker/src/outbox-relay.ts +++ b/examples/order-amqp-worker/src/outbox-relay.ts @@ -1,6 +1,6 @@ import { TypedAmqpClient } from "@amqp-contract/client"; import type { Context } from "@btravstack/di"; -import type { OrderContract } from "@btravstack/start-example-order-amqp-contract"; +import { orderContract } from "@btravstack/start-example-order-amqp-contract"; import { Logger, Outbox } from "@btravstack/start-example-order-application"; import { P, fromSafePromise, type AsyncResult } from "unthrown"; @@ -33,13 +33,29 @@ const BATCH = 32; * validation error → the row cannot ever serialize, a bug worth a log line, * left pending so it stays visible; a defect (broker down, mid-flight close) * → logged, left pending, retried next sweep. + * + * **Why the client is created here rather than injected as a port, and why + * this is not a di provider.** A transport connection is a *runtime* concern + * in this repo, configured from the environment in `main.ts`: `start-amqp` + * creates its own `TypedAmqpWorker` inside `Runtime.start` from the same + * `urls`, and `order-temporal-worker`'s `main.ts` opens its `NativeConnection` + * the same way. Only `OrderDatabase` is a resourceful provider, because the + * *application* depends on it — the repository cannot be built without one. + * Nothing in the application graph depends on this publisher, and a provider + * exists to be resolved by someone. + * + * It is not a second connection, either: `@amqp-contract/core`'s + * `ConnectionManagerSingleton` pools by URL and reference-counts leases, so + * this client and the consumer's worker share one TCP connection and + * `client.close()` releases a lease rather than closing the socket. What the + * relay *does* take from di is everything the application owns — `Outbox` and + * `Logger`, resolved from `ctx` — which is the boundary that matters. */ export const startOutboxRelay = ( ctx: Context>, - contract: OrderContract, { urls, pollMs }: RelayOptions, ): AsyncResult<{ readonly stop: () => AsyncResult }, never> => - TypedAmqpClient.create({ contract, urls: [...urls] }).map((client) => { + TypedAmqpClient.create({ contract: orderContract, urls: [...urls] }).map((client) => { const outbox = ctx.get(Outbox); const logger = ctx.get(Logger); diff --git a/examples/order-amqp-worker/src/test-fixtures.ts b/examples/order-amqp-worker/src/test-fixtures.ts index ad709ac..eb535c4 100644 --- a/examples/order-amqp-worker/src/test-fixtures.ts +++ b/examples/order-amqp-worker/src/test-fixtures.ts @@ -3,7 +3,6 @@ import type { AmqpTestFixtures } from "@amqp-contract/testing/extension"; import { Module, Port, Provider, type Scope, type ServiceOf } from "@btravstack/di"; import type { AmqpInfo } from "@btravstack/start-amqp"; import { start, type RunningApp } from "@btravstack/start-core"; -import { orderContract } from "@btravstack/start-example-order-amqp-contract"; import { Logger, Outbox, PlaceOrder } from "@btravstack/start-example-order-application"; import { expect, type TestAPI } from "vitest"; @@ -83,7 +82,6 @@ export const it: TestAPI = amqpIt.extend { const app = start(module, { runtime: orderAmqpRuntime({ - contract: orderContract, urls: [amqpConnectionUrl], // Tight on purpose: the specs wait on real broker round trips, and // a production-sized idle sleep would be most of every test's clock. From f14fdb00b440743a59ceab346dd3b92ed0a9e9ca Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 00:05:59 +0200 Subject: [PATCH 5/7] test(examples): guard the DDL against schema drift The hand-written DDL and schema.prisma are two sources of truth for one shape; a model added to the schema alone compiles and fails only at runtime. The new spec reads the schema and fails if any model has no table. Documents why a real migration step has nothing to occupy here. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- examples/README.md | 2 +- examples/order-infrastructure/src/database.ts | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2050f00..93ad768 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -371,7 +371,7 @@ the code. ## Toolchain & conventions - **`examples/` is part of the gate, not a folder of illustrations.** All - ten workspaces run under the same six commands as the kernel — 79 specs + ten workspaces run under the same six commands as the kernel — 80 specs plus four `needs-gate.test-d.ts` files and four `layering.test-d.ts` ones — so an example that stops compiling, stops linting or stops passing fails CI exactly as `packages/start-core` would. Three of the four needs-gate files pin diff --git a/examples/README.md b/examples/README.md index 0474f32..686afac 100644 --- a/examples/README.md +++ b/examples/README.md @@ -171,7 +171,7 @@ missing need. ## Why these are tests, not just illustrations -Each package reads as application code, and each is covered by real specs — 79 +Each package reads as application code, and each is covered by real specs — 80 of them, run by the repository's own `pnpm test`: ```sh diff --git a/examples/order-infrastructure/src/database.ts b/examples/order-infrastructure/src/database.ts index dde2d1b..30ed2c8 100644 --- a/examples/order-infrastructure/src/database.ts +++ b/examples/order-infrastructure/src/database.ts @@ -9,6 +9,22 @@ import { PrismaClient } from "./generated/prisma/client.ts"; * The example's database is SQLite held in memory, so it is born empty and its * tables are created by hand — no migration engine, no file on disk, nothing to * clean up between runs. + * + * **Why not a real Prisma migration run before start**, which is what a + * deployment with a durable database would do: there is nothing here to + * migrate. The database is created empty by `openDatabase` and ceases to exist + * when the process does, so it has no prior version to move *from*; the + * datasource in `schema.prisma` deliberately declares no `url` (the driver + * adapter supplies the connection at runtime), and `prisma migrate` needs one; + * and these example packages are never executed as processes — `main.ts` is + * typechecked, and every spec drives `start` directly — so there is no + * "before application start" for a migration step to occupy. + * + * The cost of the shortcut is real, though: this array and `schema.prisma` are + * two sources of truth for one shape, and a model added to the schema alone + * still compiles (the generated client's types come from the schema) while the + * table quietly does not exist. `schema-drift.spec.ts` is what closes that — + * it reads the schema and fails if any model has no table. */ const DDL = [ `CREATE TABLE "Order" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "orderId" TEXT NOT NULL, "quantity" INTEGER NOT NULL)`, From 5c9936b67e50a41d0ff20cba32b6f14d97a4db1c Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 00:17:44 +0200 Subject: [PATCH 6/7] feat(examples): the outbox carries a change stream, tombstones included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove now writes a tombstone — an event with a null payload — in the same transaction as the delete, so a subscriber that learned an order exists learns it is gone. The outbox row becomes the event envelope (kind, subjectId, occurredAt, payload) and the wire carries it under one routing key, because a reader compacting by id needs a subject's create and its tombstone in one ordered stream. A remove that finds nothing writes nothing: the delete fails inside the transaction and the tombstone rolls back with it, so a compensation that runs twice cannot tell the world twice. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- examples/README.md | 6 +- examples/order-amqp-contract/README.md | 13 ++-- .../order-amqp-contract/src/contract.spec.ts | 46 ++++++++++--- examples/order-amqp-contract/src/contract.ts | 33 ++++++++-- .../order-amqp-contract/src/test-fixtures.ts | 6 +- examples/order-amqp-worker/README.md | 14 ++-- .../src/amqp-runtime.spec.ts | 29 ++++++++- .../order-amqp-worker/src/amqp-runtime.ts | 16 +++-- examples/order-amqp-worker/src/module.ts | 15 +++-- .../order-amqp-worker/src/outbox-relay.ts | 7 +- .../order-amqp-worker/src/test-fixtures.ts | 18 +++-- examples/order-application/src/index.ts | 2 +- examples/order-application/src/ports.ts | 33 +++++++--- .../order-infrastructure/prisma/schema.prisma | 16 +++-- examples/order-infrastructure/src/database.ts | 2 +- .../src/prisma-order-repository.spec.ts | 25 +++++++ .../src/prisma-order-repository.ts | 65 ++++++++++++++----- .../src/prisma-outbox.spec.ts | 45 +++++++++++-- .../order-infrastructure/src/prisma-outbox.ts | 19 +++++- .../src/schema-drift.spec.ts | 39 +++++++++++ 21 files changed, 361 insertions(+), 90 deletions(-) create mode 100644 examples/order-infrastructure/src/schema-drift.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 93ad768..a3c1ea0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -371,7 +371,7 @@ the code. ## Toolchain & conventions - **`examples/` is part of the gate, not a folder of illustrations.** All - ten workspaces run under the same six commands as the kernel — 80 specs + ten workspaces run under the same six commands as the kernel — 86 specs plus four `needs-gate.test-d.ts` files and four `layering.test-d.ts` ones — so an example that stops compiling, stops linting or stops passing fails CI exactly as `packages/start-core` would. Three of the four needs-gate files pin diff --git a/examples/README.md b/examples/README.md index 686afac..4c00464 100644 --- a/examples/README.md +++ b/examples/README.md @@ -95,8 +95,8 @@ nothing could. What differs is what each transport is **for**: in order and compensates in reverse when one answers a permanent no — orchestration, which needs a durable owner. - **`order-amqp-worker`** tells everyone what happened: every committed write - leaves an `order.placed` event through a transactional outbox — broadcast, - which needs no addressee at all. + leaves an event through a transactional outbox — and a cancellation leaves + a tombstone — broadcast, which needs no addressee at all. The use cases return a `Result`, and what a `Result` means to a transport is the transport's business — **the same `Err` becomes different outcomes** where @@ -171,7 +171,7 @@ missing need. ## Why these are tests, not just illustrations -Each package reads as application code, and each is covered by real specs — 80 +Each package reads as application code, and each is covered by real specs — 86 of them, run by the repository's own `pnpm test`: ```sh diff --git a/examples/order-amqp-contract/README.md b/examples/order-amqp-contract/README.md index 356bb9e..2ff4dc5 100644 --- a/examples/order-amqp-contract/README.md +++ b/examples/order-amqp-contract/README.md @@ -1,8 +1,9 @@ # `@btravstack/start-core` example: the order AMQP contract -The AMQP contract — one exchange, one broadcast event, one subscriber queue -with a dead-letter exchange and a retry policy — in a package of its own, -depending on `@amqp-contract/contract` and `zod`. +The AMQP contract — one exchange, one change-stream event (`kind`, `id`, +`occurredAt`, `payload`, where a null payload is the **tombstone**), one +subscriber queue with a dead-letter exchange and a retry policy — in a package +of its own, depending on `@amqp-contract/contract` and `zod`. ``` src/contract.ts the contract: exchange, queue, retry/dead-letter policy, message, publisher, consumer @@ -13,13 +14,13 @@ src/test-fixtures.ts the contract itself, and its message schema as a validato ## Why it is not part of `order-amqp-worker` A contract is a **shared artifact**. Two parties read this file: the worker -whose relay publishes `order.placed` and whose consumer reads +whose relay publishes `order.changed` and whose consumer reads `order-notifications`, and any _other_ service that wants to subscribe to the broadcast — neither wants a di container, a Prisma-backed repository or the kernel. ``` - order-amqp-worker any subscriber to order.placed + order-amqp-worker any subscriber to order.changed └──────────┬──────────┘ ▼ order-amqp-contract ← @amqp-contract/contract and zod, nothing else @@ -34,7 +35,7 @@ because the directive stops being used. `defineEventConsumer` derives the queue's binding from the publisher it consumes, so `defineContract` needs a publisher entry — and here the repo -genuinely ships both sides: `orderPlacedEvent` is what the outbox relay +genuinely ships both sides: `orderChangedEvent` is what the outbox relay publishes, and the `order-notifications` consumer is one subscriber among however many bind their own queues to the same exchange. diff --git a/examples/order-amqp-contract/src/contract.spec.ts b/examples/order-amqp-contract/src/contract.spec.ts index 35c905d..8bd6be3 100644 --- a/examples/order-amqp-contract/src/contract.spec.ts +++ b/examples/order-amqp-contract/src/contract.spec.ts @@ -24,17 +24,42 @@ describe("orderContract", () => { it("names the event as a fact, not a command", ({ contract }) => { // GIVEN the publisher any relay would take // WHEN its routing key is read - // THEN it announces something that happened — past tense, no addressee - expect(contract.publishers.orderPlaced.routingKey).toBe("order.placed"); + // THEN it announces something that happened — past tense, no addressee — + // and it is ONE key for every change, because a reader compacting by `id` + // needs a subject's create and its tombstone in one ordered stream + expect(contract.publishers.orderChanged.routingKey).toBe("order.changed"); }); - it("validates a broadcast payload from the contract alone", ({ validate }) => { + it("validates a broadcast event from the contract alone", ({ validate }) => { // GIVEN the contract's own schema, and nothing else — no worker, no // connection, no broker + const event = { + kind: "order", + id: "o-1", + occurredAt: "2026-08-13T22:00:00.000Z", + payload: { quantity: 2 }, + }; - // WHEN a relay checks the payload it is about to publish + // WHEN a relay checks the envelope it is about to publish // THEN it is accepted, in the shape the wire will carry - expect(validate({ orderId: "o-1", quantity: 2 })).toBeOkWith({ orderId: "o-1", quantity: 2 }); + expect(validate(event)).toBeOkWith(event); + }); + + it("accepts a tombstone — the deletion is a null payload, not a second event type", ({ + validate, + }) => { + // GIVEN the same schema + const tombstone = { + kind: "order", + id: "o-1", + occurredAt: "2026-08-13T22:00:00.000Z", + payload: null, + }; + + // WHEN the relay checks the last word about a subject + // THEN the contract carries it: this is what lets one stream and one + // handler express create, replace and delete without a second message + expect(validate(tombstone)).toBeOkWith(tombstone); }); it("rejects a payload the wire should never carry", ({ validate }) => { @@ -43,8 +68,13 @@ describe("orderContract", () => { // WHEN the quantity arrives as a string, the way an untyped publisher sends it // THEN it is rejected as a value, naming the field — the contract is // executable, not documentation, and a caller can run it - expect(validate({ orderId: "o-1", quantity: "two" })).toBeErrWith([ - expect.objectContaining({ path: ["quantity"] }), - ]); + expect( + validate({ + kind: "order", + id: "o-1", + occurredAt: "2026-08-13T22:00:00.000Z", + payload: { quantity: "two" }, + }), + ).toBeErrWith([expect.objectContaining({ path: ["payload", "quantity"] })]); }); }); diff --git a/examples/order-amqp-contract/src/contract.ts b/examples/order-amqp-contract/src/contract.ts index 800ddaf..3b12f24 100644 --- a/examples/order-amqp-contract/src/contract.ts +++ b/examples/order-amqp-contract/src/contract.ts @@ -17,11 +17,34 @@ const parked = defineExchange("orders-dlx", { type: "direct" }); * asked to do anything, and the publisher does not know who is listening. * That is what separates this deployment from the Temporal one: AMQP carries * announcements, orchestration carries intent. + * + * The envelope is the whole vocabulary a reader needs to rebuild state: + * `kind` is what sort of thing changed, `id` is which one — the key a reader + * keys its own copy on — and `payload` is what it now is. **A null payload is + * the tombstone**, the last word about a subject, saying it is gone. So the + * first event for an id creates, later ones with a payload replace, and the + * null one deletes; a subscriber needs no other event types and no schema + * change when a fourth verb shows up. + * + * `occurredAt` is an ISO string rather than a `Date` because JSON has no date + * type and the wire is JSON — the shape has to be one that survives the trip. */ -const orderPlaced = defineMessage(z.object({ orderId: z.string(), quantity: z.number() })); +const orderChanged = defineMessage( + z.object({ + kind: z.literal("order"), + id: z.string(), + occurredAt: z.string(), + payload: z.object({ quantity: z.number() }).nullable(), + }), +); -const orderPlacedEvent = defineEventPublisher(orders, orderPlaced, { - routingKey: "order.placed", +/** + * One routing key for every change, not one per verb: a reader that compacts + * by `id` needs a subject's create and its tombstone in **one ordered + * stream**, and two routing keys are two queues and no order between them. + */ +const orderChangedEvent = defineEventPublisher(orders, orderChanged, { + routingKey: "order.changed", }); /** @@ -56,8 +79,8 @@ const notifications = defineQueue("order-notifications", { * notifier) as one checkable artifact. */ export const orderContract = defineContract({ - publishers: { orderPlaced: orderPlacedEvent }, - consumers: { orderPlaced: defineEventConsumer(orderPlacedEvent, notifications) }, + publishers: { orderChanged: orderChangedEvent }, + consumers: { orderChanged: defineEventConsumer(orderChangedEvent, notifications) }, }); export type OrderContract = typeof orderContract; diff --git a/examples/order-amqp-contract/src/test-fixtures.ts b/examples/order-amqp-contract/src/test-fixtures.ts index cd12225..90cd775 100644 --- a/examples/order-amqp-contract/src/test-fixtures.ts +++ b/examples/order-amqp-contract/src/test-fixtures.ts @@ -3,7 +3,7 @@ import { test, type TestAPI } from "vitest"; import { orderContract, type OrderContract } from "./contract.js"; -type PlacedPayload = typeof orderContract.consumers.orderPlaced.message.payload; +type ChangedPayload = typeof orderContract.consumers.orderChanged.message.payload; export type ContractFixtures = { /** The contract itself, as any worker or publisher would take it. */ @@ -13,7 +13,7 @@ export type ContractFixtures = { * `Result` — what a caller holding nothing but this package can check a * payload with before it ever reaches a worker. */ - readonly validate: ReturnType>; + readonly validate: ReturnType>; }; export const it: TestAPI = test.extend({ @@ -24,6 +24,6 @@ export const it: TestAPI = test.extend({ // oxlint-disable-next-line no-empty-pattern -- see above validate: async ({}, use) => { // `fromSchema` is CURRIED — it takes the schema and hands back the validator. - await use(fromSchema(orderContract.consumers.orderPlaced.message.payload)); + await use(fromSchema(orderContract.consumers.orderChanged.message.payload)); }, }); diff --git a/examples/order-amqp-worker/README.md b/examples/order-amqp-worker/README.md index 735376f..1eca199 100644 --- a/examples/order-amqp-worker/README.md +++ b/examples/order-amqp-worker/README.md @@ -1,9 +1,10 @@ # `@btravstack/start-core` example: the order broadcast worker **What AMQP is for: telling everyone what happened.** This deployment -broadcasts a fact — `order.placed` — to whoever cares to listen, and it gets -that fact onto the wire without ever letting "the order committed" and "the -event was sent" disagree: the **transactional outbox** pattern, end to end. +broadcasts a **change stream** — every write to an order, as a fact on the +wire — and it gets those facts out without ever letting "the order committed" +and "the event was sent" disagree: the **transactional outbox** pattern, end +to end. The consuming half is served by [`@btravstack/start-amqp`](../../packages/start-amqp) the way `order-api` is served by `@btravstack/start-http`; the contract lives in @@ -28,7 +29,7 @@ the fact of it is lost — the failure mode the naive `save(); publish();` sequence carries by construction. **The relay** is `src/outbox-relay.ts`: an infinite sweep — pull pending rows -in commit order, `publish("orderPlaced", …)` each to the `orders` exchange, +in commit order, `publish("orderChanged", …)` each to the `orders` exchange, mark what the broker confirmed. It is deliberately **at-least-once**: a crash between publish and mark re-publishes on the next sweep, a broker outage leaves rows pending and the sweep after the outage drains them. What is never @@ -87,8 +88,9 @@ fragment's bounds would not. The suite runs against a **real RabbitMQ** in a testcontainer (Docker required): a write placed through the application's own `PlaceOrder` crosses the outbox, the broker and the queue, and comes back as the consumer's -notification — commit order preserved, outbox drained, and the same event -delivered to a subscriber this contract never heard of. +notification — commit order preserved, outbox drained, a cancellation +arriving as a tombstone behind its placement, and the same event delivered to +a subscriber this contract never heard of. ```bash pnpm --filter @btravstack/start-example-order-amqp-worker test # broadcast e2e + env specs diff --git a/examples/order-amqp-worker/src/amqp-runtime.spec.ts b/examples/order-amqp-worker/src/amqp-runtime.spec.ts index abc6ed4..87b6c9e 100644 --- a/examples/order-amqp-worker/src/amqp-runtime.spec.ts +++ b/examples/order-amqp-worker/src/amqp-runtime.spec.ts @@ -63,6 +63,26 @@ describe("the broadcast deployment", () => { ]); }); + it("broadcasts the cancellation as a tombstone, after the placement", async ({ + serve, + tapped, + }) => { + // GIVEN a served app and a placed order + await serve(tapped.module); + const { placeOrder, repository } = tapped.services(); + await expect(placeOrder.execute("o-6", 2)).toBeOk(); + + // WHEN the order is cancelled — the write path the saga's compensation uses + await expect(repository.remove("o-6")).toBeOk(); + + // THEN the subscriber hears both words about the subject, in order: what + // it was, then that it is gone. Without the tombstone a reader keeping its + // own copy would hold a cancelled order forever. + await expect + .poll(() => notifications(tapped.services().logger.lines()), { timeout: 5_000 }) + .toEqual(["order o-6 placed — notifying (2 items)", "order o-6 is gone — notifying"]); + }); + it("is a broadcast: a subscriber this repo never heard of receives it too", async ({ serve, tapped, @@ -72,7 +92,7 @@ describe("the broadcast deployment", () => { // a foreign subscriber: its own queue, bound to the same exchange, // declared by nothing in this contract await serve(tapped.module); - const waitForMessages = await initConsumer("orders", "order.placed"); + const waitForMessages = await initConsumer("orders", "order.changed"); // WHEN an order is placed await expect(tapped.services().placeOrder.execute("o-5", 4)).toBeOk(); @@ -80,6 +100,11 @@ describe("the broadcast deployment", () => { // THEN the foreign queue receives the same fact the notifier does — the // publisher addressed an exchange, never a consumer const [message] = await waitForMessages({ count: 1, timeoutMs: 5_000 }); - expect(JSON.parse(String(message?.content))).toEqual({ orderId: "o-5", quantity: 4 }); + expect(JSON.parse(String(message?.content))).toEqual({ + kind: "order", + id: "o-5", + occurredAt: expect.any(String), + payload: { quantity: 4 }, + }); }); }); diff --git a/examples/order-amqp-worker/src/amqp-runtime.ts b/examples/order-amqp-worker/src/amqp-runtime.ts index 44ef821..fc6753a 100644 --- a/examples/order-amqp-worker/src/amqp-runtime.ts +++ b/examples/order-amqp-worker/src/amqp-runtime.ts @@ -60,7 +60,7 @@ export const orderAmqpRuntime = ({ ...transport, contract: orderContract, needs: [Outbox, Logger], - handlers: () => ({ orderPlaced: notifyHandler(orderContract) }), + handlers: () => ({ orderChanged: notifyHandler(orderContract) }), middleware: (host) => messageUnits(host), }); @@ -84,16 +84,24 @@ export const orderAmqpRuntime = ({ * would write, reacting to a fact somebody else committed. It has no domain * errors to triage: notifying is a `Logger.info` here, and a real notifier's * failures would be retryable infrastructure, not answers about the order. + * + * The `payload === null` branch is the whole point of the envelope: one + * handler, one stream, and a reader that keeps its own copy of a subject + * upserts on a payload and drops on a tombstone. There is no second message + * type to declare, subscribe to, or keep ordered against this one. */ const notifyHandler = (contract: OrderContract) => - declareHandler>( + declareHandler>( contract, - "orderPlaced", + "orderChanged", (message, _raw, { context }) => { + const { id, payload } = message.payload; context.ctx .get(Logger) .info( - `order ${message.payload.orderId} placed — notifying (${message.payload.quantity} items)`, + payload === null + ? `order ${id} is gone — notifying` + : `order ${id} placed — notifying (${payload.quantity} items)`, ); return OkAsync(); }, diff --git a/examples/order-amqp-worker/src/module.ts b/examples/order-amqp-worker/src/module.ts index 0566b73..4898a9b 100644 --- a/examples/order-amqp-worker/src/module.ts +++ b/examples/order-amqp-worker/src/module.ts @@ -2,6 +2,7 @@ import { Module } from "@btravstack/di"; import { ApplicationModule, Logger, + OrderRepository, Outbox, PlaceOrder, } from "@btravstack/start-example-order-application"; @@ -14,13 +15,15 @@ import { PersistenceModule } from "@btravstack/start-example-order-infrastructur * and consumes the broadcast back. * * The exports are this deployment's own selection: `Outbox` and `Logger` are - * what the runtime needs, `PlaceOrder` is what a writer in the same process - * (the specs; in production, `order-api` against the same database) places - * orders through. Declared here rather than imported from a sibling because - * sharing a composition root would share its transport dependency — one - * application, one root per process. + * what the runtime needs, and `PlaceOrder` / `OrderRepository` are the writer's + * surface — what a writer in the same process (the specs; in production, + * `order-api` against the same database) places and cancels orders through. + * Both write paths leave the outbox an event, which is the property this + * deployment exists to demonstrate. Declared here rather than imported from a + * sibling because sharing a composition root would share its transport + * dependency — one application, one root per process. */ export const OrderAmqpModule = Module("OrderAmqp")({ imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, Outbox, Logger], + exports: [PlaceOrder, OrderRepository, Outbox, Logger], }); diff --git a/examples/order-amqp-worker/src/outbox-relay.ts b/examples/order-amqp-worker/src/outbox-relay.ts index 5e0447a..bae3d7d 100644 --- a/examples/order-amqp-worker/src/outbox-relay.ts +++ b/examples/order-amqp-worker/src/outbox-relay.ts @@ -80,7 +80,12 @@ export const startOutboxRelay = ( const published: number[] = []; for (const event of events) { await client - .publish("orderPlaced", { orderId: event.orderId, quantity: event.quantity }) + .publish("orderChanged", { + kind: event.kind, + id: event.subjectId, + occurredAt: event.occurredAt.toISOString(), + payload: event.payload, + }) .match({ ok: () => { published.push(event.id); diff --git a/examples/order-amqp-worker/src/test-fixtures.ts b/examples/order-amqp-worker/src/test-fixtures.ts index eb535c4..7bfd92a 100644 --- a/examples/order-amqp-worker/src/test-fixtures.ts +++ b/examples/order-amqp-worker/src/test-fixtures.ts @@ -3,7 +3,12 @@ import type { AmqpTestFixtures } from "@amqp-contract/testing/extension"; import { Module, Port, Provider, type Scope, type ServiceOf } from "@btravstack/di"; import type { AmqpInfo } from "@btravstack/start-amqp"; import { start, type RunningApp } from "@btravstack/start-core"; -import { Logger, Outbox, PlaceOrder } from "@btravstack/start-example-order-application"; +import { + Logger, + OrderRepository, + Outbox, + PlaceOrder, +} from "@btravstack/start-example-order-application"; import { expect, type TestAPI } from "vitest"; import { orderAmqpRuntime } from "./amqp-runtime.js"; @@ -17,7 +22,7 @@ type App = RunningApp; * call site, and no proof is available inside a helper generic in the module's * own exports. The runtime needs two of them; `PlaceOrder` is the writer's. */ -type AmqpPorts = PlaceOrder | Outbox | Logger; +type AmqpPorts = PlaceOrder | OrderRepository | Outbox | Logger; type ServeOptions = { readonly drainTimeoutMs: number }; @@ -33,6 +38,7 @@ type Serve = (module: Module, options?: ServeOptions) => */ class ServicesTap extends Port("ServicesTap")<{ readonly placeOrder: ServiceOf; + readonly repository: ServiceOf; readonly outbox: ServiceOf; readonly logger: ServiceOf; }> {} @@ -44,14 +50,14 @@ const tappedAmqp = () => { module: Module("TappedAmqp")({ imports: [OrderAmqpModule], provides: [ - Provider(ServicesTap)([PlaceOrder, Outbox, Logger], { - sync: (placeOrder, outbox, logger) => { - services = { placeOrder, outbox, logger }; + Provider(ServicesTap)([PlaceOrder, OrderRepository, Outbox, Logger], { + sync: (placeOrder, repository, outbox, logger) => { + services = { placeOrder, repository, outbox, logger }; return services; }, }), ], - exports: [PlaceOrder, Outbox, Logger], + exports: [PlaceOrder, OrderRepository, Outbox, Logger], }), services: (): ServiceOf => { // oxlint-disable-next-line unthrown/no-throw -- a fixture misused before `serve` is a broken test, and the loudest possible answer is the right one diff --git a/examples/order-application/src/index.ts b/examples/order-application/src/index.ts index 6fd4ad8..688652a 100644 --- a/examples/order-application/src/index.ts +++ b/examples/order-application/src/index.ts @@ -7,5 +7,5 @@ export { PlaceOrder, ShippingService, StockService, - type OrderPlacedEvent, + type OrderEvent, } from "./ports.js"; diff --git a/examples/order-application/src/ports.ts b/examples/order-application/src/ports.ts index cbdc386..0356587 100644 --- a/examples/order-application/src/ports.ts +++ b/examples/order-application/src/ports.ts @@ -14,11 +14,13 @@ import type { AsyncResult } from "unthrown"; * adapter, because the use cases own the shape they need — the direction that * keeps the dependency arrow pointing inwards. * - * `save` promises more than a row: every successful write also leaves an - * `order.placed` entry in the outbox, atomically — the write and the fact of - * the write commit or roll back together. `remove` is the compensation arm the - * fulfillment saga leans on; deleting what does not exist is `OrderNotFound`, - * a value, so a duplicate compensation is inert rather than a crash. + * Both write paths promise more than a row: `save` also leaves an event in + * the outbox and `remove` leaves a **tombstone**, each atomically — the write + * and the fact of the write commit or roll back together, so a subscriber can + * never miss either. `remove` is the compensation arm the fulfillment saga + * leans on; deleting what does not exist is `OrderNotFound`, a value, so a + * duplicate compensation is inert rather than a crash (and writes no second + * tombstone). */ export class OrderRepository extends Port("OrderRepository")<{ readonly save: (order: Order) => AsyncResult; @@ -26,11 +28,22 @@ export class OrderRepository extends Port("OrderRepository")<{ readonly remove: (id: string) => AsyncResult; }> {} -/** One row of the outbox: the fact that an order was placed, awaiting broadcast. */ -export type OrderPlacedEvent = { +/** + * One event awaiting broadcast — the envelope every subscriber reads. + * + * `kind` says what sort of thing changed, `subjectId` says which one, and + * `payload` says what it now is. A **null payload is the tombstone**: the last + * word about a subject, saying it is gone. That is the whole vocabulary a + * reader needs to rebuild state — the first event for a subject creates it, + * later ones with a payload replace it, and the null one deletes it — and it + * is why `id`, the outbox sequence, is the order the relay must publish in. + */ +export type OrderEvent = { readonly id: number; - readonly orderId: string; - readonly quantity: number; + readonly kind: "order"; + readonly subjectId: string; + readonly occurredAt: Date; + readonly payload: { readonly quantity: number } | null; }; /** @@ -42,7 +55,7 @@ export type OrderPlacedEvent = { * not answer is a defect, not a domain outcome. */ export class Outbox extends Port("Outbox")<{ - readonly pending: (limit: number) => AsyncResult; + readonly pending: (limit: number) => AsyncResult; readonly markPublished: (ids: readonly number[]) => AsyncResult; }> {} diff --git a/examples/order-infrastructure/prisma/schema.prisma b/examples/order-infrastructure/prisma/schema.prisma index 84d9711..52efe79 100644 --- a/examples/order-infrastructure/prisma/schema.prisma +++ b/examples/order-infrastructure/prisma/schema.prisma @@ -20,11 +20,19 @@ model Order { } // The transactional outbox: one row per fact worth broadcasting, written in -// the same transaction as the write it describes. `publishedAt` is the relay's -// bookkeeping — NULL means pending. +// the same transaction as the write it describes. +// +// The row IS the event envelope — `kind` (which sort of thing changed), +// `subjectId` (which one, and the key a reader compacts on), `occurredAt`, and +// `payload`. A NULL payload is the **tombstone**: the last word about a +// subject, saying it is gone. `id` is the sequence the relay publishes in, so +// a reader sees a subject's create before its tombstone. `publishedAt` is the +// relay's own bookkeeping — NULL means pending. model OutboxMessage { id Int @id @default(autoincrement()) - orderId String - quantity Int + kind String + subjectId String + payload String? + occurredAt DateTime @default(now()) publishedAt DateTime? } diff --git a/examples/order-infrastructure/src/database.ts b/examples/order-infrastructure/src/database.ts index 30ed2c8..8a19dfd 100644 --- a/examples/order-infrastructure/src/database.ts +++ b/examples/order-infrastructure/src/database.ts @@ -29,7 +29,7 @@ import { PrismaClient } from "./generated/prisma/client.ts"; const DDL = [ `CREATE TABLE "Order" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "orderId" TEXT NOT NULL, "quantity" INTEGER NOT NULL)`, `CREATE UNIQUE INDEX "Order_orderId_key" ON "Order"("orderId")`, - `CREATE TABLE "OutboxMessage" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "orderId" TEXT NOT NULL, "quantity" INTEGER NOT NULL, "publishedAt" DATETIME)`, + `CREATE TABLE "OutboxMessage" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "kind" TEXT NOT NULL, "subjectId" TEXT NOT NULL, "payload" TEXT, "occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "publishedAt" DATETIME)`, ]; const createClient = () => diff --git a/examples/order-infrastructure/src/prisma-order-repository.spec.ts b/examples/order-infrastructure/src/prisma-order-repository.spec.ts index ebf5ae5..bd20d5f 100644 --- a/examples/order-infrastructure/src/prisma-order-repository.spec.ts +++ b/examples/order-infrastructure/src/prisma-order-repository.spec.ts @@ -25,6 +25,31 @@ describe("the Prisma OrderRepository", () => { expect(roundTripped).toBeOkWith({ id: "o-1", quantity: 3 }); }); + it("deletes the one row the unique key names", async ({ repository, anOrder }) => { + // GIVEN a stored order + // WHEN it is removed and then looked for — chained, so a failed removal + // cannot be mistaken for a successful one + const afterRemoval = await repository + .save(anOrder("o-1", 3)) + .flatMap(() => repository.remove("o-1")) + .flatMap(() => repository.find("o-1")); + + // THEN it is gone: `orderId` carries the UNIQUE index, so this is a + // single-row `delete`, not a batch whose count has to be interpreted + expect(afterRemoval).toBeErrTagged("OrderNotFound", { id: "o-1" }); + }); + + it("answers OrderNotFound when there is nothing to remove", async ({ repository }) => { + // GIVEN a fresh database + // WHEN a placement that never landed is compensated — what a re-run of the + // saga's `cancelPlacement` does + const removal = await repository.remove("o-absent"); + + // THEN Prisma's P2025 arrives as the domain's own value, so the + // compensation can ignore it on purpose rather than crash on a throw + expect(removal).toBeErrTagged("OrderNotFound", { id: "o-absent" }); + }); + it("translates a real unique-constraint violation into DuplicateOrder", async ({ repository, anOrder, diff --git a/examples/order-infrastructure/src/prisma-order-repository.ts b/examples/order-infrastructure/src/prisma-order-repository.ts index c085d20..60c9e7e 100644 --- a/examples/order-infrastructure/src/prisma-order-repository.ts +++ b/examples/order-infrastructure/src/prisma-order-repository.ts @@ -1,7 +1,7 @@ import { Provider, type ServiceOf } from "@btravstack/di"; import { OrderRepository } from "@btravstack/start-example-order-application"; import { DuplicateOrder, Order, OrderNotFound } from "@btravstack/start-example-order-domain"; -import { Err, Ok, P, type Result } from "unthrown"; +import { Err, P, type Result } from "unthrown"; import { OrderDatabase, type OrderDatabaseClient } from "./database.js"; @@ -36,14 +36,21 @@ export const prismaOrderRepository = (db: OrderDatabaseClient): ServiceOf db .$tryTransaction((tx) => - tx.order - .tryCreate({ data: { orderId: order.id, quantity: order.quantity } }) - .flatMap(() => - tx.outboxMessage.tryCreate({ data: { orderId: order.id, quantity: order.quantity } }), - ), + tx.order.tryCreate({ data: { orderId: order.id, quantity: order.quantity } }).flatMap(() => + tx.outboxMessage.tryCreate({ + data: { + kind: "order", + subjectId: order.id, + payload: JSON.stringify({ quantity: order.quantity }), + }, + }), + ), ) .mapErrCases((matcher, defect) => matcher @@ -58,20 +65,42 @@ export const prismaOrderRepository = (db: OrderDatabaseClient): ServiceOf (row === null ? Err(new OrderNotFound({ id })) : hydrate(row))), - // Compensation's persistence arm. `deleteMany` rather than `delete` so a - // missing row is a countable outcome instead of a P2025: compensating a - // placement that never landed answers `OrderNotFound`, a value the saga can - // ignore on purpose. The outbox row, if the placement committed one, stays — - // the broadcast says what happened, and a cancellation is a *further* fact, - // not an eraser (`order.cancelled` is the reader's exercise). + // Compensation's persistence arm — `delete`, not `deleteMany`, because + // `orderId` carries the UNIQUE index and this deletes exactly one row. + // Counting a batch to discover the row was missing would be hand-rolling + // what the library already models: `tryDelete` puts P2025 in the error + // channel as `RecordNotFound`, which is precisely the domain's + // `OrderNotFound` under another vocabulary. Compensating a placement that + // never landed therefore answers a value the saga can ignore on purpose. + // + // It emits a **tombstone** — an event with no payload — in the same + // transaction as the delete, for the same reason `save` emits its event + // there: a subscriber that learned an order exists must learn it is gone, + // and "the row went but the news did not" is precisely the failure the + // outbox exists to make impossible. The earlier events for this subject are + // left alone; the log is a history, and the tombstone is its last word. + // + // Nothing is written when there was nothing to delete: `tryDelete` fails + // with `RecordNotFound` before the insert, and the transaction rolls back — + // so a re-run of the saga's `cancelPlacement` cannot append a second + // tombstone for an order already gone. remove: (id) => - db.order - .tryDeleteMany({ where: { orderId: id } }) - .mapErrCases((matcher, defect) => - // This schema has no relation to violate; reaching it is a bug. - matcher.with(P.tag("ForeignKeyViolation"), (violation) => defect(violation)), + db + .$tryTransaction((tx) => + tx.order.tryDelete({ where: { orderId: id } }).flatMap(() => + tx.outboxMessage.tryCreate({ + data: { kind: "order", subjectId: id, payload: null }, + }), + ), ) - .flatMap((batch) => (batch.count === 0 ? Err(new OrderNotFound({ id })) : Ok())), + .map(() => undefined) + .mapErrCases((matcher, defect) => + matcher + .with(P.tag("RecordNotFound"), () => new OrderNotFound({ id })) + // No relation to violate in this schema; reaching it is a bug. + .with(P.tag("ForeignKeyViolation"), (violation) => defect(violation)) + .with(P.tag("UniqueConstraintViolation"), (clash) => defect(clash)), + ), }); export const orderRepositoryProvider = Provider(OrderRepository)([OrderDatabase], { diff --git a/examples/order-infrastructure/src/prisma-outbox.spec.ts b/examples/order-infrastructure/src/prisma-outbox.spec.ts index 81d990e..acd4945 100644 --- a/examples/order-infrastructure/src/prisma-outbox.spec.ts +++ b/examples/order-infrastructure/src/prisma-outbox.spec.ts @@ -10,8 +10,11 @@ describe("the transactional outbox", () => { const events = await repository.save(anOrder("o-1", 3)).flatMap(() => outbox.pending(10)); // THEN the fact of the write is already in the outbox — no second call, - // no second chance to forget - expect(events).toBeOkWith([expect.objectContaining({ orderId: "o-1", quantity: 3 })]); + // no second chance to forget — carrying a payload, which is what makes it + // a create-or-replace for its subject + expect(events).toBeOkWith([ + expect.objectContaining({ kind: "order", subjectId: "o-1", payload: { quantity: 3 } }), + ]); }); it("leaves no event behind when the write rolls back", async ({ @@ -30,7 +33,9 @@ describe("the transactional outbox", () => { // THEN only the first placement's event exists — the duplicate's outbox // row rolled back with its order row - expect(events).toBeOkWith([expect.objectContaining({ orderId: "o-1", quantity: 1 })]); + expect(events).toBeOkWith([ + expect.objectContaining({ subjectId: "o-1", payload: { quantity: 1 } }), + ]); }); it("marks published events so the relay never re-reads them", async ({ @@ -53,6 +58,38 @@ describe("the transactional outbox", () => { const rest = await outbox.markPublished([first.id]).flatMap(() => outbox.pending(10)); // THEN only the second remains pending - expect(rest).toBeOkWith([expect.objectContaining({ orderId: "o-2" })]); + expect(rest).toBeOkWith([expect.objectContaining({ subjectId: "o-2" })]); + }); + + it("appends a tombstone when the order is removed", async ({ repository, outbox, anOrder }) => { + // GIVEN a placed order + // WHEN it is removed + const events = await repository + .save(anOrder("o-1", 3)) + .flatMap(() => repository.remove("o-1")) + .flatMap(() => outbox.pending(10)); + + // THEN the log carries both words about the subject, in order: what it + // was, then that it is gone. A null payload IS the deletion — a reader + // that keeps its own copy drops it here, and needs no second event type + expect(events).toBeOkWith([ + expect.objectContaining({ subjectId: "o-1", payload: { quantity: 3 } }), + expect.objectContaining({ subjectId: "o-1", payload: null }), + ]); + }); + + it("appends no tombstone when there was nothing to remove", async ({ repository, outbox }) => { + // GIVEN a fresh database + // WHEN a placement that never landed is compensated — a re-run of the + // saga's `cancelPlacement` + const events = await repository + .remove("o-absent") + .recoverErrCases((matcher) => matcher.with(P.tag("OrderNotFound"), () => undefined)) + .flatMap(() => outbox.pending(10)); + + // THEN nothing was announced: the delete failed inside the transaction, so + // the tombstone rolled back with it. A compensation that ran twice cannot + // tell the world twice. + expect(events).toBeOkWith([]); }); }); diff --git a/examples/order-infrastructure/src/prisma-outbox.ts b/examples/order-infrastructure/src/prisma-outbox.ts index 35ae965..262c93d 100644 --- a/examples/order-infrastructure/src/prisma-outbox.ts +++ b/examples/order-infrastructure/src/prisma-outbox.ts @@ -24,7 +24,24 @@ export const prismaOutbox = (db: OrderDatabaseClient): ServiceOf => ({ db.outboxMessage .tryFindMany({ where: { publishedAt: null }, orderBy: { id: "asc" }, take: limit }) .map((rows) => - rows.map((row) => ({ id: row.id, orderId: row.orderId, quantity: row.quantity })), + rows.map((row) => ({ + id: row.id, + // The column is a `string`; the port's `kind` is the union of the + // kinds this application emits, and `save`/`remove` are the only + // writers. A row carrying anything else was not written by this + // code, so the narrowing is a claim the adapter is entitled to make. + kind: row.kind as "order", + subjectId: row.subjectId, + occurredAt: row.occurredAt, + // A NULL payload is the tombstone, and it stays null all the way to + // the wire. `JSON.parse` on a row this code wrote cannot fail; if it + // somehow does, the throw becomes a Defect — which is the honest + // channel for "the database contains something impossible". + payload: + row.payload === null + ? null + : (JSON.parse(row.payload) as { readonly quantity: number }), + })), ) .mapErrCases((matcher, defect) => matcher.with(P.tag("DriverError"), (e) => defect(e))), diff --git a/examples/order-infrastructure/src/schema-drift.spec.ts b/examples/order-infrastructure/src/schema-drift.spec.ts new file mode 100644 index 0000000..9efed4f --- /dev/null +++ b/examples/order-infrastructure/src/schema-drift.spec.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect } from "vitest"; + +import { it } from "./test-fixtures.js"; + +/** + * Every `model` schema.prisma declares. SQLite names the table after the model + * unless `@@map` says otherwise, and nothing here uses `@@map`. + */ +const modelsInSchema = (): readonly string[] => + [ + ...readFileSync( + fileURLToPath(new URL("../prisma/schema.prisma", import.meta.url)), + "utf8", + ).matchAll(/^model\s+(\w+)\s*\{/gm), + ].map((match) => match[1] ?? ""); + +describe("the hand-written DDL", () => { + it("creates a table for every model the schema declares", async ({ db }) => { + // GIVEN the models `schema.prisma` declares — the source the generated + // client's types are built from + const models = modelsInSchema(); + expect(models.length).toBeGreaterThan(0); + + // WHEN the database `openDatabase` built is asked what it actually has + const tables = await db.$queryRawUnsafe( + "SELECT name FROM sqlite_master WHERE type = 'table'", + ); + + // THEN every model has its table. `openDatabase`'s `DDL` and the schema + // are two sources of truth for one shape — this is what stops them + // drifting, because a model added to the schema alone still *compiles* + // (the client's types come from the schema) and only fails when a query + // reaches the missing table at runtime. + expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([...models])); + }); +}); From 1172b09fb2383099ddd294668c84424933ca1669 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 00:22:01 +0200 Subject: [PATCH 7/7] feat(examples): migrate the schema, do not hand-write it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DDL array is gone. prisma/migrations/ is generated from the schema by prisma migrate dev and committed, a db:migrate script runs prisma migrate deploy, and turbo's dev task depends on ^db:migrate so the app cannot start against an unmigrated database — the deploy step owns migrations, never the process at boot. This example's own database is in-memory, so no external command can reach it: openDatabase applies the same committed SQL itself, which means the specs run the exact statements a deployment runs instead of a hand-kept copy that can drift. Prisma 7 removed url from the schema datasource, so the migration connection lives in prisma.config.ts — the CLI's alone, since the application passes a driver adapter rather than a URL. Co-Authored-By: Claude Fable 5 --- .gitignore | 4 ++ examples/order-infrastructure/README.md | 31 ++++++++- examples/order-infrastructure/package.json | 1 + .../order-infrastructure/prisma.config.ts | 16 ++++- .../20260813221942_init/migration.sql | 19 ++++++ .../prisma/migrations/migration_lock.toml | 3 + examples/order-infrastructure/src/database.ts | 63 +++++++++++-------- .../src/schema-drift.spec.ts | 13 ++-- turbo.json | 35 ++++++++--- 9 files changed, 144 insertions(+), 41 deletions(-) create mode 100644 examples/order-infrastructure/prisma/migrations/20260813221942_init/migration.sql create mode 100644 examples/order-infrastructure/prisma/migrations/migration_lock.toml diff --git a/.gitignore b/.gitignore index 184cd26..157a584 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ docs/superpowers/ # directory so it survives across runs and is a stable path CI can cache. # See examples/order-temporal-worker/README.md. .cache/ + +# The scratch database `prisma migrate` reaches for when DATABASE_URL is unset. +# The migrations it writes are committed; the database it applies them to is not. +**/.migrate.db diff --git a/examples/order-infrastructure/README.md b/examples/order-infrastructure/README.md index faf7ad7..b5736ce 100644 --- a/examples/order-infrastructure/README.md +++ b/examples/order-infrastructure/README.md @@ -4,13 +4,42 @@ The adapter side. This layer speaks Prisma, SQLite and P-codes, and its job is to make sure none of that vocabulary reaches the layers above it. ``` -prisma/schema.prisma one Order model, UNIQUE on the business id +prisma/schema.prisma the Order and OutboxMessage models +prisma/migrations/ generated from the schema, committed, applied by db:migrate src/database.ts the client, the OrderDatabase port, the acquire/release provider src/prisma-order-repository.ts the adapter — where Prisma's errors become the domain's src/module.ts PersistenceModule src/test-fixtures.ts the in-memory database and repository, as Vitest fixtures ``` +## The schema is migrated, not hand-created + +`prisma/migrations/` is generated by `prisma migrate dev` from +`prisma/schema.prisma` and committed — one source of truth for the shape. + +A deployment with a durable database runs them **before the process starts**: + +```bash +DATABASE_URL="file:./orders.db" pnpm --filter @btravstack/start-example-order-infrastructure db:migrate +# or, from the root, for every workspace that has migrations: +pnpm turbo run db:migrate +``` + +`turbo.json` makes `dev` depend on `^db:migrate`, so the app cannot start +against an unmigrated database. The application never migrates itself at boot — +that belongs to the deploy step, not the process. + +This example's own database is the exception that proves it: SQLite held _in +memory_, born empty inside `openDatabase` and gone with the process, so no +external command can reach it. `openDatabase` therefore applies the same +committed SQL itself — the exact statements a deployment runs, rather than a +hand-kept copy that can drift from the schema. `schema-drift.spec.ts` pins that +the migrations were regenerated after a schema change. + +The migration connection lives in `prisma.config.ts`, not the schema: Prisma 7 +removed `url` from `datasource`, and the application passes a driver adapter to +`PrismaClient` instead of a URL at all. + ## The translation is the point `@unthrown/prisma` gives `tryCreate` an error channel of exactly the outcomes a diff --git a/examples/order-infrastructure/package.json b/examples/order-infrastructure/package.json index eb47155..fa79e91 100644 --- a/examples/order-infrastructure/package.json +++ b/examples/order-infrastructure/package.json @@ -10,6 +10,7 @@ ".": "./src/index.ts" }, "scripts": { + "db:migrate": "prisma migrate deploy", "generate": "prisma generate", "test": "vitest run", "typecheck": "tsc --noEmit" diff --git a/examples/order-infrastructure/prisma.config.ts b/examples/order-infrastructure/prisma.config.ts index 03e9879..d3c668f 100644 --- a/examples/order-infrastructure/prisma.config.ts +++ b/examples/order-infrastructure/prisma.config.ts @@ -1,8 +1,20 @@ -// Prisma 7 config. The schema exists only so this layer has a concrete -// generated client to run its adapter against an in-memory SQLite database. +// Prisma 7 config. +// +// The `datasource` here is the **migration** connection, and it is the CLI's +// alone: `prisma migrate` needs a real database to diff against and apply to, +// while the application itself never uses this URL — it passes a driver +// adapter (`PrismaBetterSqlite3`) to `PrismaClient` instead. Prisma 7 removed +// `url` from the schema for exactly this reason: the schema describes the +// shape, the config says where the CLI should reach. +// +// `DATABASE_URL` is what a deployment sets before running `pnpm db:migrate`. +// The fallback is a gitignored scratch file so `db:migrate` works out of the +// box in a checkout, which is what generating the committed migrations needs. import { defineConfig } from "prisma/config"; export default defineConfig({ schema: "prisma/schema.prisma", + migrations: { path: "prisma/migrations" }, + datasource: { url: process.env["DATABASE_URL"] ?? "file:./.migrate.db" }, }); diff --git a/examples/order-infrastructure/prisma/migrations/20260813221942_init/migration.sql b/examples/order-infrastructure/prisma/migrations/20260813221942_init/migration.sql new file mode 100644 index 0000000..2533448 --- /dev/null +++ b/examples/order-infrastructure/prisma/migrations/20260813221942_init/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "Order" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "orderId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL +); + +-- CreateTable +CREATE TABLE "OutboxMessage" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "kind" TEXT NOT NULL, + "subjectId" TEXT NOT NULL, + "payload" TEXT, + "occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "publishedAt" DATETIME +); + +-- CreateIndex +CREATE UNIQUE INDEX "Order_orderId_key" ON "Order"("orderId"); diff --git a/examples/order-infrastructure/prisma/migrations/migration_lock.toml b/examples/order-infrastructure/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/examples/order-infrastructure/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/examples/order-infrastructure/src/database.ts b/examples/order-infrastructure/src/database.ts index 8a19dfd..37422ac 100644 --- a/examples/order-infrastructure/src/database.ts +++ b/examples/order-infrastructure/src/database.ts @@ -1,3 +1,6 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + import { Port, Provider } from "@btravstack/di"; import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"; import { unthrownPrisma } from "@unthrown/prisma"; @@ -6,31 +9,39 @@ import { fromSafePromise, type AsyncResult } from "unthrown"; import { PrismaClient } from "./generated/prisma/client.ts"; /** - * The example's database is SQLite held in memory, so it is born empty and its - * tables are created by hand — no migration engine, no file on disk, nothing to - * clean up between runs. - * - * **Why not a real Prisma migration run before start**, which is what a - * deployment with a durable database would do: there is nothing here to - * migrate. The database is created empty by `openDatabase` and ceases to exist - * when the process does, so it has no prior version to move *from*; the - * datasource in `schema.prisma` deliberately declares no `url` (the driver - * adapter supplies the connection at runtime), and `prisma migrate` needs one; - * and these example packages are never executed as processes — `main.ts` is - * typechecked, and every spec drives `start` directly — so there is no - * "before application start" for a migration step to occupy. + * The migrations, as `prisma migrate dev` generated them and as they are + * committed — the single source of truth for this schema's shape. * - * The cost of the shortcut is real, though: this array and `schema.prisma` are - * two sources of truth for one shape, and a model added to the schema alone - * still compiles (the generated client's types come from the schema) while the - * table quietly does not exist. `schema-drift.spec.ts` is what closes that — - * it reads the schema and fails if any model has no table. + * A deployment with a durable database runs `pnpm db:migrate` + * (`prisma migrate deploy`) **before the process starts**, which is what the + * `db:migrate` turbo task exists for; the application never migrates itself at + * boot. This example's database cannot be reached that way — it is SQLite held + * *in memory*, born empty inside `openDatabase` and gone when the process is, + * so no external command can prepare it — so the same committed SQL is applied + * here instead. That is the point: tests run the exact statements a deployment + * runs, rather than a hand-kept copy that can drift from the schema. + */ +const MIGRATIONS_DIR = fileURLToPath(new URL("../prisma/migrations/", import.meta.url)); + +const migrations = (): readonly string[] => + readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + // Prisma names migration directories with a leading timestamp, so + // lexicographic order IS chronological order — the order they must be + // applied in, and the order `migrate deploy` applies them in. + .sort((a, b) => a.name.localeCompare(b.name)) + .map((entry) => readFileSync(`${MIGRATIONS_DIR}${entry.name}/migration.sql`, "utf8")); + +/** + * One migration file holds several statements; better-sqlite3's `exec` (which + * `$executeRawUnsafe` reaches) takes one at a time, so the file is split on + * `;` and the empty tail dropped. Comments survive fine — SQLite parses them. */ -const DDL = [ - `CREATE TABLE "Order" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "orderId" TEXT NOT NULL, "quantity" INTEGER NOT NULL)`, - `CREATE UNIQUE INDEX "Order_orderId_key" ON "Order"("orderId")`, - `CREATE TABLE "OutboxMessage" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "kind" TEXT NOT NULL, "subjectId" TEXT NOT NULL, "payload" TEXT, "occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "publishedAt" DATETIME)`, -]; +const statementsOf = (migration: string): readonly string[] => + migration + .split(";") + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); const createClient = () => new PrismaClient({ adapter: new PrismaBetterSqlite3({ url: ":memory:" }) }).$extends( @@ -48,7 +59,7 @@ export type OrderDatabaseClient = ReturnType; export class OrderDatabase extends Port("OrderDatabase") {} /** - * Opens a fresh in-memory database with the schema applied. + * Opens a fresh in-memory database with every committed migration applied. * * `AsyncResult`, not a bare `Promise`: this is an exported async surface, and * the rule the rest of the stack follows is that every one of them returns a @@ -61,7 +72,9 @@ export const openDatabase = (): AsyncResult => fromSafePromise( (async () => { const db = createClient(); - for (const statement of DDL) await db.$executeRawUnsafe(statement); + for (const migration of migrations()) { + for (const statement of statementsOf(migration)) await db.$executeRawUnsafe(statement); + } return db; })(), ); diff --git a/examples/order-infrastructure/src/schema-drift.spec.ts b/examples/order-infrastructure/src/schema-drift.spec.ts index 9efed4f..a237c87 100644 --- a/examples/order-infrastructure/src/schema-drift.spec.ts +++ b/examples/order-infrastructure/src/schema-drift.spec.ts @@ -17,7 +17,7 @@ const modelsInSchema = (): readonly string[] => ).matchAll(/^model\s+(\w+)\s*\{/gm), ].map((match) => match[1] ?? ""); -describe("the hand-written DDL", () => { +describe("the committed migrations", () => { it("creates a table for every model the schema declares", async ({ db }) => { // GIVEN the models `schema.prisma` declares — the source the generated // client's types are built from @@ -29,11 +29,12 @@ describe("the hand-written DDL", () => { "SELECT name FROM sqlite_master WHERE type = 'table'", ); - // THEN every model has its table. `openDatabase`'s `DDL` and the schema - // are two sources of truth for one shape — this is what stops them - // drifting, because a model added to the schema alone still *compiles* - // (the client's types come from the schema) and only fails when a query - // reaches the missing table at runtime. + // THEN every model has its table. The migrations are generated from this + // schema, so the two agree by construction — what this pins is that they + // were *regenerated*: editing the schema without running + // `prisma migrate dev` leaves the client's types (which come from the + // schema) describing a column no migration ever created, and nothing else + // in the gate would notice until a query reached it. expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([...models])); }); }); diff --git a/turbo.json b/turbo.json index 280f69f..96574e0 100644 --- a/turbo.json +++ b/turbo.json @@ -1,13 +1,34 @@ { "$schema": "https://turbo.build/schema.json", "tasks": { - "lint": { "dependsOn": ["^build"] }, + "lint": { + "dependsOn": ["^build"] + }, "format": {}, - "generate": { "outputs": ["src/generated/**"] }, - "typecheck": { "dependsOn": ["build", "generate", "^generate"] }, - "test:types": { "dependsOn": ["^build", "generate", "^generate"] }, - "test": { "dependsOn": ["^build", "generate", "^generate"], "cache": false }, - "dev": { "dependsOn": ["^build"], "cache": false, "persistent": true }, - "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] } + "generate": { + "outputs": ["src/generated/**"] + }, + "typecheck": { + "dependsOn": ["build", "generate", "^generate"] + }, + "test:types": { + "dependsOn": ["^build", "generate", "^generate"] + }, + "test": { + "dependsOn": ["^build", "generate", "^generate"], + "cache": false + }, + "dev": { + "dependsOn": ["^build", "^db:migrate"], + "cache": false, + "persistent": true + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "db:migrate": { + "cache": false + } } }