Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/scoped-config-slices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@btravstack/config": minor
---

configuration parsed from the environment, as one value that is both port and module

`Config(id)(shape, options?)` declares a single value that is both a
`@btravstack/di` port token and the module serving it from the environment,
so `imports: [amqpConfig]` and `ctx.get(amqpConfig)` name the same thing. The
signature mirrors `@btravstack/entity`'s `Entity(tag)(fields, options?)`:
curried on the identity, then the field map, then an optional `{ prefix }` —
omitted, the prefix is the screaming-snake of the identity. Each field is
validated by any Standard Schema library.

Being one value costs nothing in adaptability, because it is a token first:
its module statics are consulted only when it appears in a module's
`imports:`, so a test can hand it a literal `Provider(amqpConfig)({ value })`
without importing it, with no environment involved.

`Config.source` provides the environment as a port rather than an ambient
`process.env` read, so validation and injection can never disagree about what
the environment was. `Config.collect` walks a module tree for every reachable
config, and `Config.parse` validates them all against one source, aggregating
every wrong variable into one `ConfigInvalid` instead of stopping at the
first — an operator who mistyped three variables learns all three from one
failed boot. `describeIssues` formats them, `ConfigType<T>` names a parsed
config's type, and `@btravstack/config/zod` ships `wholeNumber` and `port`
builders that guard the `Number("") === 0` trap.

Needs a `@btravstack/di` that exports `ConcretePortClass` and `PortInstance`
— the two names declaration emit requires for a port built from data, one for
declaring a config and one for exporting it from a composition root.
72 changes: 42 additions & 30 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -622,37 +622,49 @@ expect(r.error.code)… }` passes on the outer assertion alone the moment the

A sixth rule is about production code that tests keep honest:

6. **Configuration is validated through a schema and returned as a value, never
`.parse()`d.** `examples/order-api/src/env.ts` is the shape: a schema over
`process.env` run through `@unthrown/standard-schema`'s `fromSchema`, whose
issues are the modeled `E`, folded by the entry point into a message and a
non-zero exit code. A schema's own `.parse()` **throws**, which
`unthrown/no-throw` bans and which would contradict the example it appears in.
6. **Configuration is declared with `@btravstack/config`, never hand-rolled over
`process.env`.** `Config(id)(shape, options?)` is one value that is both a di
port token and the module serving it from the environment, so a deployment
writes `imports: [amqpConfig]` and reads `ctx.get(amqpConfig)` — no port here
and an adapter there. `examples/order-amqp-worker` is the reference: configs
in `src/config.ts` (and `outboxRelayConfig` next to the loop it tunes in
`src/outbox-relay.ts`), `Config.source(process.env)` as the single place the
environment enters the composition root, a runtime that names its configs in
`needs` and reads them off `host.ctx` rather than taking them as parameters,
and a `main.ts` whose whole tail is
`Config.parse(Config.collect(Root), process.env).match({ … })` — one report
for every config in the graph, folded with
`matcher.with(P.tag("config/ConfigInvalid"), …)` and `describeIssues`. A
spec supplies its own `Config.source({ … })` instead; a `Provider` stub for
the port works too.

A numeric variable is a **non-empty string piped into a coercion** —
`z.string().trim().min(1).pipe(z.coerce.number<string>().int().min(min).max(max)).default(f)`
— never a bare `z.coerce.number()`: coercion is `Number()` underneath, so
`PORT=abc` binds `NaN` and `PORT=` binds the ephemeral port `0` — the exact
silent failure the module exists to remove. The bounds catch the first (and
`3.5`, and out-of-range); they cannot catch the second, because a **port's
`min` is `0`** so that an ephemeral bind stays expressible, which is why the
string guard is not optional. An empty or whitespace-only value is a
configuration **error**, not an absent one — `.default(...)` applies only
when the variable is genuinely missing. The fragment is
`examples/order-config`'s `wholeNumber` / `port`, shared by all three
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-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 `<string>` type argument
is needed because `z.coerce.number()`'s input is `unknown`, which `.pipe`
will not accept from a `string`. The earlier digits-only regex plus
`.transform(Number)` was the over-built form of this; it was simplified in
the PR #7 review, and a bare `z.coerce.number()` was tried there and reverted
for the `min(0)` hole above.
Note `fromSchema` is **curried** — `fromSchema(schema)(input)`, not
`fromSchema(schema, input)`.
`@btravstack/config/zod`'s `wholeNumber(fallback, min, max)` and
`port(fallback)` — never a bare `z.coerce.number()`: coercion is `Number()`
underneath, so `PORT=abc` binds `NaN` and `PORT=` binds the ephemeral port
`0`, the exact silent failure the package exists to remove. The bounds catch
the first (and `3.5`, and out-of-range); they cannot catch the second,
because a **port's `min` is `0`** so that an ephemeral bind stays
expressible. An empty or whitespace-only value is a configuration **error**,
not an absent one — `.default(...)` applies only when the variable is
genuinely missing. `packages/config` pins all seven cases (absent, `""`,
whitespace, `abc`, `3.5`, valid, out of range) once; a deployment's specs
pin only what is genuinely its own. The `<string>` type argument is needed
because `z.coerce.number()`'s input is `unknown`, which `.pipe` will not
accept from a `string`.

The hand-rolled shape — a `z.object` of `SCREAMING_SNAKE` keys per
deployment, a `readEnv` over `@unthrown/standard-schema`'s `fromSchema`, and
an `examples/order-config` package holding the shared fragment — was the
earlier form and is gone. Its fold needed a `P._` catch-all behind an
`unthrown/no-catch-all-pattern` disable, because `SchemaIssues` is one type
with no discriminant; `ConfigInvalid` is a `TaggedError`, so the matcher
enumerates it and the disable is gone with it. Two things still lag: `start`
needs `probes: { port }` **before** the graph exists, so each `main.ts`
reads `PROBE_PORT` from `process.env` at that one line (declared and
validated with the rest all the same), and a composition root exporting a
config cannot emit declarations until `@btravstack/di` exports
`PortInstance`. Both are phase-2 items; see `packages/config/README.md`.

## Deferred, deliberately

Expand Down
100 changes: 80 additions & 20 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
# Examples

Eleven small packages that are **one application booted four ways**: a clean
Nine small packages that are **one application booted four ways**: a clean
architecture split across four layers, deployed once as an oRPC API, once as a
queue worker, once as a Temporal worker and once as an AMQP consumer, with each
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 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. |
| 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-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-temporal-worker order-amqp-worker ← one runtime each; one process each
└────────────────┼──────────────────┘ ─────▶ order-config ← how all three read the environment
└────────────────┼──────────────────┘ ─────▶ @btravstack/config ← how all three read the environment
order-infrastructure ← Prisma, SQLite, P-codes
│ provides OrderRepository
Expand All @@ -35,6 +34,14 @@ exercising `@btravstack/start-core` end to end from a consumer's own workspace,
order-domain ← entities and rules; depends on nothing
```

There used to be an `order-config` package on the right-hand arrow, holding the
one environment-variable idiom the three deployments shared. It is gone:
[`@btravstack/config`](../packages/config) owns that idiom now — `wholeNumber`
and `port` are its `@btravstack/config/zod` builders, `describeEnvIssues` is
its `describeIssues` — and each deployment declares its own configs with
`Config(id)(shape, options?)` instead of hand-rolling a schema over
`process.env`. See [Configuration](#configuration) below.

Every arrow points **inwards**, and the one that looks like it goes the wrong
way is the whole idea: `order-infrastructure` imports `order-application`,
because the port it implements — `OrderRepository`, spelled in the domain's
Expand Down Expand Up @@ -150,11 +157,14 @@ the other does not.

## The runtimes with a non-empty `needs`

`order-api`'s `httpRuntime` call declares `[PlaceOrder, FindOrder, Logger]`,
while `queueWorkerRuntime`, `temporalWorkerRuntime` and `orderAmqpRuntime` each
declare `[PlaceOrder, Logger]` — two of the three the module exports, because a
runtime declares what _it_ needs. The kernel's own `testRuntime` needs nothing,
so these four are what exercise `start`'s phantom rest-tuple gate and
`orderApiRuntime` declares `[PlaceOrder, FindOrder, Logger, httpConfig]`,
`temporalWorkerRuntime` its five application ports plus `temporalConfig`, and
`orderAmqpRuntime` `[Outbox, Logger, amqpConfig, outboxRelayConfig]` — a
selection of what the module exports, because a runtime declares what _it_
needs. **Configuration is inside that selection**, which is the point: a config
is a port like any other, so a deployment that forgot to import one fails to
compile rather than to boot. The kernel's own `testRuntime` needs nothing, so
these are what exercise `start`'s phantom rest-tuple gate and
`RuntimeHost`'s `Context<InstanceType<Needs>>` — where a runtime names port
_classes_ while di parameterises contexts by port _instances_ — against a real
module here. `@btravstack/start-http`'s own `AppModule`/`Greeting` fixture
Expand All @@ -169,9 +179,59 @@ 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.

## Configuration

Every deployment declares its configuration with
[`@btravstack/config`](../packages/config), and none of them has an `env.ts`.

| Variable | Deployment | Declared by | Default |
| -------------------- | ----------------------- | ------------------- | ----------------------- |
| `HTTP_PORT` | `order-api` | `httpConfig` | `3000` |
| `TEMPORAL_ADDRESS` | `order-temporal-worker` | `temporalConfig` | `127.0.0.1:7233` |
| `TEMPORAL_NAMESPACE` | `order-temporal-worker` | `temporalConfig` | `default` |
| `AMQP_URL` | `order-amqp-worker` | `amqpConfig` | `amqp://127.0.0.1:5672` |
| `OUTBOX_POLL_MS` | `order-amqp-worker` | `outboxRelayConfig` | `200` |
| `PROBE_PORT` | all three | `probeConfig` | `9000` |

Every name is what it was before the package existed, bar one: `order-api`'s
`HTTP_PORT` was `PORT`, and a bare `PORT` is not expressible — a config's
variables are `PREFIX_KEY`, and no prefix and key join to it.

Three things changed, and all three are worth copying:

**A config is one value, and it is a di port.**
`Config("Amqp")({ url: … })` is at once the token `ctx.get(amqpConfig)` reads
and the module `imports: [amqpConfig]` provides — no port declared here and an
adapter written for it there. `Config.source(process.env)` is the single place
the environment enters a graph, so a spec swaps the whole environment by
importing a different one (`order-amqp-worker`'s fixtures give each test its
own vhost that way).

**It travels through the graph, not through `main`.** A runtime is handed a
`Context` at `start`, so it names its configs in `needs` and reads them itself.
`main.ts` no longer knows what a broker URL, a namespace or a listening port
is, and the needs gate proves the graph carries them before anything runs.

**One report, not one deploy per typo.**
`Config.parse(Config.collect(Root), process.env)` validates every config
reachable from the composition root against one source and aggregates the lot
into a single `ConfigInvalid`; `describeIssues` prints one line per wrong
variable. Because `ConfigInvalid` is a `TaggedError`, the fold that reports it
enumerates it properly — the `P._` catch-all and its lint-disable, which the
old hand-rolled `SchemaIssues` fold needed, are gone from all three entry
points.

One thing is **not** clean yet, and each `main.ts` says so at the exact line:
`PROBE_PORT` (and `order-temporal-worker`'s `TEMPORAL_ADDRESS`) is still read
straight from `process.env`, because `start` binds the probe server — and the
Temporal connection is opened — before the graph exists, and phase 1 has no
way to read one config's value outside a graph. Both are still declared and
still validated in the report above. Kernel integration is phase 2's, in
`@btravstack/start-core`.

## Why these are tests, not just illustrations

Each package reads as application code, and each is covered by real specs — 86
Each package reads as application code, and each is covered by real specs — 68
of them, run by the repository's own `pnpm test`:

```sh
Expand Down
Loading
Loading