From 665d66618c129535ae8409cc036d0efcc3f53c35 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 15:21:41 +0200 Subject: [PATCH 1/3] feat(config): move @btravstack/config into the monorepo 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. --- .changeset/scoped-config-slices.md | 18 +++ packages/config/LICENSE | 21 +++ packages/config/README.md | 188 ++++++++++++++++++++++++++ packages/config/package.json | 91 +++++++++++++ packages/config/src/collect.spec.ts | 57 ++++++++ packages/config/src/collect.ts | 26 ++++ packages/config/src/errors.ts | 21 +++ packages/config/src/index.ts | 20 +++ packages/config/src/parse-all.spec.ts | 62 +++++++++ packages/config/src/parse.spec.ts | 85 ++++++++++++ packages/config/src/parse.ts | 89 ++++++++++++ packages/config/src/slice.spec.ts | 94 +++++++++++++ packages/config/src/slice.test-d.ts | 53 ++++++++ packages/config/src/slice.ts | 158 ++++++++++++++++++++++ packages/config/src/source.ts | 15 ++ packages/config/src/variable.spec.ts | 42 ++++++ packages/config/src/variable.ts | 27 ++++ packages/config/src/zod.spec.ts | 49 +++++++ packages/config/src/zod.ts | 26 ++++ packages/config/tsconfig.json | 11 ++ packages/config/tsconfig.test-d.json | 10 ++ packages/config/vitest.config.ts | 15 ++ pnpm-lock.yaml | 43 ++++++ pnpm-workspace.yaml | 1 + 24 files changed, 1222 insertions(+) create mode 100644 .changeset/scoped-config-slices.md create mode 100644 packages/config/LICENSE create mode 100644 packages/config/README.md create mode 100644 packages/config/package.json create mode 100644 packages/config/src/collect.spec.ts create mode 100644 packages/config/src/collect.ts create mode 100644 packages/config/src/errors.ts create mode 100644 packages/config/src/index.ts create mode 100644 packages/config/src/parse-all.spec.ts create mode 100644 packages/config/src/parse.spec.ts create mode 100644 packages/config/src/parse.ts create mode 100644 packages/config/src/slice.spec.ts create mode 100644 packages/config/src/slice.test-d.ts create mode 100644 packages/config/src/slice.ts create mode 100644 packages/config/src/source.ts create mode 100644 packages/config/src/variable.spec.ts create mode 100644 packages/config/src/variable.ts create mode 100644 packages/config/src/zod.spec.ts create mode 100644 packages/config/src/zod.ts create mode 100644 packages/config/tsconfig.json create mode 100644 packages/config/tsconfig.test-d.json create mode 100644 packages/config/vitest.config.ts diff --git a/.changeset/scoped-config-slices.md b/.changeset/scoped-config-slices.md new file mode 100644 index 0000000..42fc7bd --- /dev/null +++ b/.changeset/scoped-config-slices.md @@ -0,0 +1,18 @@ +--- +"@btravstack/config": minor +--- + +configuration parsed from the environment, as a port and adapter + +`Config(port, "PREFIX")({ key: validator })` implements an ordinary +`@btravstack/di` port — declared by the starter with `Port(id)` — +as a module that parses `PREFIX`-scoped environment variables, validated +with any Standard Schema library. The port stays adaptable: a test can hand +it a literal `Provider(port)({ value: ... })` instead, with no config module +involved. `Config.source` provides the environment as a port rather than an +ambient `process.env` read, so validation and injection can never disagree. +`Config.collect` walks a module tree for every reachable env adapter, and +`Config.parse` validates them all against one source, aggregating every +wrong variable into one `ConfigInvalid` instead of stopping at the first. +`@btravstack/config/zod` ships `wholeNumber` and `port` builders that guard +against the `Number("") === 0` trap. diff --git a/packages/config/LICENSE b/packages/config/LICENSE new file mode 100644 index 0000000..e389328 --- /dev/null +++ b/packages/config/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Benoit TRAVERS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/config/README.md b/packages/config/README.md new file mode 100644 index 0000000..65937dd --- /dev/null +++ b/packages/config/README.md @@ -0,0 +1,188 @@ +# @btravstack/config + +Configuration parsed from the environment, wired through +[`@btravstack/di`](https://github.com/btravstack/di) as an ordinary port and +adapter. + +A starter declares a **port** the normal way — `Port(id)` — and +`Config(port, prefix)(shape)` is one **adapter** for it: a di module that +implements the port by parsing `prefix`-scoped environment variables. The +port stays a port: a test can hand it a literal, and a future file or +secret-manager source can be a different adapter for the same port. Neither +is possible when the port and its environment parser are welded into one +value — this package deliberately keeps them apart. + +## Declaring a port and its env adapter + +```ts +import { Config } 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().int()).default(10), +}; +export class AmqpConfig extends Port("AmqpConfig")> {} +export const AmqpConfigFromEnv = Config(AmqpConfig, "AMQP")(shape); +``` + +Each value in `shape` is a [Standard Schema](https://standardschema.dev) +validator — a `zod` schema above, but any Standard-Schema-compliant library +works. `ValueOf` types the port from the shape, so the service +type and the schema are never written twice. A starter ships the port and +its env adapter together, alongside the properties it defines: the shape and +the defaults live with the code that gives them meaning, not in a central +file every consumer has to know about. + +The port is declared by the starter, not by `Config`, because that is what +makes it adaptable: `Config(port, prefix)(shape)` is just one provider for +`port`, on equal footing with any other. An application imports the env +adapter; a test imports nothing from this package at all: + +```ts +// the application +import { Module } from "@btravstack/di"; +import { OkAsync } from "unthrown"; + +const App = Module("App")({ + imports: [ + ApplicationModule, + PersistenceModule, + AmqpConfigFromEnv, + Config.source({ AMQP_URL: "amqp://broker" }), + ], + exports: [AmqpConfig], +}); + +const value = await Module.scoped(App, (ctx) => OkAsync(ctx.get(AmqpConfig))); +// Ok({ url: "amqp://broker", prefetch: 10 }) +``` + +```ts +// a test, or any other adapter — ordinary di, no config involvement +import { Module, Provider } from "@btravstack/di"; + +const Test = Module("Test")({ + provides: [ + Provider(AmqpConfig)({ value: { url: "amqp://fake", prefetch: 1 } }), + ], + exports: [AmqpConfig], +}); +``` + +`imports: [AmqpConfigFromEnv]` provides the port; `ctx.get(AmqpConfig)` reads +it back — from either adapter, unchanged. An adapter imported by two +different modules in the same graph is still parsed once — di dedupes the +module before its provider ever runs. + +`Config(port, prefix)(shape)` also enforces, at compile time, that `shape` +actually implements `port`: `shape`'s parsed output must be assignable to +`port`'s own service type. A shape missing a key the port declares, or with +the wrong type for one, fails to compile — the same way a wrong `Provider` +qualification would. + +## Naming: camelCase in, `PREFIX_SCREAMING_SNAKE` out + +Each key of the shape maps to an environment variable named by the adapter's +prefix and the key shouted: `url` under `Config(port, "AMQP")` reads +`AMQP_URL`; `prefetch` reads `AMQP_PREFETCH`. The rule is a straight case +conversion — the key's own camelCase word boundaries become underscores — so +a validator's keys can stay idiomatic TypeScript while the environment stays +idiomatic shell. A multi-word key splits at every boundary: `prefetchCount` +under `Config(port, "AMQP")` reads `AMQP_PREFETCH_COUNT`. An acronym run is +left alone, not split letter-by-letter: `urlBase` reads `URL_BASE`, but +`URLBase` — the acronym already shouted in the key itself — reads `URLBASE`. + +## `Config.source`: the environment as a port + +Env adapters don't read `process.env` directly. `Config.source(record)` is +the module that provides `ConfigSource` — the one place a plain +`Record` enters the graph: + +```ts +Config.source(process.env); +``` + +The environment is a port, not an ambient read, so that validating the +configuration and actually providing it can never disagree about what the +environment was. If an adapter read `process.env` itself while validation +read a snapshot taken earlier, the two could see different values — a +variable set between the two reads would pass validation and then fail to +inject, or the reverse. Threading one `ConfigSource` through both removes the +seam where that mismatch could happen. + +## Validating everything before boot: `Config.collect` and `Config.parse` + +`Config.collect(module)` walks a module tree and returns every env adapter +reachable from it, each once, regardless of how many times or how deep it is +imported: + +```ts +const adapters = Config.collect(App); +``` + +`Config.parse(adapters, source)` validates all of them against one source +and aggregates every issue — from every adapter — into a single +`ConfigInvalid`: + +```ts +const outcome = Config.parse(adapters, process.env); +``` + +`outcome` is `Ok()` when every adapter agrees with the environment, or an +`Err` carrying one `ConfigInvalid` — `{ issues: [{ variable, message }, ...] }` +— when any of them don't. This is the one-report guarantee: `Config.parse` +does not stop at the first wrong variable. An operator who mistyped three +environment variables learns about all three from one failed boot, not one +deploy per fix. `describeIssues` formats the issues as one line each, +`" VARIABLE: message"`, joined for a log or an exit message: + +```ts +import { describeIssues } from "@btravstack/config"; + +if (outcome.isErr()) { + console.error(describeIssues(outcome.error.issues)); +} +``` + +`Config.collect` walks the **root** module tree only. An adapter reachable +exclusively through a `Module.forkScope` request module — one built fresh per +request, layered over an already-built parent `Context` — is not part of that +tree and is therefore not covered by this pre-boot validation pass. That gap +is a deliberate phase-2 design decision (`@btravstack/start-core` owns when +and how forked request modules get validated), not something this package +solves; an adapter declared only inside a request module today is still +parsed by its own provider, just without the one-report-before-boot +guarantee. + +## `@btravstack/config/zod`: `wholeNumber` and `port` + +Two `zod` builders for the coercions configuration validation needs most: + +```ts +import { port, wholeNumber } from "@btravstack/config/zod"; + +const httpShape = { + port: port(3000), + maxConnections: wholeNumber(100, 1, 10_000), +}; +class HttpConfig extends Port("HttpConfig")> {} +const HttpConfigFromEnv = Config(HttpConfig, "HTTP")(httpShape); +``` + +`wholeNumber(fallback, min, max)` reads a string, requires it non-empty, +coerces it to an integer, and bounds it — falling back to `fallback` only when +the variable is genuinely absent. The non-empty check in front of the +coercion is load-bearing: `Number("")` is `0`, so a variable set to nothing +(`PORT=`) would otherwise silently become the ephemeral port `0` instead of +raising a configuration error. The bounds alone cannot catch that case, +because for `port` — `wholeNumber(fallback, 0, 65_535)` — `0` is a legitimate +value, not just the coercion of an empty string. + +## What's next + +Kernel integration — turning a `ConfigInvalid` into process exit code +`EX_CONFIG` and wiring zero-config entry points — arrives in +`@btravstack/start-core` in phase 2. This package only validates and provides; +it never reads `process.env` on its own and it never exits the process. diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..1913e37 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,91 @@ +{ + "name": "@btravstack/config", + "version": "0.0.0", + "description": "Environment variables validated into typed config, wired through @btravstack/di as a port and its adapter", + "keywords": [ + "configuration", + "dependency-injection", + "environment", + "standard-schema", + "typescript", + "unthrown", + "validation" + ], + "homepage": "https://github.com/btravstack/start#readme", + "bugs": { + "url": "https://github.com/btravstack/start/issues" + }, + "license": "MIT", + "author": "Benoit TRAVERS ", + "repository": { + "type": "git", + "url": "https://github.com/btravstack/start.git", + "directory": "packages/config" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./zod": { + "import": { + "types": "./dist/zod.d.mts", + "default": "./dist/zod.mjs" + }, + "require": { + "types": "./dist/zod.d.cts", + "default": "./dist/zod.cjs" + } + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsdown src/index.ts src/zod.ts --format cjs,esm --dts --clean", + "test": "vitest run --coverage", + "test:types": "tsc --noEmit -p tsconfig.test-d.json", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" + }, + "dependencies": { + "@standard-schema/spec": "catalog:", + "@unthrown/standard-schema": "catalog:" + }, + "devDependencies": { + "@btravstack/di": "catalog:", + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", + "@vitest/coverage-v8": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "unthrown": "catalog:", + "vitest": "catalog:", + "zod": "catalog:" + }, + "peerDependencies": { + "@btravstack/di": "^0.1.0", + "unthrown": "^5.0.0", + "zod": "^4" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/config/src/collect.spec.ts b/packages/config/src/collect.spec.ts new file mode 100644 index 0000000..c656b04 --- /dev/null +++ b/packages/config/src/collect.spec.ts @@ -0,0 +1,57 @@ +import { Module, Port } from "@btravstack/di"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { collect } from "./collect.js"; +import { Config, type ValueOf } from "./slice.js"; + +const aShape = { x: z.string().default("a") }; +class APort extends Port("A")> {} +const A = Config(APort, "A")(aShape); + +const bShape = { y: z.string().default("b") }; +class BPort extends Port("B")> {} +const B = Config(BPort, "B")(bShape); + +describe("collect", () => { + it("finds an adapter nested anywhere in the import tree", () => { + // GIVEN an adapter imported by a module imported by the root + const Inner = Module("Inner")({ imports: [A], exports: [APort] }); + const Root = Module("Root")({ imports: [Inner, B], exports: [APort, BPort] }); + + // WHEN the tree is walked + // THEN both adapters are found, wherever they sit + expect(collect(Root)).toEqual(expect.arrayContaining([A, B])); + }); + + it("returns an adapter imported twice only once", () => { + // GIVEN two modules importing the same adapter + const One = Module("One")({ imports: [A], exports: [APort] }); + const Two = Module("Two")({ imports: [A], exports: [APort] }); + const Root = Module("Root")({ imports: [One, Two], exports: [APort] }); + + // WHEN the tree is walked + // THEN the adapter appears once — it is parsed once, so it is reported once + expect(collect(Root)).toEqual([A]); + }); + + it("returns adapters in the order they were declared", () => { + // GIVEN a root importing two adapters directly, in a given order + const Root = Module("Root")({ imports: [A, B], exports: [APort, BPort] }); + + // WHEN the tree is walked + // THEN they come back in that same order — `describeIssues` promises one + // line per issue "in the order the adapters declared them" + expect(collect(Root)).toEqual([A, B]); + }); + + it("ignores ordinary modules", () => { + // GIVEN a tree with no adapters at all + const Plain = Module("Plain")({}); + const Root = Module("Root")({ imports: [Plain] }); + + // WHEN the tree is walked + // THEN nothing is reported + expect(collect(Root)).toEqual([]); + }); +}); diff --git a/packages/config/src/collect.ts b/packages/config/src/collect.ts new file mode 100644 index 0000000..5be9302 --- /dev/null +++ b/packages/config/src/collect.ts @@ -0,0 +1,26 @@ +import { CONFIG_ADAPTER, type AnyConfigAdapter, type AnyModule } from "./slice.js"; + +const isAdapter = (module: AnyModule): module is AnyConfigAdapter => CONFIG_ADAPTER in module; + +/** + * Every config adapter reachable from a root module, each once. + * + * Iterative rather than recursive, and `seen`-guarded: a module graph may + * reach the same module by two paths, and an adapter imported twice must be + * parsed — and reported — once. + */ +export const collect = (module: AnyModule): readonly AnyConfigAdapter[] => { + const seen = new Set(); + const adapters: AnyConfigAdapter[] = []; + const queue: AnyModule[] = [module]; + + 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); + } + + return adapters; +}; diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts new file mode 100644 index 0000000..ee8903d --- /dev/null +++ b/packages/config/src/errors.ts @@ -0,0 +1,21 @@ +import { TaggedError } from "unthrown"; + +/** One wrong variable, already named the way the environment names it. */ +export type ConfigIssue = { + readonly variable: string; + readonly message: string; +}; + +/** + * Every wrong variable in one value. Carried out of the boot path so the + * kernel can report the whole environment at once and exit `EX_CONFIG`. + */ +export class ConfigInvalid extends TaggedError("config/ConfigInvalid")<{ + readonly issues: readonly ConfigIssue[]; +}> { + override message = `configuration is invalid`; +} + +/** One line per issue, in the order the slices declared them. */ +export const describeIssues = (issues: readonly ConfigIssue[]): string => + issues.map((issue) => ` ${issue.variable}: ${issue.message}`).join("\n"); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 0000000..b0c7273 --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,20 @@ +import { collect } from "./collect.js"; +import { parseAll } from "./parse.js"; +import { Config as declareConfig } from "./slice.js"; +import { source } from "./source.js"; + +export { ConfigInvalid, describeIssues, type ConfigIssue } from "./errors.js"; +export type { Shape } from "./parse.js"; +export { ConfigSource } from "./source.js"; +export type { AnyConfigAdapter, ValueOf } from "./slice.js"; + +/** + * `Config(port, "PREFIX")({ … })` implements `port` as a module; the + * namespaced operations are the kernel's, following `Module.build` / + * `Module.scoped` and `Port.many`. + */ +export const Config = Object.assign(declareConfig, { + collect, + parse: parseAll, + source, +}); diff --git a/packages/config/src/parse-all.spec.ts b/packages/config/src/parse-all.spec.ts new file mode 100644 index 0000000..da614fb --- /dev/null +++ b/packages/config/src/parse-all.spec.ts @@ -0,0 +1,62 @@ +import { Port } from "@btravstack/di"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { Config, type ValueOf } from "./index.js"; + +const amqpShape = { url: z.string().min(1).default("amqp://localhost") }; +class AmqpPort extends Port("Amqp")> {} +const Amqp = Config(AmqpPort, "AMQP")(amqpShape); + +const httpShape = { + port: z.string().min(1).pipe(z.coerce.number().int()).default(3000), +}; +class HttpPort extends Port("Http")> {} +const Http = Config(HttpPort, "HTTP")(httpShape); + +describe("Config.parse", () => { + it("accepts an environment every adapter agrees with", () => { + // GIVEN a valid environment + const source = { AMQP_URL: "amqp://broker", HTTP_PORT: "8080" }; + + // WHEN every adapter is validated + // THEN nothing is reported + expect(Config.parse([Amqp, Http], source)).toBeOk(); + }); + + it("reports every wrong variable across every adapter, in one value", () => { + // GIVEN two adapters wrong at once — the operator's real situation + const source = { AMQP_URL: "", HTTP_PORT: "abc" }; + + // WHEN they are validated + const outcome = Config.parse([Amqp, Http], source); + + // THEN one error carries both, so one deploy fixes both + expect(outcome).toBeErrTagged("config/ConfigInvalid", { + issues: [ + expect.objectContaining({ variable: "AMQP_URL" }), + expect.objectContaining({ variable: "HTTP_PORT" }), + ], + }); + }); + + it("returns rather than throws when an adapter's schema validates asynchronously", () => { + // GIVEN an adapter whose schema validates asynchronously — a shape only + // `Config.parse`'s type accepts, not something a sync `Result` can run + const asyncSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "test", + validate: (value) => Promise.resolve({ value: String(value) }), + }, + }; + class AsyncPort extends Port("Async")<{ value: string }> {} + const Async = Config(AsyncPort, "ASYNC")({ value: asyncSchema }); + + // WHEN it is validated through the public entry point + // THEN the crash is a Defect, not a `ConfigInvalid` — and it never throws + expect(() => Config.parse([Async], { ASYNC_VALUE: "x" })).not.toThrow(); + expect(Config.parse([Async], { ASYNC_VALUE: "x" })).toBeDefect(); + }); +}); diff --git a/packages/config/src/parse.spec.ts b/packages/config/src/parse.spec.ts new file mode 100644 index 0000000..89b50ac --- /dev/null +++ b/packages/config/src/parse.spec.ts @@ -0,0 +1,85 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { parseShape } from "./parse.js"; + +const shape = { + url: z.string().min(1).default("amqp://localhost"), + prefetch: z.string().min(1).pipe(z.coerce.number().int()).default(10), +}; + +/** A schema whose `validate` crashes rather than reporting an issue. */ +const crashingSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "test", + validate: () => { + // oxlint-disable-next-line unthrown/no-throw -- simulates a third-party Standard Schema whose validate() throws, to exercise the defect path + throw new Error("schema bug, not a config problem"); + }, + }, +}; + +/** A schema that validates asynchronously — outside what `Shape` can run sync. */ +const asyncSchema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "test", + validate: (value) => Promise.resolve({ value: String(value) }), + }, +}; + +describe("parseShape", () => { + it("reads each key from its prefixed variable", () => { + // GIVEN an environment carrying both variables + const source = { AMQP_URL: "amqp://broker", AMQP_PREFETCH: "32" }; + + // WHEN the shape is parsed + // THEN the value is keyed by the schema's own camelCase names + expect(parseShape("AMQP", shape, source)).toBeOkWith({ + url: "amqp://broker", + prefetch: 32, + }); + }); + + it("falls back to the declared defaults when a variable is absent", () => { + // GIVEN an empty environment + // WHEN the shape is parsed + // THEN the starter's own defaults stand in + expect(parseShape("AMQP", shape, {})).toBeOkWith({ + url: "amqp://localhost", + prefetch: 10, + }); + }); + + it("labels every issue with the variable an operator must fix", () => { + // GIVEN two variables that are present but wrong + const source = { AMQP_URL: "", AMQP_PREFETCH: "abc" }; + + // WHEN the shape is parsed + // THEN both are reported, named as the environment names them — not as + // the schema's camelCase keys, which no operator can act on + expect(parseShape("AMQP", shape, source)).toBeErrWith([ + expect.objectContaining({ variable: "AMQP_URL" }), + expect.objectContaining({ variable: "AMQP_PREFETCH" }), + ]); + }); + + it("propagates a crash inside validation as a defect, not an issue", () => { + // GIVEN a schema that crashes rather than reporting an issue — a bug in + // the schema, not a wrong environment + // WHEN the shape is parsed + // THEN the crash is a Defect, not folded into the reported issues + expect(parseShape("AMQP", { url: crashingSchema }, { AMQP_URL: "x" })).toBeDefect(); + }); + + it("returns rather than throws when a schema validates asynchronously", () => { + // GIVEN a schema outside what a synchronous `Shape` can run — `Shape`'s + // own type accepts it, but `fromSchema` cannot represent the pending work + // WHEN the shape is parsed + // THEN it lands on the defect channel instead of throwing out of parseShape + expect(() => parseShape("AMQP", { url: asyncSchema }, { AMQP_URL: "x" })).not.toThrow(); + expect(parseShape("AMQP", { url: asyncSchema }, { AMQP_URL: "x" })).toBeDefect(); + }); +}); diff --git a/packages/config/src/parse.ts b/packages/config/src/parse.ts new file mode 100644 index 0000000..42f3470 --- /dev/null +++ b/packages/config/src/parse.ts @@ -0,0 +1,89 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { fromSchema } from "@unthrown/standard-schema"; +import { Err, fromSafeThrowable, isDefect, Ok, type Result } from "unthrown"; + +import { ConfigInvalid, type ConfigIssue } from "./errors.js"; +import type { AnyConfigAdapter } from "./slice.js"; +import { variableName } from "./variable.js"; + +/** One validator per variable — the prefix mapping needs each key's own name. */ +export type Shape = Record; + +/** + * Parse every key of one slice, collecting issues rather than stopping at the + * first: an operator fixing three variables should learn all three now. + */ +export const parseShape = ( + prefix: string, + shape: Shape, + source: Record, +): Result, readonly ConfigIssue[]> => { + const value: Record = {}; + const issues: ConfigIssue[] = []; + + for (const [key, schema] of Object.entries(shape)) { + const variable = variableName(prefix, key); + // `fromSchema` throws a raw `TypeError` when handed a schema that + // validates *asynchronously* — a synchronous `Result` cannot represent + // pending work — and that throw happens at the call itself, past + // `fromSchema`'s own boundary. Wrapping the call turns it into a + // `Defect` like any other crash, instead of letting it escape + // `Config.parse` as an uncaught exception; `.flatMap` then unwraps the + // one extra layer `fromSafeThrowable` adds, letting a Defect from either + // source pass through untouched. + const parsed = fromSafeThrowable(() => fromSchema(schema)(source[variable]))().flatMap( + (result) => result, + ); + + // A crash inside validation is a defect in the schema, not a wrong + // environment: propagate it on the defect channel rather than folding it + // into `issues`, which would misreport a validator bug as "fix this + // variable" and, downstream, exit `EX_CONFIG` where the design says + // `EX_SOFTWARE`. The cast is sound but not checkable: a `Defect` carries + // no `T`/`E`-dependent data, so it is compatible with any `Result` type, + // but the compiler can't see that across two structurally-unrelated + // instantiations. + if (isDefect(parsed)) { + return parsed as unknown as Result, readonly ConfigIssue[]>; + } + + if (parsed.isOk()) { + value[key] = parsed.value; + } else { + for (const issue of parsed.error) issues.push({ variable, message: issue.message }); + } + } + + return issues.length > 0 ? Err(issues) : Ok(value); +}; + +/** + * Validate every config adapter against one source, aggregating across all of + * them. + * + * Deliberately not short-circuiting *on modeled issues*: three wrong + * variables should cost one deploy, not three. The success value is `void` + * — each adapter's own provider parses again when the graph is built, from + * the same injected source, so there is nothing to carry across and no state + * to keep in step. + * + * A `Defect` from any one adapter, by contrast, does stop the walk: it is a + * bug in that adapter's own schema, not a wrong environment, and aggregating + * further adapters cannot make it any more or less true. Propagating it + * immediately keeps `Config.parse`'s promise — a `Defect` never becomes a + * `ConfigInvalid` issue, the same rule `parseShape` applies per key. + */ +export const parseAll = ( + adapters: readonly AnyConfigAdapter[], + source: Record, +): Result => { + const issues: ConfigIssue[] = []; + + for (const adapter of adapters) { + const parsed = parseShape(adapter.prefix, adapter.shape, source); + if (isDefect(parsed)) return parsed as unknown as Result; + if (parsed.isErr()) issues.push(...parsed.error); + } + + return issues.length > 0 ? Err(new ConfigInvalid({ issues })) : Ok(); +}; diff --git a/packages/config/src/slice.spec.ts b/packages/config/src/slice.spec.ts new file mode 100644 index 0000000..bb4fd94 --- /dev/null +++ b/packages/config/src/slice.spec.ts @@ -0,0 +1,94 @@ +import { Module, Port, Provider } from "@btravstack/di"; +import { OkAsync } from "unthrown"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { Config, type ValueOf } from "./slice.js"; +import { source } from "./source.js"; + +const amqpShape = { url: z.string().min(1).default("amqp://localhost") }; +class AmqpConfig extends Port("AmqpConfig")> {} +const AmqpConfigFromEnv = Config(AmqpConfig, "AMQP")(amqpShape); + +describe("a config adapter", () => { + it("implements the port it is given, injected under the port's own identity", async () => { + // GIVEN a module importing the env adapter and a source + const App = Module("App")({ + imports: [AmqpConfigFromEnv, source({ AMQP_URL: "amqp://broker" })], + exports: [AmqpConfig], + }); + + // WHEN the graph is built and the port is resolved + const value = await Module.scoped(App, (ctx) => OkAsync(ctx.get(AmqpConfig))); + + // THEN the adapter served the port it was declared for + expect(value).toBeOkWith({ url: "amqp://broker" }); + }); + + it("is parsed once even when two modules import it", async () => { + // GIVEN a port whose validator counts how many times it actually runs + let parses = 0; + const countedShape = { + url: z + .string() + .min(1) + .default("amqp://localhost") + .transform((value) => { + parses++; + return value; + }), + }; + class CountedConfig extends Port("CountedConfig")> {} + const CountedConfigFromEnv = Config(CountedConfig, "COUNTED")(countedShape); + + // AND two modules that both import that adapter + const A = Module("A")({ imports: [CountedConfigFromEnv], exports: [CountedConfig] }); + const B = Module("B")({ imports: [CountedConfigFromEnv], exports: [CountedConfig] }); + const Both = Module("Both")({ + imports: [A, B, source({ COUNTED_URL: "amqp://broker" })], + exports: [CountedConfig], + }); + + // WHEN the graph is built + const value = await Module.scoped(Both, (ctx) => OkAsync(ctx.get(CountedConfig))); + + // THEN di deduped the module — one provider ran, so the validator ran once + expect(value).toBeOkWith({ url: "amqp://broker" }); + expect(parses).toBe(1); + }); + + it("names its module after its prefix, for a legible graph", () => { + // GIVEN an env adapter + // WHEN its module name is read + // THEN it says which configuration it is, not which port it implements + expect(AmqpConfigFromEnv.name).toBe("Config(AMQP)"); + }); + + it("rejects a prefix that is not upper-snake-case, at declaration", () => { + // GIVEN a lowercase prefix — `Config(port, "amqp")` would derive + // `amqp_URL`, a variable no operator will ever set + // WHEN the adapter is declared + // THEN the mistake is caught immediately, not left to fall back silently + expect(() => + Config(AmqpConfig, "amqp")({ url: z.string().default("amqp://localhost") }), + ).toThrow(/upper-snake-case/); + }); + + it("lets a port the env adapter implements be provided as a literal instead, with no config module involved", async () => { + // GIVEN the exact same port `AmqpConfigFromEnv` implements, and a + // consumer that only ever depends on the port — never on `Config` + const readUrl = (ctx: { get: (port: typeof AmqpConfig) => ValueOf }) => + OkAsync(ctx.get(AmqpConfig).url); + + // WHEN a test hands the port a literal, ordinary di provider — no + // `Config`, no `ConfigSource`, no environment + const Fake = Module("Fake")({ + provides: [Provider(AmqpConfig)({ value: { url: "amqp://fake" } })], + exports: [AmqpConfig], + }); + + // THEN the same consumer resolves it unchanged: the port is adaptable + const value = await Module.scoped(Fake, readUrl); + expect(value).toBeOkWith("amqp://fake"); + }); +}); diff --git a/packages/config/src/slice.test-d.ts b/packages/config/src/slice.test-d.ts new file mode 100644 index 0000000..d22837a --- /dev/null +++ b/packages/config/src/slice.test-d.ts @@ -0,0 +1,53 @@ +import { Module, Port } from "@btravstack/di"; +import { z } from "zod"; + +import { Config, type ValueOf } from "./index.js"; + +const shape = { + url: z.string().default("amqp://localhost"), + prefetch: z.string().pipe(z.coerce.number()).default(10), +}; +class AmqpConfig extends Port("AmqpConfig")> {} +const AmqpConfigFromEnv = Config(AmqpConfig, "AMQP")(shape); + +// The adapter is importable as a module, alongside the source it needs … +const App = Module("App")({ + imports: [AmqpConfigFromEnv, Config.source({})], + exports: [AmqpConfig], +}); + +// … and the port it implements is resolvable, with the shape's own output types. +void Module.scoped(App, (ctx) => { + const value = ctx.get(AmqpConfig); + const url: string = value.url; + const prefetch: number = value.prefetch; + // @ts-expect-error — `nope` is not a key of this port's shape + const missing = value.nope; + void [url, prefetch, missing]; + return undefined as never; +}); + +// A module importing an adapter with no `Config.source` leaves `ConfigSource` +// unmet: a compile error at the call site (di's arity gate on `Needs`), not +// a runtime `WiringDefect` a laundered `never` used to defer this to. +const Unsourced = Module("Unsourced")({ imports: [AmqpConfigFromEnv], exports: [AmqpConfig] }); +// @ts-expect-error — `ConfigSource` is unmet: no `Config.source(...)` import +void Module.scoped(Unsourced, (ctx) => { + void ctx.get(AmqpConfig); + return undefined as never; +}); + +// A shape missing a key the port declares does not implement the port: the +// enforcement is what makes "this adapter implements this port" a compile-time +// fact rather than something only discovered once `ctx.get(port)` is read. +class NeedsPrefetch extends Port("NeedsPrefetch")<{ url: string; prefetch: number }> {} +const adaptNeedsPrefetch = Config(NeedsPrefetch, "AMQP"); +// @ts-expect-error — shape is missing `prefetch`, which the port declares +adaptNeedsPrefetch({ url: z.string().default("amqp://localhost") }); + +// A shape with the wrong output type for a key the port declares does not +// implement the port either. +class NeedsNumericPrefetch extends Port("NeedsNumericPrefetch")<{ prefetch: number }> {} +const adaptNeedsNumericPrefetch = Config(NeedsNumericPrefetch, "AMQP"); +// @ts-expect-error — `prefetch` parses to a `string`, not the `number` the port declares +adaptNeedsNumericPrefetch({ prefetch: z.string().default("10") }); diff --git a/packages/config/src/slice.ts b/packages/config/src/slice.ts new file mode 100644 index 0000000..e2c35fb --- /dev/null +++ b/packages/config/src/slice.ts @@ -0,0 +1,158 @@ +import { Module, Provider, type AnyPort, type ServiceOf } from "@btravstack/di"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { P } from "unthrown"; + +import { ConfigInvalid } from "./errors.js"; +import { parseShape, type Shape } from "./parse.js"; +import { ConfigSource } from "./source.js"; +import { assertValidPrefix } from "./variable.js"; + +/** The parsed value of a shape: each key's validator output. */ +export type ValueOf = { + readonly [K in keyof S]: StandardSchemaV1.InferOutput; +}; + +/** + * Marks a `Config(port, prefix)(shape)` result so `collect` (Task 4) can find + * it in a module tree by identity rather than by duck-typing on `provides` or + * `exports`, which every module — adapter or not — carries. Exported: + * `collect` lives in a different file and needs this exact symbol to test + * `CONFIG_ADAPTER in module`, not a copy of it — an unexported unique symbol + * has no identity another module can reach. + * + * `Symbol.for` (the global registry), not a fresh `Symbol(...)`: a fresh + * symbol has per-*module-instantiation* identity, so two resolved copies of + * this package — realistic once several starters depend on it across + * diverging semver ranges, or across an ESM/CJS split — would each brand + * their adapters with a different symbol. `collect`'s `CONFIG_ADAPTER in + * module` check would then silently miss every adapter built by the other + * copy, so `Config.parse` reports no issues for them and boot proceeds + * against a bad environment — the exact failure this package exists to + * prevent, and it would fail quietly. The registry symbol is shared + * process-wide by string key, so every copy of this module resolves to the + * same one regardless of which copy actually ran `Config(port, prefix)(shape)`. + */ +export const CONFIG_ADAPTER = Symbol.for("btravstack/config/adapter"); + +/** + * Restated rather than imported: di's own `AnyModule` isn't part of its + * public export list. `imports` is the only field `collect`'s walk reads, so + * that is all this needs to say — but it is what `AnyConfigAdapter.imports` + * below must also match, recursively, for a real adapter to satisfy both + * this type (so `collect` can walk into one) and `AnyConfigAdapter` (so + * `parseAll` can read one back out). Exported so `collect.ts` uses this one + * instead of a second, merely structurally-identical copy. + */ +export type AnyModule = { readonly imports: readonly AnyModule[] }; + +/** + * The structural type of what `Config(port, prefix)(shape)` returns, as + * later tasks consume it: an ordinary di module — branded, and carrying back + * the `prefix` and `shape` it was built from so `collect`/`parseAll` can read + * them off the value instead of threading them separately. Unlike the welded + * design this replaces, it is *only* a module: the port it implements is a + * separate value, declared by whoever needs the port to exist. + */ +export type AnyConfigAdapter = AnyModule & { + readonly [CONFIG_ADAPTER]: true; + readonly prefix: string; + readonly shape: Shape; +}; + +/** + * `Config(port, prefix)(shape)` implements `port` — an ordinary port, + * declared by the caller with `Port(id)` — by parsing + * `prefix`-scoped variables out of `ConfigSource`, and returns the **module** + * that provides it. It does not declare a port and does not return one: the + * port stays adaptable precisely because this is just one provider for it, + * the same as a test's literal or a future file/secret-manager adapter + * would be. + * + * `ValueOf extends ServiceOf

` — checked via the trailing rest + * parameter, the same arity-gate idiom `Module.build`'s `_missing` uses — is + * "this adapter implements this port" made a compile-time fact: a shape + * missing a key the port declares, or with the wrong type for one, is a + * call-arity error at `Config(port, prefix)(shape)`, not a mismatch + * discovered only once `ctx.get(port)` is read. + */ +export const Config = + (port: TPort, prefix: Prefix) => + ( + shape: S, + ..._implements: ValueOf extends ServiceOf + ? [] + : [error: "shape does not implement the port's service type", expected: ServiceOf] + ): Module, never, ConfigSource> & { + readonly [CONFIG_ADAPTER]: true; + readonly prefix: Prefix; + readonly shape: S; + } => { + assertValidPrefix(prefix); + + const provider = Provider(port)([ConfigSource], { + sync: (source: ServiceOf) => + // The provider is reached only after `Config.parse` has already + // validated every adapter, so a failure here is a defect rather than + // a domain outcome — the environment cannot have changed in between. + // That ordering is not something this file enforces: `Config.parse` + // (Task 5) does the validating, and `@btravstack/start-core` is what + // will call it before building the graph, in phase 2 — this file + // only trusts the kernel to have done so first. Reaching this line + // with an invalid environment therefore means that guarantee was + // skipped, which is a bug in the caller, not in the environment — + // exactly what `getOrThrow` turning it into a thrown `ConfigInvalid` + // expresses. Wrapped into `ConfigInvalid` first (rather than thrown + // as the bare `ConfigIssue[]` `parseShape` returns) so a reporter + // catching this sees a tagged error with a real `message`, not + // `[object Object]`. + // oxlint-disable-next-line unthrown/no-get-or-throw -- deliberate defect conversion; see the comment above on why reaching this invalid is a caller bug, not a domain outcome + parseShape(prefix, shape, source) + .mapErrCases((matcher) => + // oxlint-disable-next-line unthrown/no-catch-all-pattern -- E is readonly ConfigIssue[], a single non-union type + matcher.with(P._, (issues) => new ConfigInvalid({ issues })), + ) + .getOrThrow() as ServiceOf, + }); + + // `port as never`: `Module`'s `exports` array checks each entry against + // `AnyPort & (new () => Available<...>)` — a *non-abstract* constructor + // — but `AnyPort` (and therefore `TPort`, bounded by it) declares an + // *abstract* one on purpose (see `port.ts`'s note on why: it is what + // lets a concrete port class still widen to `AnyPort`). A generic + // `TPort` is checked against its bound, not against whatever concrete + // class the caller actually passed, so TypeScript can never see this + // particular `port` as concrete — no cast to a *named* type closes that + // gap. `never` is the bottom type, assignable into anything, so it + // sidesteps the check entirely rather than trying to satisfy it; the + // final cast below restates the real, precise type this factory + // promises, so nothing downstream ever observes the widening. + const adapter = Module(`Config(${prefix})`)({ + provides: [provider], + exports: [port as never], + }); + + // Non-enumerable — the default `Object.defineProperties` descriptor: + // `collect`/`parseAll` reach these three through the type, not through + // `Object.keys`/spreading the adapter, so leaving them out of an + // enumerated view keeps `{ ...adapter }` and friends showing only the + // ordinary module fields (`name`/`imports`/`provides`/`exports`). + Object.defineProperties(adapter, { + [CONFIG_ADAPTER]: { value: true }, + prefix: { value: prefix }, + shape: { value: shape }, + }); + + // `as unknown as`: the widened `exports: [port as never]` above means + // `Module(...)`'s own inferred return type is wider than the precise + // `Module, never, ConfigSource>` this factory + // promises — a direct `as` between two differently-instantiated + // `Module<...>`s is rejected for the same contravariant-phantom-field + // reason di's own `Provider`/`Module` factories cast `as never` + // internally. The object itself is exactly right at runtime; only the + // type needs restating here, back to what the port and shape actually are. + return adapter as unknown as Module, never, ConfigSource> & { + readonly [CONFIG_ADAPTER]: true; + readonly prefix: Prefix; + readonly shape: S; + }; + }; diff --git a/packages/config/src/source.ts b/packages/config/src/source.ts new file mode 100644 index 0000000..314ce6f --- /dev/null +++ b/packages/config/src/source.ts @@ -0,0 +1,15 @@ +import { Module, Port, Provider } from "@btravstack/di"; + +/** + * Where slices read from. A port rather than an ambient `process.env` read so + * that the kernel's validation pass and the slices' own providers cannot + * disagree about what the environment was. + */ +export class ConfigSource extends Port("config/Source")> {} + +/** The module an application (or, in phase 2, `start`) imports exactly once. */ +export const source = (record: Record) => + Module("ConfigSource")({ + provides: [Provider(ConfigSource)({ value: record })], + exports: [ConfigSource], + }); diff --git a/packages/config/src/variable.spec.ts b/packages/config/src/variable.spec.ts new file mode 100644 index 0000000..f51cf01 --- /dev/null +++ b/packages/config/src/variable.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { assertValidPrefix, variableName } from "./variable.js"; + +describe("assertValidPrefix", () => { + it("rejects a prefix that is not upper-snake-case", () => { + // GIVEN a lowercase prefix — `amqp_URL` is a variable nobody will set + // WHEN the prefix is checked + // THEN the mistake is caught, not silently accepted + expect(() => assertValidPrefix("amqp")).toThrow(/upper-snake-case/); + }); + + it("accepts an upper-snake-case prefix", () => { + // GIVEN a properly shouted prefix + // WHEN the prefix is checked + // THEN nothing is thrown + expect(() => assertValidPrefix("AMQP")).not.toThrow(); + }); +}); + +describe("variableName", () => { + it("joins the prefix to a screaming-snake key", () => { + // GIVEN a slice prefix and a camelCase key + // WHEN the environment variable name is derived + // THEN it is the shouted form the environment actually uses + expect(variableName("AMQP", "url")).toBe("AMQP_URL"); + }); + + it("splits camelCase into words", () => { + // GIVEN a multi-word key + // WHEN the name is derived + // THEN each word boundary becomes an underscore + expect(variableName("AMQP", "prefetchCount")).toBe("AMQP_PREFETCH_COUNT"); + }); + + it("keeps digits attached to the word they follow", () => { + // GIVEN a key with a trailing number + // WHEN the name is derived + // THEN the digit does not become a word of its own + expect(variableName("HTTP", "port2")).toBe("HTTP_PORT2"); + }); +}); diff --git a/packages/config/src/variable.ts b/packages/config/src/variable.ts new file mode 100644 index 0000000..46a5d73 --- /dev/null +++ b/packages/config/src/variable.ts @@ -0,0 +1,27 @@ +const PREFIX_PATTERN = /^[A-Z][A-Z0-9_]*$/; + +/** + * `Config("amqp")` would derive `amqp_URL` — a variable no operator will ever + * set, so every key silently falls back to its default: a wrong environment + * that reports clean. Called from `Config(prefix)(shape)` at slice + * declaration — synchronous, import-time setup code, not the `Result` + * pipeline `Config.parse` promises never to throw out of — so a malformed + * prefix is a programmer error caught immediately, the same way a malformed + * schema throws at definition time. + */ +export const assertValidPrefix = (prefix: string): void => { + if (!PREFIX_PATTERN.test(prefix)) { + // oxlint-disable-next-line unthrown/no-throw -- programmer-error precondition at import-time slice declaration, not the Result pipeline; see the doc comment above + throw new Error( + `Config prefix must be upper-snake-case (e.g. "AMQP"), got ${JSON.stringify(prefix)}`, + ); + } +}; + +/** + * The environment variable a slice's key is read from: the prefix, then the + * key shouted. Spring's relaxed binding, in the one direction an environment + * needs it — the environment shouts, the injected value does not. + */ +export const variableName = (prefix: string, key: string): string => + `${prefix}_${key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase()}`; diff --git a/packages/config/src/zod.spec.ts b/packages/config/src/zod.spec.ts new file mode 100644 index 0000000..27f2fa9 --- /dev/null +++ b/packages/config/src/zod.spec.ts @@ -0,0 +1,49 @@ +import { fromSchema } from "@unthrown/standard-schema"; +import { describe, expect, it } from "vitest"; + +import { port, wholeNumber } from "./zod.js"; + +describe("wholeNumber", () => { + const parse = fromSchema(wholeNumber(10, 1, 100)); + + it("defaults when the variable is absent", () => { + // GIVEN nothing set + // WHEN parsed + // THEN the fallback stands in + expect(parse(undefined)).toBeOkWith(10); + }); + + it("rejects a present-but-empty value rather than defaulting it", () => { + // GIVEN `VAR=` — set, but to nothing + // WHEN parsed + // THEN it is a configuration error: `Number("")` is `0`, and a silent `0` + // is the bug this guard exists to prevent + expect(parse("")).toBeErr(); + }); + + it("rejects a non-number, a fraction, and an out-of-range value", () => { + // GIVEN values the bounds must catch + // WHEN parsed + // THEN each is an error + expect(parse("abc")).toBeErr(); + expect(parse("3.5")).toBeErr(); + expect(parse("101")).toBeErr(); + }); + + it("accepts a whole number in range", () => { + // GIVEN a good value, as a string — the only thing an environment holds + // WHEN parsed + // THEN it arrives as a number + expect(parse("42")).toBeOkWith(42); + }); +}); + +describe("port", () => { + it("allows 0, the ephemeral bind", () => { + // GIVEN `PORT=0` + // WHEN parsed + // THEN it survives: a port's `min` IS `0`, which is why the empty-string + // guard cannot be left to the bounds + expect(fromSchema(port(3000))("0")).toBeOkWith(0); + }); +}); diff --git a/packages/config/src/zod.ts b/packages/config/src/zod.ts new file mode 100644 index 0000000..a20608e --- /dev/null +++ b/packages/config/src/zod.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +/** + * A whole number, read the way an environment variable actually arrives: as a + * string. + * + * The non-empty string in front of the coercion is the load-bearing part. + * Coercion is `Number()` underneath, and `Number("")` is `0` — so a bare + * `PORT=` would bind the ephemeral port `0`, an endpoint nobody can find. The + * bounds cannot catch it, because a port's `min` **is** `0`. An empty value is + * a configuration error, not an absent one — `.default(...)` applies only when + * the variable is genuinely missing. + * + * The `` type argument is needed because `z.coerce.number()`'s input + * is `unknown`, which `.pipe` will not accept from a `string`. + */ +export const wholeNumber = (fallback: number, min: number, max: number) => + z + .string() + .trim() + .min(1) + .pipe(z.coerce.number().int().min(min).max(max)) + .default(fallback); + +/** A port: a whole number in the range the OS will accept, `0` included. */ +export const port = (fallback: number) => wholeNumber(fallback, 0, 65_535); diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 0000000..2b79fef --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationMap": false, + "types": ["node", "@unthrown/vitest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test-d.ts"] +} diff --git a/packages/config/tsconfig.test-d.json b/packages/config/tsconfig.test-d.json new file mode 100644 index 0000000..0fa2f70 --- /dev/null +++ b/packages/config/tsconfig.test-d.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "files": [], + "include": ["src/**/*.test-d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/config/vitest.config.ts b/packages/config/vitest.config.ts new file mode 100644 index 0000000..84bcc7b --- /dev/null +++ b/packages/config/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.spec.ts", "src/**/*.test-d.ts"], + thresholds: { lines: 100, functions: 100 }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84c1fcd..a09dbc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ catalogs: '@prisma/client': specifier: 7.9.1 version: 7.9.1 + '@standard-schema/spec': + specifier: 1.1.0 + version: 1.1.0 '@temporal-contract/client': specifier: 8.0.0-beta.5 version: 8.0.0-beta.5 @@ -604,6 +607,46 @@ 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) + packages/config: + dependencies: + '@standard-schema/spec': + specifier: 'catalog:' + version: 1.1.0 + '@unthrown/standard-schema': + specifier: 'catalog:' + version: 5.5.0 + devDependencies: + '@btravstack/di': + specifier: 'catalog:' + version: 0.1.0(unthrown@5.5.0) + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.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) + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.10(vitest@4.1.10) + tsdown: + specifier: 'catalog:' + version: 0.22.14(oxc-resolver@11.24.2)(typescript@7.0.2) + 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) + zod: + specifier: 'catalog:' + version: 4.4.3 + packages/start-amqp: devDependencies: '@amqp-contract/contract': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f36e7b0..1cbf29d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,6 +41,7 @@ catalog: "@orpc/server": 2.0.0-beta.23 "@prisma/adapter-better-sqlite3": 7.9.1 "@prisma/client": 7.9.1 + "@standard-schema/spec": 1.1.0 # `@temporal-contract/*` v8 is still in beta and its `latest` dist-tag is the # 7.x line, which peers on `unthrown@^4` while this repo pins 5.5.0. 8.x also # ships the `test-rig` / `workflow-bundle` subpaths the Temporal example's From 14fb96478faad43c3164a89e5506a4432bc9f096 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 15:30:07 +0200 Subject: [PATCH 2/3] test(config): cover the operator report and the skipped-validation path 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 --- packages/config/src/errors.spec.ts | 29 +++++++++++++++++++++++++++++ packages/config/src/slice.spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 packages/config/src/errors.spec.ts diff --git a/packages/config/src/errors.spec.ts b/packages/config/src/errors.spec.ts new file mode 100644 index 0000000..0c8d154 --- /dev/null +++ b/packages/config/src/errors.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { describeIssues } from "./errors.js"; + +describe("describeIssues", () => { + it("writes one indented line per issue, naming the variable", () => { + // GIVEN two wrong variables, as an operator's environment produced them + const issues = [ + { variable: "AMQP_URL", message: "must be a non-empty string" }, + { variable: "HTTP_PORT", message: "expected a whole number" }, + ]; + + // WHEN they are described + // THEN each line names the variable to fix — this is what an operator + // reads on a failed boot, so the variable comes first and the lines are + // indented under whatever headline the reporter prints + expect(describeIssues(issues)).toBe( + " AMQP_URL: must be a non-empty string\n HTTP_PORT: expected a whole number", + ); + }); + + it("describes an empty list as nothing at all", () => { + // GIVEN no issues — the shape a caller gets when it describes before + // checking whether there was anything to describe + // WHEN they are described + // THEN the result is empty rather than a stray blank line + expect(describeIssues([])).toBe(""); + }); +}); diff --git a/packages/config/src/slice.spec.ts b/packages/config/src/slice.spec.ts index bb4fd94..cead2ca 100644 --- a/packages/config/src/slice.spec.ts +++ b/packages/config/src/slice.spec.ts @@ -74,6 +74,28 @@ describe("a config adapter", () => { ).toThrow(/upper-snake-case/); }); + it("makes a skipped validation a defect, not a silent wrong value", async () => { + // GIVEN an environment the schema rejects, reached WITHOUT the kernel + // having run `Config.parse` first — the precondition the adapter's + // provider documents but cannot itself enforce + const strictShape = { url: z.string().min(1) }; + class StrictConfig extends Port("StrictConfig")> {} + const StrictFromEnv = Config(StrictConfig, "STRICT")(strictShape); + + const App = Module("App")({ + imports: [StrictFromEnv, source({ STRICT_URL: "" })], + exports: [StrictConfig], + }); + + // WHEN the graph is built anyway + const built = await Module.scoped(App, (ctx) => OkAsync(ctx.get(StrictConfig))); + + // THEN it is a Defect carrying the tagged error — a bug in the caller who + // skipped validation, never an `Err` the application could branch on, and + // never a wrong value that boots + expect(built).toBeDefect(); + }); + it("lets a port the env adapter implements be provided as a literal instead, with no config module involved", async () => { // GIVEN the exact same port `AmqpConfigFromEnv` implements, and a // consumer that only ever depends on the port — never on `Config` From ed0272d2d6f05d46333b4a29aec7fc725b11175a Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 15:43:11 +0200 Subject: [PATCH 3/3] fix(config): import ValueOf in the readme example, walk with a cursor 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 --- packages/config/README.md | 2 +- packages/config/src/collect.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/config/README.md b/packages/config/README.md index 65937dd..3458e2a 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -15,7 +15,7 @@ value — this package deliberately keeps them apart. ## Declaring a port and its env adapter ```ts -import { Config } from "@btravstack/config"; +import { Config, type ValueOf } from "@btravstack/config"; import { Port } from "@btravstack/di"; import { z } from "zod"; diff --git a/packages/config/src/collect.ts b/packages/config/src/collect.ts index 5be9302..f07b841 100644 --- a/packages/config/src/collect.ts +++ b/packages/config/src/collect.ts @@ -12,10 +12,14 @@ const isAdapter = (module: AnyModule): module is AnyConfigAdapter => CONFIG_ADAP export const collect = (module: AnyModule): readonly AnyConfigAdapter[] => { const seen = new Set(); const adapters: AnyConfigAdapter[] = []; + // A cursor rather than `shift()`: the walk must stay breadth-first so + // adapters come back in declaration order — which is the order + // `describeIssues` promises an operator — and an index keeps that order + // without re-indexing the array on every step. const queue: AnyModule[] = [module]; - while (queue.length > 0) { - const current = queue.shift(); + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const current = queue[cursor]; if (current === undefined || seen.has(current)) continue; seen.add(current); if (isAdapter(current)) adapters.push(current);