Skip to content

feat(config): @btravstack/config — one-value Config(id)(shape, options?), used by the examples - #35

Draft
btravers wants to merge 11 commits into
mainfrom
feat/config-one-value
Draft

feat(config): @btravstack/config — one-value Config(id)(shape, options?), used by the examples#35
btravers wants to merge 11 commits into
mainfrom
feat/config-one-value

Conversation

@btravers

@btravers btravers commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds @btravstack/config to the monorepo and uses it, closing #20's copy-pasted env-to-exit boot tail. Supersedes #34, which held the same move behind an earlier API.

One value, not two. Config(id)(shape, options?) returns a single value that is both a 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 ("AmqpConfig"AMQP_CONFIG_URL); given, it is used verbatim. Either way the resolved prefix is validated as upper-snake at declaration.

export const amqpConfig = Config("AmqpConfig")(
  { url: z.string().min(1).default("amqp://127.0.0.1:5672"), prefetch: wholeNumber(10, 1, 1_000) },
  { prefix: "AMQP" },
);

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:. A test can hand it a literal Provider(amqpConfig)({ value }) without importing it and with no environment involved — there is a test asserting exactly that.

The examples use it

examples/order-config is deleted. It was a hand-rolled prototype of this package — the same wholeNumber, port, and describeEnvIssues — now @btravstack/config/zod and describeIssues. All three deployables traded env.ts/env.spec.ts for a config.ts.

Config no longer travels through main. RuntimeHost<Needs> already hands a runtime a Context, so runtimes declare their configs in needs and read them directly: orderAmqpRuntime() and orderApiRuntime() take no arguments at all, temporalWorkerRuntime no longer takes a namespace, and start's UNSATISFIED RUNTIME NEEDS gate proves the graph supplies them. main.ts is only the one-report boot:

Config.parse(Config.collect(OrderAmqpModule), process.env).match({
  ok: () => runMain(start(OrderAmqpModule, { runtime: orderAmqpRuntime(), probes })),
  errCases: (matcher) => matcher.with(P.tag("ConfigInvalid"), (e) => abort(describeIssues(e.issues))),
  defect: (cause) => abort(`the configuration could not be validated: ${String(cause)}`),
});

Two things fall out. ConfigInvalid is a TaggedError, so the matcher enumerates it and every oxlint-disable unthrown/no-catch-all-pattern is deleted — the P._ only existed because SchemaIssues is a single type with no discriminant. And the report aggregates every config in the graph, so three typo'd variables across two packages come back from one failed boot.

Decisions worth your attention

  • PORTHTTP_PORT in order-api. Every other variable name is unchanged. A config variable is PREFIX_KEY with a mandatory underscore, so no prefix-and-key pair joins to a bare PORT. It only bites single-word names, but PORT is the one most PaaS hosts inject. Alternatives (empty prefix, per-key name override) are discussed in the comments.
  • probes.port stays a direct read. start needs it before the graph exists, and phase 1 has no API for reading one config outside a graph. It is still declared as a config so it is validated and reported with everything else; the constraint is commented at the line and belongs to phase 2.

Blocked on btravstack/di#12

Config's return type needs ConcretePortClass and PortInstance, neither published yet. They fix opposite shapes — a factory returning a data-built port needs the class name, a module exporting one needs the instance name — so neither closes the hole alone.

Built and typechecked here against a local, uncommitted pnpm-workspace.yaml override. That override is not in this diff and must not land. Merge order: di#12 merges and publishes → bump this repo's catalog off the pinned 0.1.0 → merge this.

Test plan

  • build / typecheck / lint — 20/20 tasks green
  • tests — 30/31; the one failure is start-core's "binds 9000 when no probe port is given", which fails on this host because something else holds 9000 (untouched code)
  • @btravstack/config at 100% line and function coverage, the repo's gate for packages
  • Declaration emit checked, not disabled — the examples briefly turned declaration off to dodge TS4023; reverted in d25ce6c and fixed properly in di
  • Lockfile regenerated so --frozen-lockfile matches the deleted package (82377eb)
  • Blocked on feat(di): export ConcretePortClass for ports built from data di#12 publishing

🤖 Generated with Claude Code

Benoit Travers and others added 4 commits August 14, 2026 15:21
single-repo release instead of a cross-repo one: config's only consumers
are start's kernel and starters, so the pending 0.1.0 changeset moves with
it. adopt start's coverage-gated test script, typecheck/test:types shape,
and package metadata; add @standard-schema/spec to the catalog.
start holds packages to 100% lines and functions; config arrived from its
own repo two branches short. Both are worth testing rather than exempting:
describeIssues is what an operator actually reads on a failed boot, and
the provider's defect path is what happens when the kernel's validation
guarantee is skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The readme snippet used ValueOf without importing it, so it would not
copy-paste. collect walked with shift(), which re-indexes the array on
every step; a cursor keeps the same breadth-first declaration order that
describeIssues promises, without the churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tions?) value

A starter no longer declares a port and a separate env adapter for it: Config
now returns one value that is both the di port token and the module that
serves it, mirroring @btravstack/entity's Entity(tag)(fields, options?). The
port stays adaptable — a test can provide it as a literal without ever
importing it as a module — because it is still just a port, carrying di's
module statics rather than being welded to a second value.

Requires ConcretePortClass from an unpublished btravstack/di branch; blocked
on that publishing.
Benoit Travers and others added 5 commits August 14, 2026 17:48
`export const amqpConfig = Config("Amqp")({ … })` has an inferred type, and a
package compiled with `declaration` must be able to write that type down. The
inline intersection expanded to di's `[ID]`/`[SERVICE]` brands and this
package's own `CONFIG_ADAPTER`, none of them exported, so every such consumer
failed with TS4023. `DeclaredConfig<Id, S>` gives the emitter a name to stop
at, the way di's `ConcretePortClass` does one layer down, and grants no new
power — the brand keys stay unnameable.

Found by using the package in `examples/`: nothing inside it exports a declared
config, so nothing inside it could have caught the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each deployment loses its `env.ts` — a `z.object` of SCREAMING_SNAKE keys, a
`readEnv` over `fromSchema`, and a fold that needed a `P._` catch-all behind a
lint-disable — and gains a `config.ts` of `Config(id)(shape, options?)` values.
Every environment variable keeps its name bar one: `order-api` reads
`HTTP_PORT` where it read `PORT`, because a config's variables are `PREFIX_KEY`
and no prefix and key join to a bare `PORT`.

Configuration now travels through the graph rather than through `main`: a
runtime names its configs in `needs` and reads them off `host.ctx`, so
`orderAmqpRuntime()`, `orderApiRuntime()` and `temporalWorkerRuntime` no longer
take a broker URL, a listening port or a namespace as arguments, and `start`'s
gate proves the graph carries them at compile time. `main.ts` is one
`Config.parse(Config.collect(Root), process.env)` fold, which reports every
wrong variable in the whole graph at once and enumerates `ConfigInvalid` by
tag instead of reaching for `P._`.

Two values are still read from `process.env` by hand, each with a comment at
the line: `PROBE_PORT`, because `start` binds the probe server before the graph
exists, and `TEMPORAL_ADDRESS`, because the connection must be open before the
runtime is handed it. Both are declared and validated with the rest. Kernel
integration is phase 2's.

The three packages also turn `declaration` off: they emit no declarations, and
a composition root exporting a config cannot emit one until `@btravstack/di`
exports `PortInstance`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its whole job was `wholeNumber`, `port` and `describeEnvIssues`, shared by the
three deployments so the fragment and its seven cases were pinned once.
`@btravstack/config` owns all three now — the first two as
`@btravstack/config/zod`, the third as `describeIssues` — and pins the same
cases in its own suite, so the package and its spec go rather than being
ported. Nothing imports `@btravstack/start-example-order-config` any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each deployment's README gains a Configuration section naming the variables,
what declares them and where they are read; `examples/README.md` gains one
covering all three, drops the `order-config` row and says where its idiom went.
`packages/config/README.md` gains a "Seeing it used" section pointing at the
three consumers it now has, and records the two things using it surfaced: the
declaration-emit gap `DeclaredConfig` half-closes, and `probes: { port }`
having no clean answer until the kernel owns a source.

CLAUDE.md's configuration rule is rewritten around the package: the hand-rolled
`env.ts` shape it described no longer exists anywhere in the repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rtInstance

The three deployables had turned off `declaration`/`declarationMap` because a
composition root exporting a config emitted `Module<InstanceType<typeof cfg>>`,
which reduced to di's unexported `PortInstance` and raised TS4023.

di exports it now (btravstack/di#12), so the override is unnecessary — and it
was the wrong shape of fix regardless: these packages are `noEmit`, so it
silenced the check without saving any output. di's own emit-guards.ts exists
because its examples once did exactly this, leaving the repo green while no
consumer could build.
@btravers

Copy link
Copy Markdown
Contributor Author

Two decisions for you, and one correction to what I said earlier.

1. PORTHTTP_PORT in order-api — needs your call

Every other variable name survived unchanged. This one could not: a config variable is PREFIX_KEY with a mandatory underscore, and there is no prefix-and-key pair whose join is a bare PORT. The prefix rule rejects an empty prefix by design, so this is a real expressiveness limit, not an oversight in the example.

It only bites single-word names — DATABASE_URL, NODE_ENV, AMQP_URL all decompose fine. But PORT is the one single-word name that matters, because it is what most PaaS hosts inject. Three ways out:

  1. Keep HTTP_PORT (what I did). Honest, and arguably clearer in a multi-process example where three deployables each have a port.
  2. Allow { prefix: "" } so a config can own unprefixed names. Cheap, but it makes PORT and URL collide across two configs with no way to tell.
  3. Per-key name override (port: { schema, name: "PORT" }). Most expressive, most surface.

I would keep 1 and revisit if a real deployment target forces it. Say the word and I will change it.

2. declaration: false — I fixed this properly instead

The examples briefly turned off declaration checking, because a composition root that exports a config emits Module<InstanceType<typeof cfg>>, which reduces to di's unexported PortInstance and raises TS4023.

That was the wrong fix and I have reverted it (d25ce6c). These packages are noEmit, so it silenced the check without saving any output — and di's own emit-guards.ts exists precisely because its examples once did this, leaving the repo green while no consumer could build. Repeating it downstream would have been the same bug wearing the same disguise.

The real fix is in btravstack/di#12: export PortInstance as well as ConcretePortClass. They fix opposite shapes, which is why neither closed the hole alone — a factory returning a data-built port needs the class name, a module exporting one needs the instance name. My earlier note in index.ts claiming "annotating the factory's return is enough" was measured on only the first shape.

Verified rather than assumed: ModuleExportingDataBuiltPort in emit-guards.ts reports TS4023 on ID and SERVICE with the export removed and rebuilt, and passes with it. The brands stay unexported, so the forgery directives still hold.

3. Correction: the order-api failure was mine, not pre-existing

I reported earlier that order-api's typecheck failed for reasons unrelated to this branch. That was wrong. order-api is green on feat/config-package with no override, and the failure came from a node_modules tree left inconsistent by an earlier unthrown override — the lockfile said 5.5.0 while the peer-resolved directories still pointed at 5.1.0, giving tsc two nominally distinct Result types.

After a clean reinstall: build/typecheck/lint 20/20, tests 30/31. The single failure is start-core's "binds 9000 when no probe port is given", which fails on this host because something else holds 9000 — untouched code, unrelated to this branch.

The note still described `Config(port, "PREFIX")({ ... })` with a separately
declared port — the shape replaced by the one-value `Config(id)(shape, options?)`.
@btravstack/config has never been published, so this is its first release note
and there is no earlier API to reconcile with.
@btravers
btravers changed the base branch from feat/config-package to main August 14, 2026 16:30
…removal

The lockfile still listed examples/order-config as an importer and lacked the
@btravstack/config dependency the three deployables now declare, so a
--frozen-lockfile install would have failed. Regenerated from the committed
workspace, with no local override in it.
@btravers btravers changed the title feat(config): collapse port and adapter into one Config(id)(shape, options?) value feat(config): @btravstack/config — one-value Config(id)(shape, options?), used by the examples Aug 14, 2026
// and phase 1 of `@btravstack/config` has no way to read one config's value
// outside a graph. Phase 2's kernel integration is what deletes this line:
// `start` will own the source and resolve its own configuration.
const probePort = Number(process.env["PROBE_PORT"] ?? PROBE_PORT_DEFAULT);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we have config

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant