feat(config): move @btravstack/config into the monorepo - #34
Conversation
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.
There was a problem hiding this comment.
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/configpackage (env-backed config adapters +Config.collect/Config.parse+ optional./zodsubpath). - 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.
| 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); | ||
| } |
There was a problem hiding this comment.
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.
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>
| 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); |
There was a problem hiding this comment.
why cannot we do that ?
| 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>
There was a problem hiding this comment.
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.
| @@ -0,0 +1,188 @@ | |||
| # @btravstack/config | |||
There was a problem hiding this comment.
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.
The part worth looking at is that config no longer travels through 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. Two things need your call, both on #35. |
|
Closing in favour of #35, which now targets 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: Collapsing also surfaced a second staleness this branch had: the lockfile still listed Every review comment here was addressed before closing — the four Copilot findings in |
Summary
@btravstack/configfrom its own standalone repo (btravstack/config, 24 commits, never pushed, no remote) intopackages/config/here, plus its one pending changeset (0.1.0, minor).@btravstack/configdeliberately — that name was freed for this module by renamingbtravstack/toolsout of the way. It does not take thestart-prefix its siblings use; it is a stack-level module that happens to live in start's monorepo.@btravstack/dias an ordinary port and adapter.Config(port, "PREFIX")(shape)implements a di port by parsingPREFIX-scoped environment variables against any Standard Schema validator;Config.collect/Config.parsewalk a module tree and validate every reachable adapter against one source, aggregating every wrong variable into oneConfigInvalid.@btravstack/config/zodshipswholeNumber/portbuilders that guard theNumber("") === 0trap.Fitted to start's conventions
testadoptsvitest run --coverage(was plainvitest run);typecheck/test:typesmatch start-core's shape.buildkeeps both entry points (tsdown src/index.ts src/zod.ts ...) and the./zodsubpath export — zod stays an optional peer.@standard-schema/specto the root catalog (config type-imports it; start's catalog didn't have it yet).catalog:. Dependency shape preserved:@btravstack/diandunthrownare peers + dev;zodis an optional peer + dev;@unthrown/standard-schemais a real runtime dependency.oxlint-disable-next-line unthrown/no-throw/unthrown/no-get-or-throwcomments (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-conversiongetOrThrow, a test double simulating a broken schema) — not new code.Known gap (not papered over)
packages/config#testfails start's repo-wide 100%-lines/100%-functions coverage gate:errors.ts'sdescribeIssues(0% funcs) and onegetOrThrow()defect-conversion branch inslice.tsare untested. The standalone repo ran plainvitest runwith 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 installpnpm 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#testcoverage gap, and a pre-existingstart-coretest failure (binds 9000 when no probe port is given) confirmed present onorigin/mainbefore this change too, unrelated to this move.pnpm lintandpnpm format— clean.start-amqp,start-temporal, and their examples) ran against real RabbitMQ/Temporal containers and passed.packages/config: both@btravstack/configand@btravstack/config/zodresolve their exports.🤖 Generated with Claude Code