Skip to content

feat(config): move @btravstack/config into the monorepo - #34

Closed
btravers wants to merge 3 commits into
mainfrom
feat/config-package
Closed

feat(config): move @btravstack/config into the monorepo#34
btravers wants to merge 3 commits into
mainfrom
feat/config-package

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Summary

  • Moves @btravstack/config from its own standalone repo (btravstack/config, 24 commits, never pushed, no remote) into packages/config/ here, plus its one pending changeset (0.1.0, minor).
  • Why: config's only consumers are start's kernel and starters, and a cross-repo release cycle for a package this tightly coupled is pure friction. A single-repo release replaces it.
  • The package keeps the name @btravstack/config deliberately — that name was freed for this module by renaming btravstack/tools out of the way. It does not take the start- prefix its siblings use; it is a stack-level module that happens to live in start's monorepo.
  • What it does: configuration parsed from the environment, wired through @btravstack/di as an ordinary port and adapter. Config(port, "PREFIX")(shape) implements a di port by parsing PREFIX-scoped environment variables against any Standard Schema validator; Config.collect/Config.parse walk a module tree and validate every reachable adapter against one source, aggregating every wrong variable into one ConfigInvalid. @btravstack/config/zod ships wholeNumber/port builders that guard the Number("") === 0 trap.

Fitted to start's conventions

  • test adopts vitest run --coverage (was plain vitest run); typecheck/test:types match start-core's shape.
  • build keeps both entry points (tsdown src/index.ts src/zod.ts ...) and the ./zod subpath export — zod stays an optional peer.
  • Added package metadata (description, keywords, license, repository, main/module/types, LICENSE file) to match sibling packages.
  • Added @standard-schema/spec to the root catalog (config type-imports it; start's catalog didn't have it yet).
  • Converted every dependency to catalog:. Dependency shape preserved: @btravstack/di and unthrown are peers + dev; zod is an optional peer + dev; @unthrown/standard-schema is a real runtime dependency.
  • Dropped the standalone repo's own root-level tooling (turbo.json, pnpm-workspace.yaml, .github/, lefthook.yml, commitlint.config.js, root README/tsconfig/.gitignore/.node-version) — start already provides all of it.
  • Added 3 targeted oxlint-disable-next-line unthrown/no-throw / unthrown/no-get-or-throw comments (with reasons) where start's oxlint config enables rules the standalone repo didn't have configured. Each is a pre-existing, well-documented deliberate design choice (a precondition throw at import time, a defect-conversion getOrThrow, a test double simulating a broken schema) — not new code.

Known gap (not papered over)

packages/config#test fails start's repo-wide 100%-lines/100%-functions coverage gate: errors.ts's describeIssues (0% funcs) and one getOrThrow() defect-conversion branch in slice.ts are untested. The standalone repo ran plain vitest run with no threshold, so this gap was never enforced there. Per the move plan, this is reported rather than the threshold being lowered — closing it means adding tests for two specific spots, which is a follow-up.

Test plan

  • pnpm install
  • pnpm turbo run build typecheck test --filter=@btravstack/config — build and typecheck green; test fails only on the coverage gap above.
  • pnpm turbo run build typecheck test (whole repo) — all green except the same @btravstack/config#test coverage gap, and a pre-existing start-core test failure (binds 9000 when no probe port is given) confirmed present on origin/main before this change too, unrelated to this move.
  • pnpm lint and pnpm format — clean.
  • Docker-backed suites (start-amqp, start-temporal, and their examples) ran against real RabbitMQ/Temporal containers and passed.
  • Resolution check from inside packages/config: both @btravstack/config and @btravstack/config/zod resolve their exports.

🤖 Generated with Claude Code

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.
Copilot AI lite review requested due to automatic review settings August 14, 2026 13:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR vendors @btravstack/config into the monorepo under packages/config/, aligning it with the workspace toolchain (tsdown, vitest coverage, type-tests) and wiring it into the root pnpm catalog.

Changes:

  • Adds the new @btravstack/config package (env-backed config adapters + Config.collect/Config.parse + optional ./zod subpath).
  • Integrates workspace dependencies via the root pnpm catalog/lockfile and adds package-local build/test/typecheck configuration.
  • Adds a changeset for the first monorepo release of @btravstack/config.

Reviewed changes

Copilot reviewed 23 out of 24 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pnpm-workspace.yaml Adds @standard-schema/spec to the workspace catalog for the new package.
pnpm-lock.yaml Locks new importer packages/config and its dependency graph.
packages/config/vitest.config.ts Configures vitest + v8 coverage thresholds for the package.
packages/config/tsconfig.test-d.json Adds a dedicated TS project for *.test-d.ts type tests.
packages/config/tsconfig.json Package TS config (NodeNext base, dist outDir, excludes type-tests from main build).
packages/config/src/zod.ts Implements wholeNumber/port schema helpers for env parsing.
packages/config/src/zod.spec.ts Tests wholeNumber/port parsing semantics (empty string trap, bounds, defaults).
packages/config/src/variable.ts Prefix validation + camelCase→SCREAMING_SNAKE env var name derivation.
packages/config/src/variable.spec.ts Tests prefix validation and variable naming behavior.
packages/config/src/source.ts Adds ConfigSource DI port and Config.source() module helper.
packages/config/src/slice.ts Implements Config(port, prefix)(shape) adapter module factory + branding.
packages/config/src/slice.test-d.ts Type-level gate tests for adapter/port compatibility and missing needs.
packages/config/src/slice.spec.ts Runtime tests for adapter injection, module dedupe behavior, naming, and prefix rejection.
packages/config/src/parse.ts Implements parseShape (per-slice) and parseAll (aggregate) validation behavior.
packages/config/src/parse.spec.ts Tests parsing behavior, issue labeling, async-schema defect handling.
packages/config/src/parse-all.spec.ts Tests Config.parse aggregation and async-schema defect handling.
packages/config/src/index.ts Public entry point wiring (Config namespace + exports).
packages/config/src/errors.ts Defines ConfigInvalid and describeIssues formatter.
packages/config/src/collect.ts Implements module-tree walk to collect branded config adapters.
packages/config/src/collect.spec.ts Tests adapter collection, dedupe, and basic ordering behavior.
packages/config/README.md Documents usage patterns, naming, validation flow, and ./zod subpath.
packages/config/package.json Adds package metadata, exports map, scripts, deps/peers.
packages/config/LICENSE Adds MIT license file for the vendored package.
.changeset/scoped-config-slices.md Declares the initial minor release notes for @btravstack/config.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

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

Comment thread packages/config/src/errors.ts
Comment thread packages/config/README.md Outdated
Comment thread packages/config/src/collect.ts Outdated
Comment on lines +17 to +23
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined || seen.has(current)) continue;
seen.add(current);
if (isAdapter(current)) adapters.push(current);
queue.push(...current.imports);
}

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.

Fixed in ed0272d. Worth noting why it was shift(): the walk had been LIFO, which returned adapters in reverse declaration order and contradicted what describeIssues promises an operator. A cursor keeps the breadth-first declaration order without re-indexing on every step, so it settles both points at once.

Comment thread packages/config/src/slice.ts
Benoit Travers and others added 2 commits August 14, 2026 15:30
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>
Comment thread packages/config/README.md
Comment on lines +18 to +27
import { Config, type ValueOf } from "@btravstack/config";
import { Port } from "@btravstack/di";
import { z } from "zod";

const shape = {
url: z.string().min(1).default("amqp://127.0.0.1:5672"),
prefetch: z.string().min(1).pipe(z.coerce.number<string>().int()).default(10),
};
export class AmqpConfig extends Port("AmqpConfig")<ValueOf<typeof shape>> {}
export const AmqpConfigFromEnv = Config(AmqpConfig, "AMQP")(shape);

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.

why cannot we do that ?

Suggested change
import { Config, type ValueOf } from "@btravstack/config";
import { Port } from "@btravstack/di";
import { z } from "zod";
const shape = {
url: z.string().min(1).default("amqp://127.0.0.1:5672"),
prefetch: z.string().min(1).pipe(z.coerce.number<string>().int()).default(10),
};
export class AmqpConfig extends Port("AmqpConfig")<ValueOf<typeof shape>> {}
export const AmqpConfigFromEnv = Config(AmqpConfig, "AMQP")(shape);
import { Config, } from "@btravstack/config";
import { Port } from "@btravstack/di";
import { z } from "zod";
export const amqpConfig = Config("AmqpConfig")({
url: z.string().min(1).default("amqp://127.0.0.1:5672"),
prefetch: z.string().min(1).pipe(z.coerce.number<string>().int()).default(10),
};);

amqpConfig is a btravstack/di module that can be imported in a module and it exports a config provider of kind ConfigType<typeof amqpConfig>

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 can — but not for the reason I gave you earlier, and I owe you a correction first.

What I got wrong. I argued the welded slice could not be adapted: that a test could not write Provider(AmqpConfig)({ value: … }) because it would collide with the slice's own provider, and that avoiding the collision meant losing the port. That was too strong. The value was a token; its module statics are only consulted if you put it in imports:. A test could always have done provides: [Provider(AmqpConfig)({ value: … })] and simply not imported it. Adaptability was never actually blocked. You made a call partly on that argument, so it matters that it was overstated.

What does block your snippet, concretely: injection needs a constructor. Context.get is <S extends R>(port: abstract new () => S) => ServiceOf<S> and Provider(port) takes P extends AnyPort. A module is a plain object — { name, imports, provides, exports } — so it satisfies neither. If amqpConfig is a module, then resolving it needs ctx.get(amqpConfig.port): the accessor shape you rejected earlier, just inverted (module primary, port hanging off it).

The way to avoid the accessor is to make the returned value both a module and a port class — which is exactly the shape we removed, and the reason is mechanical rather than architectural: a factory that applies Service itself returns a port class the declaration emitter cannot name, so every consumer got TS4023. That is what btravstack/di#12 (ConcretePortClass) existed to fix, and closing it is only justified while no factory returns a port class.

So your snippet is available at a known price: reinstate that di export, and re-open the PR I closed. No other cost — the emit fix was one exported type plus a regression guard, both already written and verified.

And ConfigType<typeof amqpConfig> already exists, as ValueOf<typeof shape> — the parsed type derived from the schema record, which is what stops the service type being written twice. In the current split it reads Port("AmqpConfig")<ValueOf<typeof shape>>.

What the split still buys, stated honestly now that adaptability is off the table: no library change (di stays as published), the port declared where a reader looks for it, and a compile-time check that the adapter's parsed output satisfies the port's service — which a single welded value has no way to state, because there is nothing to check it against.

What it costs: two names, and the Port(...) line you would rather not write.

Your call — I will take either, and the one-value version is a small, already-proven change.

Comment thread packages/config/README.md
@@ -0,0 +1,188 @@
# @btravstack/config

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.

illustrate it using it in the examples/

@btravers

Copy link
Copy Markdown
Contributor Author

illustrate it using it in the examples/

Done, on #35 rather than here — the API changed under this branch, so illustrating the split port/adapter shape would be example code written to be deleted.

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

The part worth looking at is that config no longer travels through main. RuntimeHost<Needs> already hands a runtime a Context, so a runtime declares its configs in needs and reads them itself — orderAmqpRuntime() and orderApiRuntime() now 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 just 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 of that. ConfigInvalid is a TaggedError, so the matcher enumerates it and every oxlint-disable unthrown/no-catch-all-pattern is deleted — the P._ was only ever there because SchemaIssues is a single type with no discriminant. And the report now aggregates every config in the graph, so three typo'd variables across two packages come back from one failed boot instead of three deploys.

Two things need your call, both on #35.

@btravers

Copy link
Copy Markdown
Contributor Author

Closing in favour of #35, which now targets main and contains every commit from this branch plus the API reshape and the examples.

Two PRs made sense while the API was still being decided — this one was green before you settled on the one-value shape, so I stacked rather than rewriting it. Once the shape was fixed, the split only created a way for the parent to drift: @btravstack/config has never been published, so merging this alone would have shipped a first release of an API #35 immediately replaces, and the changeset writing that release note still described Config(port, "PREFIX")({ ... }) with a separately declared port. Corrected on #35 (785ff8e).

Collapsing also surfaced a second staleness this branch had: the lockfile still listed examples/order-config as an importer and lacked the @btravstack/config dependency the deployables now declare, so a --frozen-lockfile install would have failed. Regenerated on #35 (82377eb).

Every review comment here was addressed before closing — the four Copilot findings in 14fb964/ed0272d, and the API question in the README thread is what became #35. Nothing here is lost; the branch is not deleted.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants