From 665d66618c129535ae8409cc036d0efcc3f53c35 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 15:21:41 +0200 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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); From 931b34b10fb97d24acc0bc90e415c41169ac4283 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 17:00:08 +0200 Subject: [PATCH 04/11] feat(config): collapse port and adapter into one Config(id)(shape, options?) value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/config/README.md | 166 +++++++++++++++----------- packages/config/package.json | 2 +- packages/config/src/collect.spec.ts | 22 ++-- packages/config/src/index.ts | 9 +- packages/config/src/parse-all.spec.ts | 12 +- packages/config/src/slice.spec.ts | 97 +++++++++------ packages/config/src/slice.test-d.ts | 48 ++++---- packages/config/src/slice.ts | 139 +++++++++++---------- packages/config/src/variable.spec.ts | 11 +- packages/config/src/variable.ts | 23 +++- 10 files changed, 296 insertions(+), 233 deletions(-) diff --git a/packages/config/README.md b/packages/config/README.md index 3458e2a..87d3944 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -1,44 +1,43 @@ # @btravstack/config Configuration parsed from the environment, wired through -[`@btravstack/di`](https://github.com/btravstack/di) as an ordinary port and -adapter. +[`@btravstack/di`](https://github.com/btravstack/di) as a port. -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. +`Config(id)(shape, options?)` declares **one value** that is both a di port +token and the module that serves it from the environment: a starter no longer +writes a port and a separate adapter for it. The signature mirrors +`@btravstack/entity`'s `Entity(tag)(fields, options?)` — curried on the +identity so it reads next to the name it labels, then the field map, then an +optional options object. The value stays adaptable even though it is one +thing, not two: it is a port like any other, so a test can hand it a literal +provider without ever importing it as a module, and a future file or +secret-manager source could be a different `imports:` entry providing the +same port. -## Declaring a port and its env adapter +## Declaring a config ```ts -import { Config, type ValueOf } from "@btravstack/config"; -import { Port } from "@btravstack/di"; +import { Config } from "@btravstack/config"; 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); +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().int()) + .default(10), + }, + { prefix: "AMQP" }, +); ``` -Each value in `shape` is a [Standard Schema](https://standardschema.dev) +Each value in the 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: +works. A starter ships `amqpConfig` 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. ```ts // the application @@ -49,55 +48,77 @@ const App = Module("App")({ imports: [ ApplicationModule, PersistenceModule, - AmqpConfigFromEnv, + amqpConfig, Config.source({ AMQP_URL: "amqp://broker" }), ], - exports: [AmqpConfig], + exports: [amqpConfig], }); -const value = await Module.scoped(App, (ctx) => OkAsync(ctx.get(AmqpConfig))); -// Ok({ url: "amqp://broker", prefetch: 10 }) +const { url, prefetch } = await Module.scoped(App, (ctx) => + OkAsync(ctx.get(amqpConfig)), +); +// { url: "amqp://broker", prefetch: 10 } ``` ```ts -// a test, or any other adapter — ordinary di, no config involvement +// a test — ordinary di, no config module or environment involved import { Module, Provider } from "@btravstack/di"; const Test = Module("Test")({ provides: [ - Provider(AmqpConfig)({ value: { url: "amqp://fake", prefetch: 1 } }), + Provider(amqpConfig)({ value: { url: "amqp://fake", prefetch: 1 } }), ], - exports: [AmqpConfig], + 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 +The test above never puts `amqpConfig` in `imports:` — only in `provides:` +and `exports:`. That works because `amqpConfig` is a token first: its module +statics (`name`/`imports`/`provides`/`exports` — the environment-parsing +behaviour) are only consulted when it appears in a module's `imports:` array. +A graph that never imports it that way never reaches them, so a test can +resolve `ctx.get(amqpConfig)` from a plain literal with no `Config.source` +and no environment at all. + +`imports: [amqpConfig]` provides the port; `ctx.get(amqpConfig)` reads it back +— from either the env adapter or a literal, unchanged. Imported by two +different modules in the same graph, it 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. +`ctx.get(amqpConfig)`'s type comes straight from the shape; annotate it +elsewhere with `ConfigType`: + +```ts +import type { ConfigType } from "@btravstack/config"; + +type AmqpValue = ConfigType; // { url: string; prefetch: number } +``` ## 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 +Each key of the shape maps to an environment variable named by the config's +prefix and the key shouted: `url` under `{ prefix: "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`. +under `{ prefix: "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`. + +`options.prefix` is optional. Omitted, it defaults to the screaming-snake +form of the identity: `Config("AmqpConfig")({ url: ... })` reads +`AMQP_CONFIG_URL`. Given explicitly, it is used verbatim, regardless of what +the identity itself spells — `Config("AmqpConfig")({ url: ... }, { prefix: +"AMQP" })` reads `AMQP_URL`. Either way, the _resolved_ prefix must be +upper-snake-case, checked at declaration: a lowercase prefix (or one derived +from an identity that doesn't shout cleanly) throws immediately rather than +falling back silently to variables no operator will ever set. ## `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 +Env-backed configs 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 @@ -106,31 +127,30 @@ 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. +environment was. If a config 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 +`Config.collect(module)` walks a module tree and returns every config reachable from it, each once, regardless of how many times or how deep it is imported: ```ts -const adapters = Config.collect(App); +const configs = 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`: +`Config.parse(configs, source)` validates all of them against one source and +aggregates every issue — from every config — into a single `ConfigInvalid`: ```ts -const outcome = Config.parse(adapters, process.env); +const outcome = Config.parse(configs, process.env); ``` -`outcome` is `Ok()` when every adapter agrees with the environment, or an +`outcome` is `Ok()` when every config 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 @@ -146,15 +166,14 @@ if (outcome.isErr()) { } ``` -`Config.collect` walks the **root** module tree only. An adapter reachable +`Config.collect` walks the **root** module tree only. A config 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. +solves; a config 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` @@ -163,12 +182,13 @@ 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); +export const httpConfig = Config("HttpConfig")( + { + port: port(3000), + maxConnections: wholeNumber(100, 1, 10_000), + }, + { prefix: "HTTP" }, +); ``` `wholeNumber(fallback, min, max)` reads a string, requires it non-empty, diff --git a/packages/config/package.json b/packages/config/package.json index 1913e37..85ae15b 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,7 +1,7 @@ { "name": "@btravstack/config", "version": "0.0.0", - "description": "Environment variables validated into typed config, wired through @btravstack/di as a port and its adapter", + "description": "Environment variables validated into typed config, wired through @btravstack/di as one port token and module", "keywords": [ "configuration", "dependency-injection", diff --git a/packages/config/src/collect.spec.ts b/packages/config/src/collect.spec.ts index c656b04..0b88b92 100644 --- a/packages/config/src/collect.spec.ts +++ b/packages/config/src/collect.spec.ts @@ -1,23 +1,21 @@ -import { Module, Port } from "@btravstack/di"; +import { Module } 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"; +import { Config } from "./slice.js"; const aShape = { x: z.string().default("a") }; -class APort extends Port("A")> {} -const A = Config(APort, "A")(aShape); +const A = Config("A")(aShape, { prefix: "A" }); const bShape = { y: z.string().default("b") }; -class BPort extends Port("B")> {} -const B = Config(BPort, "B")(bShape); +const B = Config("B")(bShape, { prefix: "B" }); 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] }); + const Inner = Module("Inner")({ imports: [A], exports: [A] }); + const Root = Module("Root")({ imports: [Inner, B], exports: [A, B] }); // WHEN the tree is walked // THEN both adapters are found, wherever they sit @@ -26,9 +24,9 @@ describe("collect", () => { 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] }); + const One = Module("One")({ imports: [A], exports: [A] }); + const Two = Module("Two")({ imports: [A], exports: [A] }); + const Root = Module("Root")({ imports: [One, Two], exports: [A] }); // WHEN the tree is walked // THEN the adapter appears once — it is parsed once, so it is reported once @@ -37,7 +35,7 @@ describe("collect", () => { 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] }); + const Root = Module("Root")({ imports: [A, B], exports: [A, B] }); // WHEN the tree is walked // THEN they come back in that same order — `describeIssues` promises one diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index b0c7273..1250b79 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -6,12 +6,13 @@ 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"; +export type { AnyConfigAdapter, ConfigType, 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`. + * `Config(id)(shape, options?)` returns one value that is both the port token + * and the di module that serves it from the environment; the namespaced + * operations are the kernel's, following `Module.build` / `Module.scoped` and + * `Port.many`. */ export const Config = Object.assign(declareConfig, { collect, diff --git a/packages/config/src/parse-all.spec.ts b/packages/config/src/parse-all.spec.ts index da614fb..844d59e 100644 --- a/packages/config/src/parse-all.spec.ts +++ b/packages/config/src/parse-all.spec.ts @@ -1,19 +1,16 @@ -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"; +import { Config } 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 Amqp = Config("Amqp")(amqpShape, { prefix: "AMQP" }); 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); +const Http = Config("Http")(httpShape, { prefix: "HTTP" }); describe("Config.parse", () => { it("accepts an environment every adapter agrees with", () => { @@ -51,8 +48,7 @@ describe("Config.parse", () => { validate: (value) => Promise.resolve({ value: String(value) }), }, }; - class AsyncPort extends Port("Async")<{ value: string }> {} - const Async = Config(AsyncPort, "ASYNC")({ value: asyncSchema }); + const Async = Config("Async")({ value: asyncSchema }, { prefix: "ASYNC" }); // WHEN it is validated through the public entry point // THEN the crash is a Defect, not a `ConfigInvalid` — and it never throws diff --git a/packages/config/src/slice.spec.ts b/packages/config/src/slice.spec.ts index cead2ca..c423cd0 100644 --- a/packages/config/src/slice.spec.ts +++ b/packages/config/src/slice.spec.ts @@ -1,4 +1,4 @@ -import { Module, Port, Provider } from "@btravstack/di"; +import { Module, Provider } from "@btravstack/di"; import { OkAsync } from "unthrown"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -7,26 +7,25 @@ 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); +const amqpConfig = Config("AmqpConfig")(amqpShape, { prefix: "AMQP" }); -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 +describe("a config", () => { + it("implements the port it declares, injected under its own identity", async () => { + // GIVEN a module importing the config as a module, and a source const App = Module("App")({ - imports: [AmqpConfigFromEnv, source({ AMQP_URL: "amqp://broker" })], - exports: [AmqpConfig], + imports: [amqpConfig, 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))); + 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 + // GIVEN a config whose validator counts how many times it actually runs let parses = 0; const countedShape = { url: z @@ -38,19 +37,18 @@ describe("a config adapter", () => { return value; }), }; - class CountedConfig extends Port("CountedConfig")> {} - const CountedConfigFromEnv = Config(CountedConfig, "COUNTED")(countedShape); + const countedConfig = Config("CountedConfig")(countedShape, { prefix: "COUNTED" }); - // AND two modules that both import that adapter - const A = Module("A")({ imports: [CountedConfigFromEnv], exports: [CountedConfig] }); - const B = Module("B")({ imports: [CountedConfigFromEnv], exports: [CountedConfig] }); + // AND two modules that both import that config + const A = Module("A")({ imports: [countedConfig], exports: [countedConfig] }); + const B = Module("B")({ imports: [countedConfig], exports: [countedConfig] }); const Both = Module("Both")({ imports: [A, B, source({ COUNTED_URL: "amqp://broker" })], - exports: [CountedConfig], + exports: [countedConfig], }); // WHEN the graph is built - const value = await Module.scoped(Both, (ctx) => OkAsync(ctx.get(CountedConfig))); + 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" }); @@ -58,37 +56,54 @@ describe("a config adapter", () => { }); it("names its module after its prefix, for a legible graph", () => { - // GIVEN an env adapter + // GIVEN a config // WHEN its module name is read - // THEN it says which configuration it is, not which port it implements - expect(AmqpConfigFromEnv.name).toBe("Config(AMQP)"); + // THEN it says which configuration it is, not which id it was declared under + expect(amqpConfig.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 + // GIVEN a lowercase explicit prefix — `Config(id, { prefix: "amqp" })` + // would derive `amqp_URL`, a variable no operator will ever set + // WHEN the config is declared // THEN the mistake is caught immediately, not left to fall back silently expect(() => - Config(AmqpConfig, "amqp")({ url: z.string().default("amqp://localhost") }), + Config("AmqpConfig")({ url: z.string().default("amqp://localhost") }, { prefix: "amqp" }), ).toThrow(/upper-snake-case/); }); + it("defaults its prefix to the screaming-snake form of its identity", () => { + // GIVEN a config declared with no `options.prefix` — a distinct id from + // `amqpConfig` above, so this doesn't also trip di's duplicate-id warning + const defaulted = Config("HttpConfig")({ port: z.string().default("3000") }); + + // WHEN the resolved prefix is read + // THEN it is the identity, shouted and underscored + expect(defaulted.prefix).toBe("HTTP_CONFIG"); + expect(defaulted.name).toBe("Config(HTTP_CONFIG)"); + }); + + it("uses an explicit prefix verbatim, not derived from the identity", () => { + // GIVEN a config declared with an explicit `options.prefix` + // WHEN the resolved prefix is read + // THEN it is exactly what was given, unrelated to the identity's own spelling + expect(amqpConfig.prefix).toBe("AMQP"); + }); + 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 strictConfig = Config("StrictConfig")(strictShape, { prefix: "STRICT" }); const App = Module("App")({ - imports: [StrictFromEnv, source({ STRICT_URL: "" })], - exports: [StrictConfig], + imports: [strictConfig, source({ STRICT_URL: "" })], + exports: [strictConfig], }); // WHEN the graph is built anyway - const built = await Module.scoped(App, (ctx) => OkAsync(ctx.get(StrictConfig))); + 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 @@ -96,20 +111,24 @@ describe("a config adapter", () => { 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` - const readUrl = (ctx: { get: (port: typeof AmqpConfig) => ValueOf }) => - OkAsync(ctx.get(AmqpConfig).url); + it("lets a config be provided as a literal, with no config module or environment involved", async () => { + // GIVEN a consumer that never imports `amqpConfig` as a module — only + // depends on it as a port token + 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 + // WHEN a test hands it a literal, ordinary di provider — no + // `Config.source`, no environment, and `amqpConfig` never appears in + // `imports:` anywhere in this graph const Fake = Module("Fake")({ - provides: [Provider(AmqpConfig)({ value: { url: "amqp://fake" } })], - exports: [AmqpConfig], + provides: [Provider(amqpConfig)({ value: { url: "amqp://fake" } })], + exports: [amqpConfig], }); - // THEN the same consumer resolves it unchanged: the port is adaptable + // THEN the same consumer resolves it unchanged: `amqpConfig` is a token + // like any other port, and its module statics (name/imports/provides/ + // exports) are only consulted when it appears in `imports:` — a graph + // that never puts it there never reaches them. 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 index d22837a..a755cc8 100644 --- a/packages/config/src/slice.test-d.ts +++ b/packages/config/src/slice.test-d.ts @@ -1,53 +1,45 @@ -import { Module, Port } from "@btravstack/di"; +import { Module } from "@btravstack/di"; import { z } from "zod"; -import { Config, type ValueOf } from "./index.js"; +import { Config, type ConfigType } 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); +const amqpConfig = Config("AmqpConfig")(shape, { prefix: "AMQP" }); -// The adapter is importable as a module, alongside the source it needs … +// The config is importable as a module, alongside the source it needs … const App = Module("App")({ - imports: [AmqpConfigFromEnv, Config.source({})], - exports: [AmqpConfig], + imports: [amqpConfig, Config.source({})], + exports: [amqpConfig], }); -// … and the port it implements is resolvable, with the shape's own output types. +// … and resolvable as the port it is, with the shape's own output types. void Module.scoped(App, (ctx) => { - const value = ctx.get(AmqpConfig); + 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 + // @ts-expect-error — `nope` is not a key of this config'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` +// `ConfigType` recovers the parsed shape without reaching for di's own +// `ServiceOf` — the same type `ctx.get(amqpConfig)` above resolves to. +type Amqp = ConfigType; +const fromType: Amqp = { url: "amqp://broker", prefetch: 5 }; +// @ts-expect-error — `nope` is not a key of the parsed shape +const badFromType: Amqp = { url: "amqp://broker", prefetch: 5, nope: true }; +void [fromType, badFromType]; + +// A module importing a config 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] }); +const Unsourced = Module("Unsourced")({ imports: [amqpConfig], exports: [amqpConfig] }); // @ts-expect-error — `ConfigSource` is unmet: no `Config.source(...)` import void Module.scoped(Unsourced, (ctx) => { - void ctx.get(AmqpConfig); + 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 index e2c35fb..eedf99c 100644 --- a/packages/config/src/slice.ts +++ b/packages/config/src/slice.ts @@ -1,11 +1,11 @@ -import { Module, Provider, type AnyPort, type ServiceOf } from "@btravstack/di"; +import { Module, Port, Provider, type ConcretePortClass, 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"; +import { assertValidPrefix, defaultPrefix } from "./variable.js"; /** The parsed value of a shape: each key's validator output. */ export type ValueOf = { @@ -13,7 +13,7 @@ export type ValueOf = { }; /** - * Marks a `Config(port, prefix)(shape)` result so `collect` (Task 4) can find + * Marks a `Config(id)(shape, options?)` 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 @@ -30,7 +30,7 @@ export type ValueOf = { * 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)`. + * same one regardless of which copy actually ran `Config(id)(shape, options?)`. */ export const CONFIG_ADAPTER = Symbol.for("btravstack/config/adapter"); @@ -46,12 +46,10 @@ export const CONFIG_ADAPTER = Symbol.for("btravstack/config/adapter"); export type AnyModule = { readonly imports: readonly AnyModule[] }; /** - * The structural type of what `Config(port, prefix)(shape)` returns, as + * The structural type of what `Config(id)(shape, options?)` 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. + * them off the value instead of threading them separately. */ export type AnyConfigAdapter = AnyModule & { readonly [CONFIG_ADAPTER]: true; @@ -60,36 +58,50 @@ export type AnyConfigAdapter = AnyModule & { }; /** - * `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. + * The parsed value's type for a declared config — di's own `ServiceOf` under + * this package's name, so a consumer can annotate `ConfigType` without reaching into `@btravstack/di` for a type that has + * nothing to do with wiring, only with what `Config` produced. + */ +export type ConfigType = ServiceOf; + +/** + * `Config(id)(shape, options?)` — curried on the identity so it reads next to + * the name it labels, then the field map, then an optional options object, + * mirroring `@btravstack/entity`'s `Entity(tag)(fields, options?)`. It + * returns ONE value that is both a port token — `ctx.get(amqpConfig)`, + * `Provider(amqpConfig)({ value: ... })` — and the di module a starter + * imports to serve that port from the environment: `imports: [amqpConfig]`. * - * `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. + * `options.prefix` names the environment variables' shared prefix; omitted, + * it defaults to the screaming-snake form of `id` (`"AmqpConfig"` → + * `"AMQP_CONFIG"`). Either way the *resolved* prefix is validated at + * declaration — see `assertValidPrefix`. */ export const Config = - (port: TPort, prefix: Prefix) => + (id: Id) => ( 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; - } => { + options?: { readonly prefix?: string }, + ): ConcretePortClass> & + Module>>, never, ConfigSource> & { + readonly [CONFIG_ADAPTER]: true; + readonly prefix: string; + readonly shape: S; + } => { + const prefix = options?.prefix ?? defaultPrefix(id); assertValidPrefix(prefix); - const provider = Provider(port)([ConfigSource], { + // The port half: `Port(id)` fixes the generic construct + // signature to this shape's parsed output — the same generic-heritage + // instantiation a starter would write by hand + // (`class X extends Port("X") {}`). Reusing it rather than + // hand-rolling an equivalent class keeps the duplicate-id warning + // `Port` already carries. + // oxlint-disable-next-line typescript/no-extraneous-class -- a port class is a phantom token; see port.ts + class ConfigPort extends Port(id)> {} + + const provider = Provider(ConfigPort)([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 @@ -111,48 +123,47 @@ export const Config = // 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, + // `ServiceOf`, not the seemingly-equivalent + // `ValueOf`: the `sync` arm's parameter type is generic in + // `ServiceOf

` (`P` still open here, inside `Provider`'s own + // signature), an unresolved conditional type that is not + // *textually* `ValueOf` even though the two describe the same + // shape — TypeScript compares the annotation as written, not its + // reduced form, so only naming the conditional itself satisfies it. + .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], + exports: [ConfigPort], }); - // 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, { + // Object.defineProperties, never Object.assign: a class's `name` is + // configurable but not writable, so `ConfigPort.name = adapter.name` + // either silently no-ops (sloppy mode) or throws (strict mode) instead of + // renaming it. The module statics land non-enumerable — the default + // descriptor — same as `CONFIG_ADAPTER`/`prefix`/`shape`: `collect` and + // `parseAll` reach all of these through the type, not through + // `Object.keys`/spreading the value. + Object.defineProperties(ConfigPort, { + name: { value: adapter.name, configurable: true }, + imports: { value: adapter.imports }, + provides: { value: adapter.provides }, + exports: { value: adapter.exports }, [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; - }; + // `as unknown as`: `Module`'s three phantom fields (`_exports`, `_error`, + // `_needs`) don't exist at runtime, so a direct `as` from the concrete + // `ConfigPort` class — which genuinely has none of them — is rejected. + // The object itself is exactly right at runtime; only the type needs + // restating here, back to what the port and shape actually are. + return ConfigPort as unknown as ConcretePortClass> & + Module>>, never, ConfigSource> & { + readonly [CONFIG_ADAPTER]: true; + readonly prefix: string; + readonly shape: S; + }; }; diff --git a/packages/config/src/variable.spec.ts b/packages/config/src/variable.spec.ts index f51cf01..174bb22 100644 --- a/packages/config/src/variable.spec.ts +++ b/packages/config/src/variable.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { assertValidPrefix, variableName } from "./variable.js"; +import { assertValidPrefix, defaultPrefix, variableName } from "./variable.js"; describe("assertValidPrefix", () => { it("rejects a prefix that is not upper-snake-case", () => { @@ -18,6 +18,15 @@ describe("assertValidPrefix", () => { }); }); +describe("defaultPrefix", () => { + it("shouts the identity, underscored at each word boundary", () => { + // GIVEN an identity with no explicit prefix + // WHEN the default prefix is derived + // THEN it is the screaming-snake form of that identity + expect(defaultPrefix("AmqpConfig")).toBe("AMQP_CONFIG"); + }); +}); + describe("variableName", () => { it("joins the prefix to a screaming-snake key", () => { // GIVEN a slice prefix and a camelCase key diff --git a/packages/config/src/variable.ts b/packages/config/src/variable.ts index 46a5d73..773ce66 100644 --- a/packages/config/src/variable.ts +++ b/packages/config/src/variable.ts @@ -1,13 +1,23 @@ const PREFIX_PATTERN = /^[A-Z][A-Z0-9_]*$/; +/** + * camelCase → SCREAMING_SNAKE_CASE. Shared by `variableName`'s key half and + * by `defaultPrefix`'s identity half — both are the same case conversion, + * applied to different strings. + */ +const screamingSnake = (s: string): string => + s.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); + /** * `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 + * that reports clean. Called from `Config(id)(shape, options?)` 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. + * schema throws at definition time. Applied to the *resolved* prefix — the + * explicit `options.prefix` when given, `defaultPrefix(id)` otherwise — so + * neither path can slip a lowercase or malformed prefix past declaration. */ export const assertValidPrefix = (prefix: string): void => { if (!PREFIX_PATTERN.test(prefix)) { @@ -18,10 +28,17 @@ export const assertValidPrefix = (prefix: string): void => { } }; +/** + * The prefix a slice gets when it declares no `options.prefix`: the + * screaming-snake form of its own identity. `"AmqpConfig"` → `"AMQP_CONFIG"`, + * so its `url` key reads `AMQP_CONFIG_URL`. + */ +export const defaultPrefix = (id: string): string => screamingSnake(id); + /** * 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()}`; + `${prefix}_${screamingSnake(key)}`; From 71a8b6799644572f2904934484676f7cbaa874cd Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 17:48:26 +0200 Subject: [PATCH 05/11] feat(config): name the type Config returns so a consumer can export one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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` 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) --- packages/config/src/index.ts | 2 +- packages/config/src/slice.ts | 53 ++++++++++++++++++++++++++---------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 1250b79..8f3880a 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -6,7 +6,7 @@ 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, ConfigType, ValueOf } from "./slice.js"; +export type { AnyConfigAdapter, ConfigType, DeclaredConfig, ValueOf } from "./slice.js"; /** * `Config(id)(shape, options?)` returns one value that is both the port token diff --git a/packages/config/src/slice.ts b/packages/config/src/slice.ts index eedf99c..bc1e57e 100644 --- a/packages/config/src/slice.ts +++ b/packages/config/src/slice.ts @@ -65,6 +65,42 @@ export type AnyConfigAdapter = AnyModule & { */ export type ConfigType = ServiceOf; +/** + * What `Config(id)(shape, options?)` returns: the port class, the di module + * that serves it, and the brand plus the `prefix`/`shape` that `collect` and + * `parseAll` read back off the value. + * + * **Named, and exported, for declaration emit.** Written inline as an + * intersection this was unwritable by a consumer: `export const amqpConfig = + * Config("Amqp")({ … })` has an inferred type, and a package compiled with + * `declaration` must be able to *write* that type down. Expanding the + * intersection reaches `InstanceType<…>`'s `[ID]`/`[SERVICE]` brands — which + * `@btravstack/di` deliberately keeps unexported, so a port instance stays + * unforgeable — and this file's own `CONFIG_ADAPTER`, so every such consumer + * failed with `TS4023: … has or is using name 'ID' … but cannot be named`. + * Annotating the return with one exported alias gives the emitter a name to + * stop at, exactly as di's own `ConcretePortClass` does one layer down, and + * grants no new power: the brand keys are still unnameable from outside. + * `examples/` is what proved this — nothing inside this package exports a + * declared config, so nothing inside it could have caught the gap. + * + * It closes the *declaring* half only. A module that **exports** a config — + * a composition root, which is every consumer of `start` — still emits + * `Module | …>`, and `InstanceType` reduces to + * di's `PortInstance`, which di keeps unexported on purpose so a port instance + * cannot be hand-forged. No alias here can name it; only `@btravstack/di` + * exporting the type would, and that is di's call. Until then a composition + * root carrying a config must be compiled with `declaration` off — which is + * what `examples/`'s deployment packages do, being source-only and emitting no + * declarations at all. + */ +export type DeclaredConfig = ConcretePortClass> & + Module>>, never, ConfigSource> & { + readonly [CONFIG_ADAPTER]: true; + readonly prefix: string; + readonly shape: S; + }; + /** * `Config(id)(shape, options?)` — curried on the identity so it reads next to * the name it labels, then the field map, then an optional options object, @@ -80,15 +116,7 @@ export type ConfigType = ServiceOf; */ export const Config = (id: Id) => - ( - shape: S, - options?: { readonly prefix?: string }, - ): ConcretePortClass> & - Module>>, never, ConfigSource> & { - readonly [CONFIG_ADAPTER]: true; - readonly prefix: string; - readonly shape: S; - } => { + (shape: S, options?: { readonly prefix?: string }): DeclaredConfig => { const prefix = options?.prefix ?? defaultPrefix(id); assertValidPrefix(prefix); @@ -160,10 +188,5 @@ export const Config = // `ConfigPort` class — which genuinely has none of them — is rejected. // The object itself is exactly right at runtime; only the type needs // restating here, back to what the port and shape actually are. - return ConfigPort as unknown as ConcretePortClass> & - Module>>, never, ConfigSource> & { - readonly [CONFIG_ADAPTER]: true; - readonly prefix: string; - readonly shape: S; - }; + return ConfigPort as unknown as DeclaredConfig; }; From 39c03ad022288a89b47d9b0a207c348ee113a8c0 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 17:51:13 +0200 Subject: [PATCH 06/11] refactor(examples): declare configuration with @btravstack/config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- examples/order-amqp-worker/package.json | 3 +- .../order-amqp-worker/src/amqp-runtime.ts | 87 +++++++++------ examples/order-amqp-worker/src/config.ts | 29 +++++ examples/order-amqp-worker/src/env.spec.ts | 66 ----------- examples/order-amqp-worker/src/env.ts | 33 ------ examples/order-amqp-worker/src/main.ts | 63 +++++++---- examples/order-amqp-worker/src/module.ts | 40 +++++-- .../src/needs-gate.test-d.ts | 33 ++++-- .../order-amqp-worker/src/outbox-relay.ts | 65 +++++++---- .../order-amqp-worker/src/test-fixtures.ts | 63 +++++++---- examples/order-amqp-worker/tsconfig.json | 10 ++ examples/order-api/package.json | 3 +- examples/order-api/src/api-runtime.ts | 46 ++++++++ examples/order-api/src/config.ts | 32 ++++++ examples/order-api/src/env.spec.ts | 46 -------- examples/order-api/src/env.ts | 30 ----- examples/order-api/src/main.ts | 59 ++++++---- examples/order-api/src/module.ts | 28 ++++- examples/order-api/src/needs-gate.test-d.ts | 35 +++--- examples/order-api/tsconfig.json | 10 ++ examples/order-temporal-worker/package.json | 3 +- examples/order-temporal-worker/src/config.ts | 37 ++++++ .../order-temporal-worker/src/env.spec.ts | 55 --------- examples/order-temporal-worker/src/env.ts | 36 ------ examples/order-temporal-worker/src/main.ts | 102 ++++++++++------- examples/order-temporal-worker/src/module.ts | 32 +++++- .../src/needs-gate.test-d.ts | 16 +-- .../src/temporal-runtime.ts | 105 +++++++++++------- .../src/test-fixtures.ts | 28 +++-- examples/order-temporal-worker/tsconfig.json | 10 ++ 30 files changed, 673 insertions(+), 532 deletions(-) create mode 100644 examples/order-amqp-worker/src/config.ts delete mode 100644 examples/order-amqp-worker/src/env.spec.ts delete mode 100644 examples/order-amqp-worker/src/env.ts create mode 100644 examples/order-api/src/api-runtime.ts create mode 100644 examples/order-api/src/config.ts delete mode 100644 examples/order-api/src/env.spec.ts delete mode 100644 examples/order-api/src/env.ts create mode 100644 examples/order-temporal-worker/src/config.ts delete mode 100644 examples/order-temporal-worker/src/env.spec.ts delete mode 100644 examples/order-temporal-worker/src/env.ts diff --git a/examples/order-amqp-worker/package.json b/examples/order-amqp-worker/package.json index 2af7acb..9b9cc20 100644 --- a/examples/order-amqp-worker/package.json +++ b/examples/order-amqp-worker/package.json @@ -17,15 +17,14 @@ "dependencies": { "@amqp-contract/client": "catalog:", "@amqp-contract/worker": "catalog:", + "@btravstack/config": "workspace:*", "@btravstack/di": "catalog:", "@btravstack/start-amqp": "workspace:*", "@btravstack/start-core": "workspace:*", "@btravstack/start-example-order-amqp-contract": "workspace:*", "@btravstack/start-example-order-application": "workspace:*", - "@btravstack/start-example-order-config": "workspace:*", "@btravstack/start-example-order-infrastructure": "workspace:*", "@opentelemetry/api": "catalog:", - "@unthrown/standard-schema": "catalog:", "unthrown": "catalog:", "zod": "catalog:" }, diff --git a/examples/order-amqp-worker/src/amqp-runtime.ts b/examples/order-amqp-worker/src/amqp-runtime.ts index fc6753a..1ecca4d 100644 --- a/examples/order-amqp-worker/src/amqp-runtime.ts +++ b/examples/order-amqp-worker/src/amqp-runtime.ts @@ -10,18 +10,29 @@ import { orderContract, type OrderContract } from "@btravstack/start-example-ord import { Logger, Outbox } from "@btravstack/start-example-order-application"; import { OkAsync } from "unthrown"; -import { startOutboxRelay, type RelayOptions } from "./outbox-relay.js"; +import { amqpConfig } from "./config.js"; +import { outboxRelayConfig, startOutboxRelay } from "./outbox-relay.js"; /** * The ports this runtime resolves out of the application context: `Outbox` - * for the relay half, `Logger` for both halves. `PlaceOrder` is deliberately + * for the relay half, `Logger` for both halves, and the two configs that say + * which broker to reach and how often to sweep. `PlaceOrder` is deliberately * absent — nothing on a broadcast consumes a command, and a runtime declares * what *it* needs rather than what the module happens to export. * + * A config is a need like any other port, which is the point: `start` proves + * at the call site that the graph carries them, so a deployment that forgot to + * import `amqpConfig` fails to compile rather than to boot. + * + * One array, two uses — the union below is read off it, so the declared needs + * and the type the handlers see cannot drift apart. + * * Non-empty on purpose: it is what makes `start`'s arity gate mean something * (`src/needs-gate.test-d.ts` pins both directions). */ -type AmqpNeeds = typeof Outbox | typeof Logger; +const amqpNeeds = [Outbox, Logger, amqpConfig, outboxRelayConfig] as const; + +type AmqpNeeds = (typeof amqpNeeds)[number]; /** * A `Runtime` broadcasting the order application's facts over AMQP — both @@ -39,45 +50,47 @@ type AmqpNeeds = typeof Outbox | typeof Logger; * published yet are *safer* published during the drain window than abandoned * to the next boot. It stops at `stop`, before the consumer's transport goes. * - * The contract is **imported, not a parameter**: this deployment implements - * exactly one, and every caller would pass the same `orderContract` constant. + * It takes **no arguments at all**, which is the point worth copying. The + * broker URL and the relay's sweep interval used to be parameters, threaded in + * from `main.ts` after it had read the environment. They are configuration, + * and a runtime is handed a `Context` at `start` — so it reads them itself, + * out of the same graph everything else comes from. `main.ts` no longer knows + * what a broker URL is, and a spec swaps the broker by swapping a module. + * + * The contract is **imported, not a parameter**, for the same kind of reason + * turned the other way: this deployment implements exactly one, and every + * caller would pass the same `orderContract` constant. * (`order-temporal-worker`'s runtime does take one, because its specs pass a * genuinely different value — `withTaskQueue(orderContract, …)` scopes each * test to its own task queue. Here the specs get their isolation from a - * per-test vhost in the URL, so nothing varies and the parameter would be - * ceremony.) + * per-test vhost in the URL, which is now a config value, so nothing varies + * and the parameter would be ceremony.) */ -export const orderAmqpRuntime = ({ - relay, - ...transport -}: { - /** The broker URLs the worker and the relay both connect to. */ - readonly urls: readonly string[]; - /** The relay's own knobs. */ - readonly relay: Pick; -}): Runtime => { - const consumer = amqpRuntime({ - ...transport, - contract: orderContract, - needs: [Outbox, Logger], - handlers: () => ({ orderChanged: notifyHandler(orderContract) }), - middleware: (host) => messageUnits(host), - }); +export const orderAmqpRuntime = (): Runtime => ({ + // Stated here rather than forwarded from the consumer, because the consumer + // does not exist yet: its broker URL is configuration, and configuration is + // only readable once the graph has been built and handed this runtime a + // context — which is exactly what `name` and `needs` must be answerable + // *before*. `"amqp"` is `@btravstack/start-amqp`'s own name for itself. + name: "amqp", + needs: amqpNeeds, + start: (host) => { + const consumer = amqpRuntime({ + urls: [host.ctx.get(amqpConfig).url], + contract: orderContract, + needs: amqpNeeds, + handlers: () => ({ orderChanged: notifyHandler(orderContract) }), + middleware: (h) => messageUnits(h), + }); - return { - name: consumer.name, - needs: consumer.needs, - start: (host) => - consumer.start(host).flatMap((serving) => - startOutboxRelay(host.ctx, { urls: transport.urls, pollMs: relay.pollMs }).map( - (running) => ({ - ...serving, - stop: () => running.stop().flatMap(() => serving.stop()), - }), - ), - ), - }; -}; + return consumer.start(host).flatMap((serving) => + startOutboxRelay(host.ctx).map((running) => ({ + ...serving, + stop: () => running.stop().flatMap(() => serving.stop()), + })), + ); + }, +}); /** * The consuming half's one handler — a subscriber like any other service diff --git a/examples/order-amqp-worker/src/config.ts b/examples/order-amqp-worker/src/config.ts new file mode 100644 index 0000000..cfcfc33 --- /dev/null +++ b/examples/order-amqp-worker/src/config.ts @@ -0,0 +1,29 @@ +import { Config } from "@btravstack/config"; +import { port } from "@btravstack/config/zod"; +import { z } from "zod"; + +/** + * The broker this worker consumes from — and the relay publishes to. + * + * One value that is both the port token and the module serving it: `imports: + * [amqpConfig]` provides it from the environment, `ctx.get(amqpConfig)` reads + * it back. `Config("Amqp")` derives its prefix from its own identity, so `url` + * reads `AMQP_URL` — the same variable this deployment has always taken. + */ +export const amqpConfig = Config("Amqp")({ + url: z.string().min(1).default("amqp://127.0.0.1:5672"), +}); + +/** + * The probe port's default, named because `main.ts` needs it a second time — + * see the comment there on why the kernel's own port cannot come out of the + * graph in phase 1. + */ +export const PROBE_PORT_DEFAULT = 9000; + +/** + * `/livez` and `/readyz`. Nothing resolves this port: it is declared so that + * `PROBE_PORT` is validated by the same pre-boot pass as every other variable, + * and reported in the same message when it is wrong. + */ +export const probeConfig = Config("Probe")({ port: port(PROBE_PORT_DEFAULT) }); diff --git a/examples/order-amqp-worker/src/env.spec.ts b/examples/order-amqp-worker/src/env.spec.ts deleted file mode 100644 index 4c7d10a..0000000 --- a/examples/order-amqp-worker/src/env.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { readEnv } from "./env.js"; - -// The seven cases the shared `port` fragment has to survive are pinned once, in -// `order-config`. What is this deployment's own is its one string variable, -// which is not a number and has its own emptiness rule. -describe("readEnv", () => { - it("falls back to the documented defaults when nothing is set", () => { - // GIVEN an environment with neither variable set - const source = {}; - - // WHEN it is validated - const env = readEnv(source); - - // THEN all three carry their defaults, the numbers as numbers - expect(env).toBeOkWith({ - PROBE_PORT: 9000, - AMQP_URL: "amqp://127.0.0.1:5672", - OUTBOX_POLL_MS: 200, - }); - }); - - it("reads what a deployment actually supplies", () => { - // GIVEN all three set, as the strings an environment always holds - const source = { - PROBE_PORT: "0", - AMQP_URL: "amqp://broker.internal:5672", - OUTBOX_POLL_MS: "50", - }; - - // WHEN it is validated - const env = readEnv(source); - - // THEN they arrive parsed, and `0` survives as the ephemeral bind it is - expect(env).toBeOkWith({ - PROBE_PORT: 0, - AMQP_URL: "amqp://broker.internal:5672", - OUTBOX_POLL_MS: 50, - }); - }); - - it("rejects a broker URL that is present but empty, rather than defaulting it", () => { - // GIVEN a URL set to nothing — the string variable's version of the - // blank-value rule the numeric fragment enforces with `.min(1)` up front - const source = { AMQP_URL: "" }; - - // WHEN it is validated - const env = readEnv(source); - - // THEN it is a configuration error rather than an absent variable: a - // worker that silently defaults its broker is a worker consuming nothing - expect(env).toBeErrWith([expect.objectContaining({ path: ["AMQP_URL"] })]); - }); - - it("rejects a poll interval of zero where a port's own bounds would allow it", () => { - // GIVEN a sweep interval that would spin the relay hot - const source = { OUTBOX_POLL_MS: "0" }; - - // WHEN it is validated - const env = readEnv(source); - - // THEN the deployment's own lower bound speaks, not the shared fragment's - expect(env).toBeErrWith([expect.objectContaining({ path: ["OUTBOX_POLL_MS"] })]); - }); -}); diff --git a/examples/order-amqp-worker/src/env.ts b/examples/order-amqp-worker/src/env.ts deleted file mode 100644 index fa1e80f..0000000 --- a/examples/order-amqp-worker/src/env.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describeEnvIssues, port, wholeNumber } from "@btravstack/start-example-order-config"; -import { fromSchema, type SchemaIssues } from "@unthrown/standard-schema"; -import type { Result } from "unthrown"; -import { z } from "zod"; - -const environment = z.object({ - PROBE_PORT: port(9000), - /** The broker this worker consumes from — and the relay publishes to. */ - AMQP_URL: z.string().min(1).default("amqp://127.0.0.1:5672"), - /** The relay's idle sleep between outbox sweeps, in milliseconds. */ - OUTBOX_POLL_MS: wholeNumber(200, 1, 60_000), -}); - -/** The validated environment: every field present, typed, and in range. */ -export type Env = z.infer; - -// `fromSchema` is CURRIED — it takes the schema and hands back the validator. -const validate = fromSchema(environment); - -/** - * Validates the process environment **as a value**. - * - * A schema's own `.parse()` throws, which `unthrown/no-throw` bans and which - * would contradict the example it appears in. `@unthrown/standard-schema` makes - * the issues the modeled `E`, so the entry point folds a bad environment the - * same way it folds any other anticipated failure. `port` and the issue - * formatter are the shared ones — see `order-config` for why the non-empty - * string in front of the coercion is load-bearing. - */ -export const readEnv = (source: typeof process.env = process.env): Result => - validate(source); - -export { describeEnvIssues }; diff --git a/examples/order-amqp-worker/src/main.ts b/examples/order-amqp-worker/src/main.ts index ce3ed2e..ff67a95 100644 --- a/examples/order-amqp-worker/src/main.ts +++ b/examples/order-amqp-worker/src/main.ts @@ -1,31 +1,26 @@ +import { Config, describeIssues } from "@btravstack/config"; import { runMain, start } from "@btravstack/start-core"; import { P } from "unthrown"; import { orderAmqpRuntime } from "./amqp-runtime.js"; -import { describeEnvIssues, readEnv, type Env } from "./env.js"; +import { PROBE_PORT_DEFAULT } from "./config.js"; import { OrderAmqpModule } from "./module.js"; /** * The broadcast process, and — apart from the runtime it names — the same - * shape every deployment's `main.ts` is: validate the environment, build the - * graph, serve it, and turn the exit report into a process exit code. No connection - * dance here — `TypedAmqpWorker` owns its own connection, so unlike - * `order-temporal-worker`'s `main.ts` there is nothing to open before `start` and - * nothing to close after it. + * shape every deployment's `main.ts` is: validate the configuration, build the + * graph, serve it, and turn the exit report into a process exit code. No + * connection dance here — `TypedAmqpWorker` owns its own connection, so unlike + * `order-temporal-worker`'s `main.ts` there is nothing to open before `start` + * and nothing to close after it. + * + * There is no `env.ts` any more, and nothing is threaded into the runtime: the + * broker URL and the relay's sweep interval are configs the graph provides and + * the runtime reads for itself. * * Typechecked by the gate, not executed by it — the example packages are * source-only, and every spec drives `start` directly. */ -const work = (env: Env): Promise => - runMain( - start(OrderAmqpModule, { - runtime: orderAmqpRuntime({ - urls: [env.AMQP_URL], - relay: { pollMs: env.OUTBOX_POLL_MS }, - }), - probes: { port: env.PROBE_PORT }, - }), - ); /** sysexits(3) `EX_CONFIG`: the deployment is wrong, not the code. */ const abort = (reason: string): void => { @@ -33,9 +28,35 @@ const abort = (reason: string): void => { process.exitCode = 78; }; -await readEnv().match({ - ok: work, - // oxlint-disable-next-line unthrown/no-catch-all-pattern -- `E` is the issues array: one type with no discriminant, so there is nothing to enumerate and the single arm IS the enumeration - errCases: (matcher) => matcher.with(P._, (issues) => abort(describeEnvIssues(issues))), - defect: (cause) => abort(`the environment could not be validated: ${String(cause)}`), +// The one value this deployment still reads out of `process.env` by hand. +// `PROBE_PORT` is declared in `probeConfig` and validated below with every +// other variable, so by the time this is used it is known to be a whole number +// in range — but `start` binds the probe server *before* it builds the graph, +// 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); + +// One report for the whole graph. `Config.collect` walks the module tree for +// every config reachable from the root — this deployment's three, and any a +// library it imports declares — and `Config.parse` validates all of them +// against one source, aggregating every wrong variable into a single +// `ConfigInvalid`. An operator who mistyped three of them learns all three +// from this boot, instead of one per deploy. +// +// `ConfigInvalid` is a `TaggedError`, so the matcher names it. The fold this +// replaced reported one schema's issues and had to reach for `P._` behind a +// lint-disable, because a `SchemaIssues` array is a single type with no +// discriminant and so nothing to enumerate. +await Config.parse(Config.collect(OrderAmqpModule), process.env).match({ + ok: () => + runMain( + start(OrderAmqpModule, { + runtime: orderAmqpRuntime(), + probes: { port: probePort }, + }), + ), + errCases: (matcher) => + matcher.with(P.tag("config/ConfigInvalid"), (error) => abort(describeIssues(error.issues))), + defect: (cause) => abort(`the configuration could not be validated: ${String(cause)}`), }); diff --git a/examples/order-amqp-worker/src/module.ts b/examples/order-amqp-worker/src/module.ts index 4898a9b..0c19304 100644 --- a/examples/order-amqp-worker/src/module.ts +++ b/examples/order-amqp-worker/src/module.ts @@ -1,3 +1,4 @@ +import { Config } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { ApplicationModule, @@ -8,22 +9,43 @@ import { } from "@btravstack/start-example-order-application"; import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; +import { amqpConfig, probeConfig } from "./config.js"; +import { outboxRelayConfig } from "./outbox-relay.js"; + /** * The composition root of the broadcast deployment. `ApplicationModule` and * `PersistenceModule` are booted here unchanged — the same pair every other * deployment composes — under a runtime that relays the outbox onto a broker * and consumes the broadcast back. * - * The exports are this deployment's own selection: `Outbox` and `Logger` are - * what the runtime needs, and `PlaceOrder` / `OrderRepository` are the writer's - * surface — what a writer in the same process (the specs; in production, - * `order-api` against the same database) places and cancels orders through. - * Both write paths leave the outbox an event, which is the property this - * deployment exists to demonstrate. Declared here rather than imported from a - * sibling because sharing a composition root would share its transport + * The three configs are imported like any other module, and + * `Config.source(process.env)` is the one place the environment enters the + * graph: a config never reads `process.env` itself, so `Config.parse`'s + * pre-boot check and the providers that inject the values cannot disagree + * about what the environment was. A spec imports its own record instead + * (`src/test-fixtures.ts`), and nothing below it can tell the difference. + * + * `probeConfig` is imported but not exported: nothing in the graph resolves + * it, and it is here so that `PROBE_PORT` is validated by the same pre-boot + * pass as every other variable. + * + * The exports are this deployment's own selection: `Outbox`, `Logger` and the + * two configs are what the runtime needs, and `PlaceOrder` / `OrderRepository` + * are the writer's surface — what a writer in the same process (the specs; in + * production, `order-api` against the same database) places and cancels orders + * through. Both write paths leave the outbox an event, which is the property + * this deployment exists to demonstrate. Declared here rather than imported + * from a sibling because sharing a composition root would share its transport * dependency — one application, one root per process. */ export const OrderAmqpModule = Module("OrderAmqp")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, OrderRepository, Outbox, Logger], + imports: [ + ApplicationModule, + PersistenceModule, + amqpConfig, + outboxRelayConfig, + probeConfig, + Config.source(process.env), + ], + exports: [PlaceOrder, OrderRepository, Outbox, Logger, amqpConfig, outboxRelayConfig], }); diff --git a/examples/order-amqp-worker/src/needs-gate.test-d.ts b/examples/order-amqp-worker/src/needs-gate.test-d.ts index ea807c4..a1dedef 100644 --- a/examples/order-amqp-worker/src/needs-gate.test-d.ts +++ b/examples/order-amqp-worker/src/needs-gate.test-d.ts @@ -1,42 +1,51 @@ /** * The compile-time half of the broadcast deployment: `orderAmqpRuntime` - * declares two ports in `needs`, and `start`'s phantom rest-tuple gate turns a - * module that does not export both into a call-site arity error. Type-checked - * by this package's `test:types` script, never executed. + * declares four ports in `needs` — two application services and two configs — + * and `start`'s phantom rest-tuple gate turns a module that does not export + * them all into a call-site arity error. Type-checked by this package's + * `test:types` script, never executed. * * Together with `order-api`'s and `order-temporal-worker`'s, this is what * makes the claim testable rather than asserted: runtimes with non-empty * `needs`, all proven against the same application graph at the `start(...)` - * call site. + * call site. Configuration is inside that proof now, not beside it: a + * deployment that forgets to import `amqpConfig` fails here, not at boot. */ +import { Config } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { start } from "@btravstack/start-core"; import { ApplicationModule, Logger, PlaceOrder } from "@btravstack/start-example-order-application"; import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; import { orderAmqpRuntime } from "./amqp-runtime.js"; +import { amqpConfig } from "./config.js"; import { OrderAmqpModule } from "./module.js"; +import { outboxRelayConfig } from "./outbox-relay.js"; const options = { - runtime: orderAmqpRuntime({ - urls: ["amqp://127.0.0.1:5672"], - relay: { pollMs: 200 }, - }), + runtime: orderAmqpRuntime(), signals: false, probes: false, } as const; -// Positive: the composition root exports both ports the runtime needs (and a +// Positive: the composition root exports every port the runtime needs (and a // writer's port it does not), so the gate collapses to an empty tuple and this // is an ordinary two-argument call. const _wired = start(OrderAmqpModule, options); // The same graph, one port short: `Outbox` is provided (the persistence layer // carries it) but not exported, so it is not in the application context the -// runtime is handed. +// runtime is handed. Both configs *are* exported, so the gate can only be +// answering about the one genuinely missing port. const PartialAmqp = Module("PartialAmqp")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, Logger], + imports: [ + ApplicationModule, + PersistenceModule, + amqpConfig, + outboxRelayConfig, + Config.source(process.env), + ], + exports: [PlaceOrder, Logger, amqpConfig, outboxRelayConfig], }); // Negative: the gate becomes a required two-element tuple naming the unmet need, diff --git a/examples/order-amqp-worker/src/outbox-relay.ts b/examples/order-amqp-worker/src/outbox-relay.ts index bae3d7d..b162c7b 100644 --- a/examples/order-amqp-worker/src/outbox-relay.ts +++ b/examples/order-amqp-worker/src/outbox-relay.ts @@ -1,18 +1,38 @@ import { TypedAmqpClient } from "@amqp-contract/client"; +import { Config } from "@btravstack/config"; +import { wholeNumber } from "@btravstack/config/zod"; import type { Context } from "@btravstack/di"; import { orderContract } from "@btravstack/start-example-order-amqp-contract"; import { Logger, Outbox } from "@btravstack/start-example-order-application"; import { P, fromSafePromise, type AsyncResult } from "unthrown"; -/** The ports the relay resolves out of the application context. */ -export type RelayNeeds = typeof Outbox | typeof Logger; +import { amqpConfig } from "./config.js"; -export type RelayOptions = { - /** The broker URLs the relay's own client connects to. */ - readonly urls: readonly string[]; - /** How long to sleep when a sweep finds the outbox empty. */ - readonly pollMs: number; -}; +/** + * The relay's own knob, declared next to the loop it tunes rather than in a + * central file every consumer has to know about: the shape, the bound and the + * default live with the code that gives them meaning. + * + * The identity and the prefix differ on purpose — the thing is the outbox + * relay, the variable an operator sets is `OUTBOX_POLL_MS`, and + * `options.prefix` is what lets both stay true at once. + */ +export const outboxRelayConfig = Config("OutboxRelay")( + { pollMs: wholeNumber(200, 1, 60_000) }, + { prefix: "OUTBOX" }, +); + +/** + * The ports the relay resolves out of the application context: the two + * application services, and the two configs that tune it. Configuration + * arrives the same way `Outbox` does — through di — rather than threaded down + * from `main.ts` as constructor arguments. + */ +export type RelayNeeds = + | typeof Outbox + | typeof Logger + | typeof amqpConfig + | typeof outboxRelayConfig; /** How many outbox rows one sweep publishes before sleeping. */ const BATCH = 32; @@ -36,28 +56,33 @@ const BATCH = 32; * * **Why the client is created here rather than injected as a port, and why * this is not a di provider.** A transport connection is a *runtime* concern - * in this repo, configured from the environment in `main.ts`: `start-amqp` - * creates its own `TypedAmqpWorker` inside `Runtime.start` from the same - * `urls`, and `order-temporal-worker`'s `main.ts` opens its `NativeConnection` - * the same way. Only `OrderDatabase` is a resourceful provider, because the - * *application* depends on it — the repository cannot be built without one. - * Nothing in the application graph depends on this publisher, and a provider - * exists to be resolved by someone. + * in this repo: `start-amqp` creates its own `TypedAmqpWorker` inside + * `Runtime.start` from the same URL, and `order-temporal-worker`'s `main.ts` + * opens its `NativeConnection` itself. Only `OrderDatabase` is a resourceful + * provider, because the *application* depends on it — the repository cannot be + * built without one. Nothing in the application graph depends on this + * publisher, and a provider exists to be resolved by someone. + * + * The *address* it connects to is a different question, and the answer is di: + * `ctx.get(amqpConfig)` reads the same value the consumer half reads, so the + * two halves cannot be pointed at different brokers by a threading mistake in + * `main.ts`. * * It is not a second connection, either: `@amqp-contract/core`'s * `ConnectionManagerSingleton` pools by URL and reference-counts leases, so * this client and the consumer's worker share one TCP connection and - * `client.close()` releases a lease rather than closing the socket. What the - * relay *does* take from di is everything the application owns — `Outbox` and - * `Logger`, resolved from `ctx` — which is the boundary that matters. + * `client.close()` releases a lease rather than closing the socket. */ export const startOutboxRelay = ( ctx: Context>, - { urls, pollMs }: RelayOptions, ): AsyncResult<{ readonly stop: () => AsyncResult }, never> => - TypedAmqpClient.create({ contract: orderContract, urls: [...urls] }).map((client) => { + TypedAmqpClient.create({ + contract: orderContract, + urls: [ctx.get(amqpConfig).url], + }).map((client) => { const outbox = ctx.get(Outbox); const logger = ctx.get(Logger); + const { pollMs } = ctx.get(outboxRelayConfig); let stopped = false; let wake: (() => void) | undefined; diff --git a/examples/order-amqp-worker/src/test-fixtures.ts b/examples/order-amqp-worker/src/test-fixtures.ts index 7bfd92a..d2232ef 100644 --- a/examples/order-amqp-worker/src/test-fixtures.ts +++ b/examples/order-amqp-worker/src/test-fixtures.ts @@ -1,28 +1,38 @@ import { it as amqpIt } from "@amqp-contract/testing"; import type { AmqpTestFixtures } from "@amqp-contract/testing/extension"; +import { Config } from "@btravstack/config"; import { Module, Port, Provider, type Scope, type ServiceOf } from "@btravstack/di"; import type { AmqpInfo } from "@btravstack/start-amqp"; import { start, type RunningApp } from "@btravstack/start-core"; import { + ApplicationModule, Logger, OrderRepository, Outbox, PlaceOrder, } from "@btravstack/start-example-order-application"; +import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; import { expect, type TestAPI } from "vitest"; import { orderAmqpRuntime } from "./amqp-runtime.js"; -import { OrderAmqpModule } from "./module.js"; +import { amqpConfig } from "./config.js"; +import { outboxRelayConfig } from "./outbox-relay.js"; type App = RunningApp; /** - * `X` is pinned to the three ports the composition root exports rather than - * left generic: `start`'s needs gate is a phantom rest parameter proven at the - * call site, and no proof is available inside a helper generic in the module's - * own exports. The runtime needs two of them; `PlaceOrder` is the writer's. + * `X` is pinned to the ports the composition root exports rather than left + * generic: `start`'s needs gate is a phantom rest parameter proven at the call + * site, and no proof is available inside a helper generic in the module's own + * exports. The runtime needs four of them; `PlaceOrder` is the writer's. */ -type AmqpPorts = PlaceOrder | OrderRepository | Outbox | Logger; +type AmqpPorts = + | PlaceOrder + | OrderRepository + | Outbox + | Logger + | InstanceType + | InstanceType; type ServeOptions = { readonly drainTimeoutMs: number }; @@ -43,12 +53,32 @@ class ServicesTap extends Port("ServicesTap")<{ readonly logger: ServiceOf; }> {} -const tappedAmqp = () => { +/** + * The composition root `OrderAmqpModule` is, with one import swapped: this + * test's own `Config.source` in place of `Config.source(process.env)`. The + * broker is a per-test vhost and the relay's sweep is tight enough for a test + * clock, and neither is a `Provider` stub — the configs parse their own + * declared shapes, from a record a spec wrote instead of one the operating + * system did. + * + * Spelled out rather than layered over `OrderAmqpModule`, because two + * providers for one port is a wiring bug in di: a graph gets exactly one + * `ConfigSource`, and this is the one. + */ +const tappedAmqp = (url: string) => { let services: ServiceOf | undefined; return { module: Module("TappedAmqp")({ - imports: [OrderAmqpModule], + imports: [ + ApplicationModule, + PersistenceModule, + amqpConfig, + outboxRelayConfig, + // Tight on purpose: the specs wait on real broker round trips, and a + // production-sized idle sleep would be most of every test's clock. + Config.source({ AMQP_URL: url, OUTBOX_POLL_MS: "25" }), + ], provides: [ Provider(ServicesTap)([PlaceOrder, OrderRepository, Outbox, Logger], { sync: (placeOrder, repository, outbox, logger) => { @@ -57,7 +87,7 @@ const tappedAmqp = () => { }, }), ], - exports: [PlaceOrder, OrderRepository, Outbox, Logger], + exports: [PlaceOrder, OrderRepository, Outbox, Logger, amqpConfig, outboxRelayConfig], }), services: (): ServiceOf => { // oxlint-disable-next-line unthrown/no-throw -- a fixture misused before `serve` is a broken test, and the loudest possible answer is the right one @@ -82,17 +112,13 @@ export type AmqpFixtures = { // since `AmqpTestFixtures` reaches back into amqplib's `Channel` / // `ChannelModel` / `ConsumeMessage` / `Options.Publish`. export const it: TestAPI = amqpIt.extend({ - serve: async ({ amqpConnectionUrl }, use) => { + // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; the broker URL now reaches the runtime through `tapped`'s config, not through here + serve: async ({}, use) => { const shutdowns: (() => Promise)[] = []; const serve: Serve = async (module, options) => { const app = start(module, { - runtime: orderAmqpRuntime({ - urls: [amqpConnectionUrl], - // Tight on purpose: the specs wait on real broker round trips, and - // a production-sized idle sleep would be most of every test's clock. - relay: { pollMs: 25 }, - }), + runtime: orderAmqpRuntime(), signals: false, probes: false, preDrainDelayMs: 0, @@ -113,8 +139,7 @@ export const it: TestAPI = amqpIt.extend { - await use(tappedAmqp()); + tapped: async ({ amqpConnectionUrl }, use) => { + await use(tappedAmqp(amqpConnectionUrl)); }, }); diff --git a/examples/order-amqp-worker/tsconfig.json b/examples/order-amqp-worker/tsconfig.json index 3faf372..b414cfc 100644 --- a/examples/order-amqp-worker/tsconfig.json +++ b/examples/order-amqp-worker/tsconfig.json @@ -1,6 +1,16 @@ { "extends": "@btravstack/tsconfig/base.json", "compilerOptions": { + // Off, with `declarationMap`, because this package emits no declarations + // at all — it is source-only, `main` points at `src/`, and nothing ever + // consumes a `.d.ts` of it. The check they enable is real elsewhere and + // unmeetable here: a composition root that exports a `@btravstack/config` + // value emits `Module | …>`, which reduces + // to `@btravstack/di`'s `PortInstance` — deliberately unexported, so a port + // instance cannot be hand-forged, and therefore unnameable by an emitted + // declaration. See `packages/config/src/slice.ts`'s `DeclaredConfig`. + "declaration": false, + "declarationMap": false, "noEmit": true, "types": ["node"], // Inherited from the persistence layer this package composes: the generated diff --git a/examples/order-api/package.json b/examples/order-api/package.json index 56b7425..cc3e2b7 100644 --- a/examples/order-api/package.json +++ b/examples/order-api/package.json @@ -15,18 +15,17 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { + "@btravstack/config": "workspace:*", "@btravstack/di": "catalog:", "@btravstack/start-core": "workspace:*", "@btravstack/start-example-order-api-contract": "workspace:*", "@btravstack/start-example-order-application": "workspace:*", - "@btravstack/start-example-order-config": "workspace:*", "@btravstack/start-example-order-domain": "workspace:*", "@btravstack/start-example-order-infrastructure": "workspace:*", "@btravstack/start-http": "workspace:*", "@orpc/client": "catalog:", "@orpc/server": "catalog:", "@unthrown/orpc": "catalog:", - "@unthrown/standard-schema": "catalog:", "unthrown": "catalog:", "zod": "catalog:" }, diff --git a/examples/order-api/src/api-runtime.ts b/examples/order-api/src/api-runtime.ts new file mode 100644 index 0000000..93009c9 --- /dev/null +++ b/examples/order-api/src/api-runtime.ts @@ -0,0 +1,46 @@ +import type { Runtime } from "@btravstack/start-core"; +import { FindOrder, Logger, PlaceOrder } from "@btravstack/start-example-order-application"; +import { httpRuntime, type HttpInfo } from "@btravstack/start-http"; + +import { httpConfig } from "./config.js"; +import { apiHandler } from "./handler.js"; + +/** + * The ports this deployment's runtime resolves out of the application context: + * the three the handler reaches, plus the config that says which port to bind. + * + * One array, two uses — the union below is read off it, so the declared needs + * and the type the handler sees cannot drift apart. + */ +const apiRuntimeNeeds = [PlaceOrder, FindOrder, Logger, httpConfig] as const; + +type ApiRuntimeNeeds = (typeof apiRuntimeNeeds)[number]; + +/** + * `@btravstack/start-http`'s runtime, with this deployment's own answer to the + * one question it asks that is configuration: which port. + * + * It takes **no arguments**. `main.ts` used to read `PORT` and hand the number + * down; a runtime is given a `Context` at `start`, so it reads `httpConfig` + * out of the graph itself and the entry point never learns what a port is. + * + * The package's runtime is therefore built **inside** `start`, once the + * context exists — which is also why `name` and `needs` are stated here rather + * than forwarded from it: those two have to be answerable before `start` runs. + * `"http"` is `@btravstack/start-http`'s own name for itself. + * + * The specs deliberately do not use this: they call `httpRuntime({ port: 0 })` + * directly, because an ephemeral bind read back off `Serving.info` is a + * property of the test, not of the deployment. What they cover instead is the + * transport; what covers this is `src/needs-gate.test-d.ts`. + */ +export const orderApiRuntime = (): Runtime => ({ + name: "http", + needs: apiRuntimeNeeds, + start: (host) => + httpRuntime({ + port: host.ctx.get(httpConfig).port, + needs: apiRuntimeNeeds, + handler: apiHandler, + }).start(host), +}); diff --git a/examples/order-api/src/config.ts b/examples/order-api/src/config.ts new file mode 100644 index 0000000..1d18a75 --- /dev/null +++ b/examples/order-api/src/config.ts @@ -0,0 +1,32 @@ +import { Config } from "@btravstack/config"; +import { port } from "@btravstack/config/zod"; + +/** + * The port the API listens on. + * + * One value that is both the port token and the module serving it: `imports: + * [httpConfig]` provides it from the environment, `ctx.get(httpConfig)` reads + * it back. `Config("Http")` derives its prefix from its own identity, so + * `port` reads `HTTP_PORT`. + * + * **This is the one variable this refactor renamed.** It was `PORT`, and a + * bare `PORT` is not expressible: every variable a config declares is + * `PREFIX_KEY`, and there is no prefix and key whose join is `PORT` alone. The + * options were a variable the package cannot name, or a name it can — see the + * PR description; an operator setting `PORT` today has to set `HTTP_PORT`. + */ +export const httpConfig = Config("Http")({ port: port(3000) }); + +/** + * The probe port's default, named because `main.ts` needs it a second time — + * see the comment there on why the kernel's own port cannot come out of the + * graph in phase 1. + */ +export const PROBE_PORT_DEFAULT = 9000; + +/** + * `/livez` and `/readyz`. Nothing resolves this port: it is declared so that + * `PROBE_PORT` is validated by the same pre-boot pass as every other variable, + * and reported in the same message when it is wrong. + */ +export const probeConfig = Config("Probe")({ port: port(PROBE_PORT_DEFAULT) }); diff --git a/examples/order-api/src/env.spec.ts b/examples/order-api/src/env.spec.ts deleted file mode 100644 index a21304c..0000000 --- a/examples/order-api/src/env.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { readEnv } from "./env.js"; - -// The seven cases the shared `port` fragment has to survive are pinned once, in -// `order-config`. What is this deployment's own is which variables it reads and -// what they default to. -describe("readEnv", () => { - it("falls back to the documented defaults when nothing is set", () => { - // GIVEN an environment with neither port set - const source = {}; - - // WHEN it is validated - const env = readEnv(source); - - // THEN both ports carry their defaults, as numbers - expect(env).toBeOkWith({ PORT: 3000, PROBE_PORT: 9000 }); - }); - - it("reads the ports a deployment actually supplies", () => { - // GIVEN both ports set, as the strings an environment always holds - const source = { PORT: "8080", PROBE_PORT: "0" }; - - // WHEN it is validated - const env = readEnv(source); - - // THEN they arrive parsed, and `0` survives as the ephemeral bind it is - expect(env).toBeOkWith({ PORT: 8080, PROBE_PORT: 0 }); - }); - - it("reports every malformed port at once rather than binding NaN", () => { - // GIVEN the values `Number()` would silently turn into `NaN` and `0` - const source = { PORT: "abc", PROBE_PORT: "" }; - - // WHEN it is validated - const env = readEnv(source); - - // THEN neither reaches a socket: both are issues in the error channel, - // named and in order, asserted on the `Err` itself rather than behind an - // `env.isErr() &&` guard that evaluates to `false` when it does not hold - expect(env).toBeErrWith([ - expect.objectContaining({ path: ["PORT"] }), - expect.objectContaining({ path: ["PROBE_PORT"] }), - ]); - }); -}); diff --git a/examples/order-api/src/env.ts b/examples/order-api/src/env.ts deleted file mode 100644 index 8a860bd..0000000 --- a/examples/order-api/src/env.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describeEnvIssues, port } from "@btravstack/start-example-order-config"; -import { fromSchema, type SchemaIssues } from "@unthrown/standard-schema"; -import type { Result } from "unthrown"; -import { z } from "zod"; - -const environment = z.object({ - PORT: port(3000), - PROBE_PORT: port(9000), -}); - -/** The validated environment: every field present, typed, and in range. */ -export type Env = z.infer; - -// `fromSchema` is CURRIED — it takes the schema and hands back the validator. -const validate = fromSchema(environment); - -/** - * Validates the process environment **as a value**. - * - * A schema's own `.parse()` throws, which `unthrown/no-throw` bans and which - * would contradict the example it appears in. `@unthrown/standard-schema` makes - * the issues the modeled `E`, so the entry point folds a bad environment the - * same way it folds any other anticipated failure. `port` and the issue - * formatter are the shared ones — see `order-config` for why the non-empty - * string in front of the coercion is load-bearing. - */ -export const readEnv = (source: typeof process.env = process.env): Result => - validate(source); - -export { describeEnvIssues }; diff --git a/examples/order-api/src/main.ts b/examples/order-api/src/main.ts index 6388a6e..fea26db 100644 --- a/examples/order-api/src/main.ts +++ b/examples/order-api/src/main.ts @@ -1,35 +1,27 @@ +import { Config, describeIssues } from "@btravstack/config"; import { runMain, start } from "@btravstack/start-core"; -import { FindOrder, Logger, PlaceOrder } from "@btravstack/start-example-order-application"; -import { httpRuntime } from "@btravstack/start-http"; import { P } from "unthrown"; -import { describeEnvIssues, readEnv, type Env } from "./env.js"; -import { apiHandler } from "./handler.js"; +import { orderApiRuntime } from "./api-runtime.js"; +import { PROBE_PORT_DEFAULT } from "./config.js"; import { OrderApiModule } from "./module.js"; /** - * The whole process, in one expression: validate the environment, build the + * The whole process, in one expression: validate the configuration, build the * graph, serve it, and turn the exit report into a process exit code. Nothing * here catches anything — a malformed environment is a modeled `Err`, a failure * to start is the module's own `Err`, a bug is a `Defect`, and `runMain` maps * the last two onto exit codes. * + * There is no `env.ts` any more, and nothing is threaded into the runtime: the + * listening port is a config the graph provides and `orderApiRuntime` reads + * for itself. + * * Typechecked by the gate, not executed by it. The example packages are * source-only — no build step, `main` pointing straight at `src/` — so there is * no compiled entry for `node` to run, and every spec drives `start` directly. * This file is the shape a real entry point takes. */ -const serve = (env: Env): Promise => - runMain( - start(OrderApiModule, { - runtime: httpRuntime({ - port: env.PORT, - needs: [PlaceOrder, FindOrder, Logger], - handler: apiHandler, - }), - probes: { port: env.PROBE_PORT }, - }), - ); /** sysexits(3) `EX_CONFIG`: the deployment is wrong, not the code. */ const abort = (reason: string): void => { @@ -37,9 +29,34 @@ const abort = (reason: string): void => { process.exitCode = 78; }; -await readEnv().match({ - ok: serve, - // oxlint-disable-next-line unthrown/no-catch-all-pattern -- `E` is the issues array: one type with no discriminant, so there is nothing to enumerate and the single arm IS the enumeration - errCases: (matcher) => matcher.with(P._, (issues) => abort(describeEnvIssues(issues))), - defect: (cause) => abort(`the environment could not be validated: ${String(cause)}`), +// The one value this deployment still reads out of `process.env` by hand. +// `PROBE_PORT` is declared in `probeConfig` and validated below with every +// other variable, so by the time this is used it is known to be a whole number +// in range — but `start` binds the probe server *before* it builds the graph, +// 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); + +// One report for the whole graph. `Config.collect` walks the module tree for +// every config reachable from the root, and `Config.parse` validates all of +// them against one source, aggregating every wrong variable into a single +// `ConfigInvalid`. An operator who mistyped three of them learns all three +// from this boot, instead of one per deploy. +// +// `ConfigInvalid` is a `TaggedError`, so the matcher names it. The fold this +// replaced reported one schema's issues and had to reach for `P._` behind a +// lint-disable, because a `SchemaIssues` array is a single type with no +// discriminant and so nothing to enumerate. +await Config.parse(Config.collect(OrderApiModule), process.env).match({ + ok: () => + runMain( + start(OrderApiModule, { + runtime: orderApiRuntime(), + probes: { port: probePort }, + }), + ), + errCases: (matcher) => + matcher.with(P.tag("config/ConfigInvalid"), (error) => abort(describeIssues(error.issues))), + defect: (cause) => abort(`the configuration could not be validated: ${String(cause)}`), }); diff --git a/examples/order-api/src/module.ts b/examples/order-api/src/module.ts index 065c117..7755e1f 100644 --- a/examples/order-api/src/module.ts +++ b/examples/order-api/src/module.ts @@ -1,3 +1,4 @@ +import { Config } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { ApplicationModule, @@ -7,18 +8,35 @@ import { } from "@btravstack/start-example-order-application"; import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; +import { httpConfig, probeConfig } from "./config.js"; + /** * The composition root, and the only file in the example that knows both halves * exist. `ApplicationModule` leaves `OrderRepository` unmet; `PersistenceModule` - * provides it. Importing both is what closes di's arity gate — and the three - * ports re-exported here are exactly what `main.ts` declares as `httpRuntime`'s - * needs, which closes the kernel's. + * provides it. Importing both is what closes di's arity gate — and the ports + * re-exported here are exactly what `orderApiRuntime` declares as its needs, + * which closes the kernel's. + * + * Configuration is imported the same way, because that is all a config is: a + * module providing a port. `Config.source(process.env)` is the one place the + * environment enters the graph, so `Config.parse`'s pre-boot check and the + * providers that inject the values cannot disagree about what it held. + * + * `probeConfig` is imported but not exported: nothing in the graph resolves + * it, and it is here so `PROBE_PORT` is validated by the same pre-boot pass as + * every other variable. * * `PersistenceModule`'s database provider is resourceful, so this module carries * a `Scope` need that only `Module.scoped` discharges — which is what `start` * does, once, for the whole process. */ export const OrderApiModule = Module("OrderApi")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder, Logger], + imports: [ + ApplicationModule, + PersistenceModule, + httpConfig, + probeConfig, + Config.source(process.env), + ], + exports: [PlaceOrder, FindOrder, Logger, httpConfig], }); diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index 43165af..0e47656 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -1,44 +1,47 @@ /** - * The compile-time half of the transport layer: `httpRuntime` declares three - * ports in `needs`, and `start`'s phantom rest-tuple gate turns a module that - * does not export all three into a call-site arity error. Type-checked by this - * package's `test:types` script, never executed. + * The compile-time half of the transport layer: `orderApiRuntime` declares + * four ports in `needs` — three application ports and its own config — and + * `start`'s phantom rest-tuple gate turns a module that does not export them + * all into a call-site arity error. Type-checked by this package's + * `test:types` script, never executed. * - * This is the only place in the repo where a runtime with a NON-EMPTY `needs` - * meets a real module, so it is also what exercises `RuntimeHost`'s - * `Context>` — a runtime declares its needs as port - * *classes* while di parameterises `Context` by port *instances*. + * It exercises `RuntimeHost`'s `Context>` — a runtime + * declares its needs as port *classes* while di parameterises `Context` by + * port *instances* — and, now that a config is one of those needs, it is also + * what makes a deployment that forgot to import `httpConfig` fail here rather + * than at boot. */ +import { Config } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { start } from "@btravstack/start-core"; import { ApplicationModule, FindOrder, - Logger, PlaceOrder, } from "@btravstack/start-example-order-application"; import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; -import { httpRuntime } from "@btravstack/start-http"; -import { apiHandler } from "./handler.js"; +import { orderApiRuntime } from "./api-runtime.js"; +import { httpConfig } from "./config.js"; import { OrderApiModule } from "./module.js"; const options = { - runtime: httpRuntime({ port: 0, needs: [PlaceOrder, FindOrder, Logger], handler: apiHandler }), + runtime: orderApiRuntime(), signals: false, probes: false, } as const; -// Positive: the composition root exports all three ports the runtime needs, so +// Positive: the composition root exports all four ports the runtime needs, so // the gate collapses to an empty tuple and this is an ordinary two-argument call. const _wired = start(OrderApiModule, options); // The same graph, one port short: `Logger` is provided (the interactors depend // on it) but not exported, so it is not in the application context the runtime -// is handed. +// is handed. `httpConfig` *is* exported, so the gate can only be answering +// about the one genuinely missing port. const PartialApi = Module("PartialApi")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder], + imports: [ApplicationModule, PersistenceModule, httpConfig, Config.source(process.env)], + exports: [PlaceOrder, FindOrder, httpConfig], }); // Negative: the gate becomes a required two-element tuple naming the unmet need, diff --git a/examples/order-api/tsconfig.json b/examples/order-api/tsconfig.json index 3faf372..b414cfc 100644 --- a/examples/order-api/tsconfig.json +++ b/examples/order-api/tsconfig.json @@ -1,6 +1,16 @@ { "extends": "@btravstack/tsconfig/base.json", "compilerOptions": { + // Off, with `declarationMap`, because this package emits no declarations + // at all — it is source-only, `main` points at `src/`, and nothing ever + // consumes a `.d.ts` of it. The check they enable is real elsewhere and + // unmeetable here: a composition root that exports a `@btravstack/config` + // value emits `Module | …>`, which reduces + // to `@btravstack/di`'s `PortInstance` — deliberately unexported, so a port + // instance cannot be hand-forged, and therefore unnameable by an emitted + // declaration. See `packages/config/src/slice.ts`'s `DeclaredConfig`. + "declaration": false, + "declarationMap": false, "noEmit": true, "types": ["node"], // Inherited from the persistence layer this package composes: the generated diff --git a/examples/order-temporal-worker/package.json b/examples/order-temporal-worker/package.json index f0287c3..4909954 100644 --- a/examples/order-temporal-worker/package.json +++ b/examples/order-temporal-worker/package.json @@ -15,10 +15,10 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { + "@btravstack/config": "workspace:*", "@btravstack/di": "catalog:", "@btravstack/start-core": "workspace:*", "@btravstack/start-example-order-application": "workspace:*", - "@btravstack/start-example-order-config": "workspace:*", "@btravstack/start-example-order-domain": "workspace:*", "@btravstack/start-example-order-infrastructure": "workspace:*", "@btravstack/start-example-order-temporal-contract": "workspace:*", @@ -28,7 +28,6 @@ "@temporalio/client": "catalog:", "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", - "@unthrown/standard-schema": "catalog:", "unthrown": "catalog:", "zod": "catalog:" }, diff --git a/examples/order-temporal-worker/src/config.ts b/examples/order-temporal-worker/src/config.ts new file mode 100644 index 0000000..423140c --- /dev/null +++ b/examples/order-temporal-worker/src/config.ts @@ -0,0 +1,37 @@ +import { Config } from "@btravstack/config"; +import { port } from "@btravstack/config/zod"; +import { z } from "zod"; + +/** + * The address of the Temporal frontend service, named because `main.ts` needs + * it a second time — see the comment there on why the connection cannot be + * opened from the graph. + */ +export const TEMPORAL_ADDRESS_DEFAULT = "127.0.0.1:7233"; + +/** + * Where this worker polls, and in which namespace. + * + * `Config("Temporal")` derives its prefix from its own identity, so the two + * keys read `TEMPORAL_ADDRESS` and `TEMPORAL_NAMESPACE` — the same variables + * this deployment has always taken. Both are strings with an emptiness rule: + * `.min(1)` in front of `.default(...)` makes a blank value a configuration + * error rather than an absent one, which a worker silently polling the wrong + * namespace would otherwise never announce. + */ +export const temporalConfig = Config("Temporal")({ + address: z.string().min(1).default(TEMPORAL_ADDRESS_DEFAULT), + namespace: z.string().min(1).default("default"), +}); + +/** + * The probe port's default, named for the same reason as the address above. + */ +export const PROBE_PORT_DEFAULT = 9000; + +/** + * `/livez` and `/readyz`. Nothing resolves this port: it is declared so that + * `PROBE_PORT` is validated by the same pre-boot pass as every other variable, + * and reported in the same message when it is wrong. + */ +export const probeConfig = Config("Probe")({ port: port(PROBE_PORT_DEFAULT) }); diff --git a/examples/order-temporal-worker/src/env.spec.ts b/examples/order-temporal-worker/src/env.spec.ts deleted file mode 100644 index 1540ca2..0000000 --- a/examples/order-temporal-worker/src/env.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { readEnv } from "./env.js"; - -// The seven cases the shared `port` fragment has to survive are pinned once, in -// `order-config`. What is this deployment's own is its two string variables, -// which are not numbers and have their own emptiness rule. -describe("readEnv", () => { - it("falls back to the documented defaults when nothing is set", () => { - // GIVEN an environment with none of the three variables set - const source = {}; - - // WHEN it is validated - const env = readEnv(source); - - // THEN all three carry their defaults, the port as a number - expect(env).toBeOkWith({ - PROBE_PORT: 9000, - TEMPORAL_ADDRESS: "127.0.0.1:7233", - TEMPORAL_NAMESPACE: "default", - }); - }); - - it("reads what a deployment actually supplies", () => { - // GIVEN all three set, as the strings an environment always holds - const source = { - PROBE_PORT: "0", - TEMPORAL_ADDRESS: "temporal.internal:7233", - TEMPORAL_NAMESPACE: "orders", - }; - - // WHEN it is validated - const env = readEnv(source); - - // THEN they arrive parsed, and `0` survives as the ephemeral bind it is - expect(env).toBeOkWith({ - PROBE_PORT: 0, - TEMPORAL_ADDRESS: "temporal.internal:7233", - TEMPORAL_NAMESPACE: "orders", - }); - }); - - it("rejects a namespace that is present but empty, rather than defaulting it", () => { - // GIVEN a namespace set to nothing — the string variables' version of the - // blank-value rule the numeric fragment enforces with `.min(1)` up front - const source = { TEMPORAL_NAMESPACE: "" }; - - // WHEN it is validated - const env = readEnv(source); - - // THEN it is a configuration error rather than an absent variable: polling - // the wrong namespace is a worker that silently receives nothing - expect(env).toBeErrWith([expect.objectContaining({ path: ["TEMPORAL_NAMESPACE"] })]); - }); -}); diff --git a/examples/order-temporal-worker/src/env.ts b/examples/order-temporal-worker/src/env.ts deleted file mode 100644 index 9603413..0000000 --- a/examples/order-temporal-worker/src/env.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describeEnvIssues, port } from "@btravstack/start-example-order-config"; -import { fromSchema, type SchemaIssues } from "@unthrown/standard-schema"; -import type { Result } from "unthrown"; -import { z } from "zod"; - -const environment = z.object({ - PROBE_PORT: port(9000), - /** `host:port` of the Temporal frontend service. */ - TEMPORAL_ADDRESS: z.string().min(1).default("127.0.0.1:7233"), - /** - * The namespace this worker polls in. Half of the pair the runtime publishes - * on `Serving.info`, and half of what decides which work it is ever handed. - */ - TEMPORAL_NAMESPACE: z.string().min(1).default("default"), -}); - -/** The validated environment: every field present, typed, and in range. */ -export type Env = z.infer; - -// `fromSchema` is CURRIED — it takes the schema and hands back the validator. -const validate = fromSchema(environment); - -/** - * Validates the process environment **as a value**. - * - * A schema's own `.parse()` throws, which `unthrown/no-throw` bans and which - * would contradict the example it appears in. `@unthrown/standard-schema` makes - * the issues the modeled `E`, so the entry point folds a bad environment the - * same way it folds any other anticipated failure. `port` and the issue - * formatter are the shared ones — see `order-config` for why the non-empty - * string in front of the coercion is load-bearing. - */ -export const readEnv = (source: typeof process.env = process.env): Result => - validate(source); - -export { describeEnvIssues }; diff --git a/examples/order-temporal-worker/src/main.ts b/examples/order-temporal-worker/src/main.ts index 57e2297..d96d9ea 100644 --- a/examples/order-temporal-worker/src/main.ts +++ b/examples/order-temporal-worker/src/main.ts @@ -1,61 +1,71 @@ +import { Config, describeIssues } from "@btravstack/config"; import { runMain, start } from "@btravstack/start-core"; import { orderContract } from "@btravstack/start-example-order-temporal-contract"; import { workflowsPathFromURL } from "@temporal-contract/worker/worker"; import { NativeConnection } from "@temporalio/worker"; import { OkAsync, P, fromSafePromise, type AsyncResult } from "unthrown"; -import { describeEnvIssues, readEnv, type Env } from "./env.js"; +import { PROBE_PORT_DEFAULT, TEMPORAL_ADDRESS_DEFAULT } from "./config.js"; import { OrderTemporalModule } from "./module.js"; import { temporalWorkerRuntime } from "./temporal-runtime.js"; +// The two values this deployment still reads out of `process.env` by hand. +// Both are declared as configs and validated below with every other variable, +// so by the time they are used they are known to be well-formed — but both are +// needed *before* the graph exists: `start` binds the probe server before it +// builds anything, and the connection has to be open to be handed to the +// runtime. Phase 1 of `@btravstack/config` has no way to read one config's +// value outside a graph, and phase 2's kernel integration is what deletes +// these two lines: `start` will own the source and resolve its own +// configuration. `TEMPORAL_NAMESPACE`, which is *not* needed early, already +// travels the right way — the runtime reads it off the context. +const probePort = Number(process.env["PROBE_PORT"] ?? PROBE_PORT_DEFAULT); +const address = process.env["TEMPORAL_ADDRESS"] ?? TEMPORAL_ADDRESS_DEFAULT; + /** * The third process, and — apart from the runtime it names and the connection * it opens — the same one the other two `main.ts` files are: validate the - * environment, build the graph, serve it, and turn the exit report into a + * configuration, build the graph, serve it, and turn the exit report into a * process exit code. * - * The connection is opened here for the reason `order-api` binds its port - * here: it is the *transport*, and a runtime is handed one rather than owning - * its lifetime. `workflowsPathFromURL` points Temporal at the workflow module - * so it can bundle it for the sandbox; a spec hands over a prebuilt bundle - * instead, which is why `WorkflowSource` has two arms. + * The connection is opened here because it is a *resource with an owner*, not + * a value: whoever opens it closes it, which is why the close is **here** and + * not in the runtime's `stop()`. `src/test-fixtures.ts` boots a fresh worker + * per test against the *one* `testEnv.nativeConnection` the whole file shares, + * so a runtime that closed what it was given would tear the test environment + * down under the next test. The asymmetry a runtime closing it would introduce + * is the smell; the symmetry is the rule. * - * Whoever opens it closes it, which is why the close is **here** and not in the - * runtime's `stop()`. The runtime is handed a connection it did not open and - * has no claim on: `src/test-fixtures.ts` boots a fresh worker per test against - * the *one* `testEnv.nativeConnection` the whole file shares, so a runtime that - * closed what it was given would tear the test environment down under the next - * test. The asymmetry a runtime closing it would introduce is the smell; the - * symmetry is the rule. + * `workflowsPathFromURL` points Temporal at the workflow module so it can + * bundle it for the sandbox; a spec hands over a prebuilt bundle instead, + * which is why `WorkflowSource` has two arms. * * Typechecked by the gate, not executed by it — the example packages are * source-only, and every spec drives `start` directly. */ -const work = (env: Env): AsyncResult => +const work = (): AsyncResult => // A frontend service that will not answer is not an anticipated outcome of // *this* deployment — there is no domain error to name it — so it rides the // defect channel and the fold below turns it into an exit code. - fromSafePromise(NativeConnection.connect({ address: env.TEMPORAL_ADDRESS })).flatMap( - (connection) => - fromSafePromise( - runMain( - start(OrderTemporalModule, { - runtime: temporalWorkerRuntime({ - contract: orderContract, - connection, - namespace: env.TEMPORAL_NAMESPACE, - workflows: { workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js") }, - }), - probes: { port: env.PROBE_PORT }, + fromSafePromise(NativeConnection.connect({ address })).flatMap((connection) => + fromSafePromise( + runMain( + start(OrderTemporalModule, { + runtime: temporalWorkerRuntime({ + contract: orderContract, + connection, + workflows: { workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js") }, }), - // `.finally`, not a `flatTap`: an open `NativeConnection` holds the - // event loop, so a startup that ends in a defect is exactly the path - // that must still close it. `runMain`'s bare `Promise` is the one - // place a native combinator belongs — it is the documented boundary - // where the Result world ends — and `close` never rejects, so the - // exit code `runMain` just set survives. - ).finally(() => close(connection)), - ), + probes: { port: probePort }, + }), + // `.finally`, not a `flatTap`: an open `NativeConnection` holds the + // event loop, so a startup that ends in a defect is exactly the path + // that must still close it. `runMain`'s bare `Promise` is the one + // place a native combinator belongs — it is the documented boundary + // where the Result world ends — and `close` never rejects, so the + // exit code `runMain` just set survives. + ).finally(() => close(connection)), + ), ); /** @@ -79,16 +89,26 @@ const abort = (reason: string): void => { process.exitCode = 78; }; -await readEnv().match({ - ok: (env) => - work(env).match({ +// One report for the whole graph. `Config.collect` walks the module tree for +// every config reachable from the root, and `Config.parse` validates all of +// them against one source, aggregating every wrong variable into a single +// `ConfigInvalid`. An operator who mistyped three of them learns all three +// from this boot, instead of one per deploy. +// +// `ConfigInvalid` is a `TaggedError`, so the matcher names it. The fold this +// replaced reported one schema's issues and had to reach for `P._` behind a +// lint-disable, because a `SchemaIssues` array is a single type with no +// discriminant and so nothing to enumerate. +await Config.parse(Config.collect(OrderTemporalModule), process.env).match({ + ok: () => + work().match({ ok: () => {}, // Nothing can land in the error channel — `work` is typed // `AsyncResult` — so the matcher has no case to name. errCases: (matcher) => matcher, defect: (cause) => abort(`could not reach the Temporal service: ${String(cause)}`), }), - // oxlint-disable-next-line unthrown/no-catch-all-pattern -- `E` is the issues array: one type with no discriminant, so there is nothing to enumerate and the single arm IS the enumeration - errCases: (matcher) => matcher.with(P._, (issues) => abort(describeEnvIssues(issues))), - defect: (cause) => abort(`the environment could not be validated: ${String(cause)}`), + errCases: (matcher) => + matcher.with(P.tag("config/ConfigInvalid"), (error) => abort(describeIssues(error.issues))), + defect: (cause) => abort(`the configuration could not be validated: ${String(cause)}`), }); diff --git a/examples/order-temporal-worker/src/module.ts b/examples/order-temporal-worker/src/module.ts index 3b595eb..a39a435 100644 --- a/examples/order-temporal-worker/src/module.ts +++ b/examples/order-temporal-worker/src/module.ts @@ -1,3 +1,4 @@ +import { Config } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { ApplicationModule, @@ -9,6 +10,7 @@ import { } from "@btravstack/start-example-order-application"; import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; +import { probeConfig, temporalConfig } from "./config.js"; import { FulfillmentModule } from "./fulfillment.js"; /** @@ -17,13 +19,31 @@ import { FulfillmentModule } from "./fulfillment.js"; * other deployment composes — plus `FulfillmentModule`, the two external * services only this deployment orchestrates. * - * The exports are the five ports the saga's activities resolve: the placement + * The two configs are imported the same way, because that is all a config is: + * a module providing a port. `Config.source(process.env)` is the one place the + * environment enters the graph, so `Config.parse`'s pre-boot check and the + * providers that inject the values cannot disagree about what it held. A spec + * imports its own record instead (`src/test-fixtures.ts`). + * + * `probeConfig` is imported but not exported: nothing in the graph resolves + * it, and it is here so `PROBE_PORT` is validated by the same pre-boot pass as + * every other variable. + * + * The exports are the five ports the saga's activities resolve — the placement * use case, the repository (its `remove` is `cancelPlacement`'s persistence - * arm), the two fulfillment services, and the logger. Declared here rather - * than imported from a sibling because sharing a composition root would share - * its transport dependency — one application, one root per process. + * arm), the two fulfillment services, and the logger — plus the config the + * runtime reads its namespace from. Declared here rather than imported from a + * sibling because sharing a composition root would share its transport + * dependency — one application, one root per process. */ export const OrderTemporalModule = Module("OrderTemporal")({ - imports: [ApplicationModule, PersistenceModule, FulfillmentModule], - exports: [PlaceOrder, OrderRepository, StockService, ShippingService, Logger], + imports: [ + ApplicationModule, + PersistenceModule, + FulfillmentModule, + temporalConfig, + probeConfig, + Config.source(process.env), + ], + exports: [PlaceOrder, OrderRepository, StockService, ShippingService, Logger, temporalConfig], }); diff --git a/examples/order-temporal-worker/src/needs-gate.test-d.ts b/examples/order-temporal-worker/src/needs-gate.test-d.ts index ee58a9b..9439584 100644 --- a/examples/order-temporal-worker/src/needs-gate.test-d.ts +++ b/examples/order-temporal-worker/src/needs-gate.test-d.ts @@ -8,18 +8,19 @@ * claim testable rather than asserted: three runtimes with non-empty `needs`, * all proven against the same application graph at the `start(...)` call site. */ +import { Config } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { start } from "@btravstack/start-core"; import { ApplicationModule, FindOrder, - Logger, PlaceOrder, } from "@btravstack/start-example-order-application"; import { PersistenceModule } from "@btravstack/start-example-order-infrastructure"; import { orderContract } from "@btravstack/start-example-order-temporal-contract"; import type { NativeConnection } from "@temporalio/worker"; +import { temporalConfig } from "./config.js"; import { OrderTemporalModule } from "./module.js"; import { temporalWorkerRuntime } from "./temporal-runtime.js"; @@ -37,17 +38,18 @@ const options = { probes: false, } as const; -// Positive: the composition root exports both ports the runtime needs (and a -// third it does not), so the gate collapses to an empty tuple and this is an -// ordinary two-argument call. +// Positive: the composition root exports every port the runtime needs — the +// five application ones and its config — so the gate collapses to an empty +// tuple and this is an ordinary two-argument call. const _wired = start(OrderTemporalModule, options); // The same graph, one port short: `Logger` is provided (the interactors depend // on it) but not exported, so it is not in the application context the runtime -// is handed. +// is handed. `temporalConfig` *is* exported, so the gate can only be answering +// about a genuinely missing application port. const PartialTemporal = Module("PartialTemporal")({ - imports: [ApplicationModule, PersistenceModule], - exports: [PlaceOrder, FindOrder], + imports: [ApplicationModule, PersistenceModule, temporalConfig, Config.source(process.env)], + exports: [PlaceOrder, FindOrder, temporalConfig], }); // Negative: the gate becomes a required two-element tuple naming the unmet need, diff --git a/examples/order-temporal-worker/src/temporal-runtime.ts b/examples/order-temporal-worker/src/temporal-runtime.ts index 8e1c951..29448d5 100644 --- a/examples/order-temporal-worker/src/temporal-runtime.ts +++ b/examples/order-temporal-worker/src/temporal-runtime.ts @@ -21,6 +21,8 @@ import { import type { NativeConnection } from "@temporalio/worker"; import { P } from "unthrown"; +import { temporalConfig } from "./config.js"; + /** * How long an activity that is *cancellation-aware* gets to notice the * shutdown. Set explicitly because Temporal's default is `0`, and cancelling @@ -46,19 +48,30 @@ const SHUTDOWN_FORCE = "15 seconds"; /** * The ports this runtime resolves out of the application context — one per - * concern the saga's activities touch. `FindOrder` is not among them, and a - * runtime declares what *it* needs rather than what the module happens to - * export. + * concern the saga's activities touch, plus the config carrying the namespace + * it polls in. `FindOrder` is not among them, and a runtime declares what *it* + * needs rather than what the module happens to export. + * + * A config is a need like any other port, which is the point: `start` proves + * at the call site that the graph carries it, so a deployment that forgot to + * import `temporalConfig` fails to compile rather than to boot. + * + * One array, two uses — the union below is read off it, so the declared needs + * and the type the activities see cannot drift apart. * * Non-empty on purpose: it is what makes `start`'s arity gate mean something * (`src/needs-gate.test-d.ts` pins both directions). */ -type TemporalNeeds = - | typeof PlaceOrder - | typeof OrderRepository - | typeof StockService - | typeof ShippingService - | typeof Logger; +const temporalNeeds = [ + PlaceOrder, + OrderRepository, + StockService, + ShippingService, + Logger, + temporalConfig, +] as const; + +type TemporalNeeds = (typeof temporalNeeds)[number]; /** * A `Runtime` serving the order application as a Temporal worker — and, since @@ -76,6 +89,16 @@ type TemporalNeeds = * from the middleware's own type, and infers nothing from a generic call it is * still resolving, so writing it bare would leave `context` empty inside the * implementation below. + * + * The **namespace** used to be a parameter, read from the environment in + * `main.ts` and threaded down. It is configuration, and a runtime is handed a + * `Context` at `start`, so it reads `temporalConfig` out of the graph itself. + * The connection is not configuration and stays a parameter — see below. + * + * That is why the package's runtime is built **inside** `start`, and why + * `name` and `needs` are stated here rather than forwarded from it: those two + * have to be answerable before a context exists. `"temporal"` is + * `@btravstack/start-temporal`'s own name for itself. */ export const temporalWorkerRuntime = ({ contract, @@ -88,40 +111,44 @@ export const temporalWorkerRuntime = ({ */ readonly contract: OrderContract; /** - * An open connection to the Temporal service. Handed in rather than opened - * here, exactly as the queue runtime is handed its broker: `main.ts` builds - * one from the environment, and a spec passes the test environment's. + * An open connection to the Temporal service. Handed in rather than resolved + * from the graph, because it is not a value but a *resource with an owner*: + * `main.ts` opens one and closes it, and a spec passes the test + * environment's, which it must not close. Its address is configuration — + * `temporalConfig.address` — but the socket is not. */ readonly connection: NativeConnection; - readonly namespace?: string; readonly workflows: WorkflowSource; -}): Runtime => - // `...transport` rather than three named fields: `connection`, `namespace` - // and `workflows` are the package's own options under the package's own - // names, and spreading them keeps `namespace` optional instead of - // reintroducing it as `string | undefined`, which `exactOptionalPropertyTypes` - // would reject. - temporalRuntime({ - ...transport, - taskQueue: contract.taskQueue, - needs: [PlaceOrder, OrderRepository, StockService, ShippingService, Logger], - activities: (host) => - declareActivitiesHandler({ - contract, - middleware: activityUnits(host), - activities: { - fulfillOrder: { - place: placeActivity, - reserveStock: reserveStockActivity, - arrangeShipping: arrangeShippingActivity, - releaseStock: releaseStockActivity, - cancelPlacement: cancelPlacementActivity, +}): Runtime => ({ + name: "temporal", + needs: temporalNeeds, + start: (host) => + temporalRuntime({ + // `...transport` rather than two named fields: `connection` and + // `workflows` are the package's own options under the package's own + // names. + ...transport, + namespace: host.ctx.get(temporalConfig).namespace, + taskQueue: contract.taskQueue, + needs: temporalNeeds, + activities: (h) => + declareActivitiesHandler({ + contract, + middleware: activityUnits(h), + activities: { + fulfillOrder: { + place: placeActivity, + reserveStock: reserveStockActivity, + arrangeShipping: arrangeShippingActivity, + releaseStock: releaseStockActivity, + cancelPlacement: cancelPlacementActivity, + }, }, - }, - }), - gracePeriod: SHUTDOWN_GRACE, - forceAfter: SHUTDOWN_FORCE, - }); + }), + gracePeriod: SHUTDOWN_GRACE, + forceAfter: SHUTDOWN_FORCE, + }).start(host), +}); /** * The one activity, and the hinge of this whole example. diff --git a/examples/order-temporal-worker/src/test-fixtures.ts b/examples/order-temporal-worker/src/test-fixtures.ts index 131813a..3b06070 100644 --- a/examples/order-temporal-worker/src/test-fixtures.ts +++ b/examples/order-temporal-worker/src/test-fixtures.ts @@ -1,6 +1,7 @@ import { mkdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { Config } from "@btravstack/config"; import { Module, Port, Provider, type Scope, type ServiceOf } from "@btravstack/di"; import { start, type RunningApp } from "@btravstack/start-core"; import { @@ -30,6 +31,7 @@ import type { TestWorkflowEnvironment } from "@temporalio/testing"; import { ErrAsync, OkAsync } from "unthrown"; import { expect } from "vitest"; +import { temporalConfig } from "./config.js"; import { FulfillmentModule } from "./fulfillment.js"; import { temporalWorkerRuntime } from "./temporal-runtime.js"; @@ -59,12 +61,18 @@ mkdirSync(downloadDir, { recursive: true }); type App = RunningApp; /** - * `X` is pinned to the five ports the composition root exports rather than - * left generic: `start`'s needs gate is a phantom rest parameter proven at the + * `X` is pinned to the ports the composition root exports rather than left + * generic: `start`'s needs gate is a phantom rest parameter proven at the * call site, and no proof is available inside a helper generic in the module's * own exports. */ -type TemporalPorts = PlaceOrder | OrderRepository | StockService | ShippingService | Logger; +type TemporalPorts = + | PlaceOrder + | OrderRepository + | StockService + | ShippingService + | Logger + | InstanceType; /** One booted deployment: the kernel's handle, and a client that can reach it. */ type Deployment = { @@ -97,17 +105,23 @@ const tapProvider = (capture: (services: ServiceOf) => void) => /** * A composition root shaped like the real one, with this test's fulfillment * module swapped in: same `ApplicationModule`, same `PersistenceModule`, same - * runtime, same five exported ports, so the orchestration under test is - * unchanged and only the external services' answers differ. + * runtime, same exported ports, so the orchestration under test is unchanged + * and only the external services' answers differ. + * + * Its `Config.source` is an empty record, not `process.env`: the time-skipping + * environment serves the `default` namespace, which is `temporalConfig`'s own + * default, so what this pins is that a spec supplies the environment as an + * ordinary module — and that an ambient variable on a developer's machine + * cannot reach into a test. */ const rootWith = ( fulfillment: typeof FulfillmentModule, capture: (services: ServiceOf) => void, ) => Module("StubTemporal")({ - imports: [ApplicationModule, PersistenceModule, fulfillment], + imports: [ApplicationModule, PersistenceModule, fulfillment, temporalConfig, Config.source({})], provides: [tapProvider(capture)], - exports: [PlaceOrder, OrderRepository, StockService, ShippingService, Logger], + exports: [PlaceOrder, OrderRepository, StockService, ShippingService, Logger, temporalConfig], }); const deployment = (fulfillment: typeof FulfillmentModule) => { diff --git a/examples/order-temporal-worker/tsconfig.json b/examples/order-temporal-worker/tsconfig.json index 3faf372..b414cfc 100644 --- a/examples/order-temporal-worker/tsconfig.json +++ b/examples/order-temporal-worker/tsconfig.json @@ -1,6 +1,16 @@ { "extends": "@btravstack/tsconfig/base.json", "compilerOptions": { + // Off, with `declarationMap`, because this package emits no declarations + // at all — it is source-only, `main` points at `src/`, and nothing ever + // consumes a `.d.ts` of it. The check they enable is real elsewhere and + // unmeetable here: a composition root that exports a `@btravstack/config` + // value emits `Module | …>`, which reduces + // to `@btravstack/di`'s `PortInstance` — deliberately unexported, so a port + // instance cannot be hand-forged, and therefore unnameable by an emitted + // declaration. See `packages/config/src/slice.ts`'s `DeclaredConfig`. + "declaration": false, + "declarationMap": false, "noEmit": true, "types": ["node"], // Inherited from the persistence layer this package composes: the generated From 99c80a53af0ad86bb3816883d59f858c28c64f59 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 17:51:26 +0200 Subject: [PATCH 07/11] refactor(examples): delete order-config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- examples/order-config/README.md | 52 ---------- examples/order-config/package.json | 28 ------ examples/order-config/src/env.spec.ts | 108 --------------------- examples/order-config/src/env.ts | 43 -------- examples/order-config/src/index.ts | 1 - examples/order-config/src/test-fixtures.ts | 23 ----- examples/order-config/src/vitest.d.ts | 1 - examples/order-config/tsconfig.json | 9 -- examples/order-config/vitest.config.ts | 9 -- 9 files changed, 274 deletions(-) delete mode 100644 examples/order-config/README.md delete mode 100644 examples/order-config/package.json delete mode 100644 examples/order-config/src/env.spec.ts delete mode 100644 examples/order-config/src/env.ts delete mode 100644 examples/order-config/src/index.ts delete mode 100644 examples/order-config/src/test-fixtures.ts delete mode 100644 examples/order-config/src/vitest.d.ts delete mode 100644 examples/order-config/tsconfig.json delete mode 100644 examples/order-config/vitest.config.ts diff --git a/examples/order-config/README.md b/examples/order-config/README.md deleted file mode 100644 index 796a82a..0000000 --- a/examples/order-config/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# `@btravstack/start-core` example: the shared configuration reader - -Three deployments, one way to read an environment variable — and the seven cases -it has to survive pinned once instead of three times. - -``` -src/env.ts wholeNumber / port, and the issue formatter -src/env.spec.ts the seven cases, against the fragments themselves -``` - -## Why a package rather than a copy in each deployment - -`order-api`, `order-amqp-worker` and `order-temporal-worker` each validate -`process.env` through a schema and return it as a `Result`. That much is the point, and each -keeps its own schema: its variables, its defaults, its bounds. What they were -also each keeping was the _fragment_ — - -```ts -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); -``` - -— and a copy of the reasoning that makes it correct, and a copy of the seven -tests that prove it. Three copies of a subtle thing is three chances to fix it -in one place only. - -## The subtle thing - -The non-empty string in front of the coercion is load-bearing. Coercion is -`Number()` underneath, so `PORT=` is `0` — and the bounds cannot catch that, -because a port's `min` **is** `0`: an ephemeral bind has to stay expressible. -An empty value is a configuration **error**, not an absent one, and -`.default(...)` applies only when the variable is genuinely missing. - -With that guard in place the bounds do the rest: `abc` is `NaN`, `3.5` is not an -integer, `99999` is out of range. - -## What each deployment still owns - -Its variables, their defaults, and whatever is genuinely its own — so -`order-amqp-worker`'s spec pins that `OUTBOX_POLL_MS=0` is rejected where a port's -own bounds would allow it, and `order-temporal-worker`'s pins that a blank -`TEMPORAL_NAMESPACE` is an error rather than a default. Those are facts about a -deployment, not about the fragment. - -`describeEnvIssues` is here too: every entry point folds a bad environment into -the same one-line-per-issue message and a non-zero exit code. diff --git a/examples/order-config/package.json b/examples/order-config/package.json deleted file mode 100644 index b6efa24..0000000 --- a/examples/order-config/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@btravstack/start-example-order-config", - "private": true, - "description": "The shared configuration reader of the clean-architecture example: one environment-variable idiom for all three deployments, validated as a value rather than parsed by throwing", - "license": "MIT", - "author": "Benoit TRAVERS ", - "type": "module", - "main": "./src/index.ts", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "test": "vitest run", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@unthrown/standard-schema": "catalog:", - "zod": "catalog:" - }, - "devDependencies": { - "@btravstack/tsconfig": "catalog:", - "@types/node": "catalog:", - "@unthrown/vitest": "catalog:", - "typescript": "catalog:", - "unthrown": "catalog:", - "vitest": "catalog:" - } -} diff --git a/examples/order-config/src/env.spec.ts b/examples/order-config/src/env.spec.ts deleted file mode 100644 index bff106f..0000000 --- a/examples/order-config/src/env.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { P } from "unthrown"; -import { describe, expect } from "vitest"; - -import { describeEnvIssues } from "./env.js"; -import { it } from "./test-fixtures.js"; - -describe("the shared environment fragments", () => { - it("falls back to the documented defaults when nothing is set", ({ read }) => { - // GIVEN an environment with neither variable set - const source = {}; - - // WHEN it is validated - const env = read(source); - - // THEN both carry their defaults, as numbers - expect(env).toBeOkWith({ PORT: 3000, CONCURRENCY: 1 }); - }); - - it("reads what a deployment actually supplies", ({ read }) => { - // GIVEN both set, as the strings an environment always holds - const source = { PORT: "8080", CONCURRENCY: "4" }; - - // WHEN it is validated - const env = read(source); - - // THEN they arrive parsed rather than as strings - expect(env).toBeOkWith({ PORT: 8080, CONCURRENCY: 4 }); - }); - - it("keeps 0 expressible, because an ephemeral bind is legal", ({ read }) => { - // GIVEN the port a deployment sets when it wants the OS to pick one - const source = { PORT: "0" }; - - // WHEN it is validated - const env = read(source); - - // THEN it survives — which is exactly why `min(0)` cannot be the guard - // against a blank value, and why the non-empty string in front of the - // coercion has to be that guard instead - expect(env).toBeOkWith({ PORT: 0, CONCURRENCY: 1 }); - }); - - it("rejects a variable that is present but empty, rather than defaulting it", ({ read }) => { - // GIVEN the shape `Number()` reads as `0` - const source = { PORT: "" }; - - // WHEN it is validated - const env = read(source); - - // THEN it is a configuration error rather than an absent variable: the - // default is for a variable nobody set, not one set to nothing - expect(env).toBeErrWith([expect.objectContaining({ path: ["PORT"] })]); - }); - - it("rejects a variable that is present but blank", ({ read }) => { - // GIVEN whitespace, which trims to the same empty string - const source = { PORT: " ", CONCURRENCY: "\t\n" }; - - // WHEN it is validated - const env = read(source); - - // THEN both are issues, named and in order, asserted on the `Err` itself - // rather than behind a narrowing guard that can quietly not hold - expect(env).toBeErrWith([ - expect.objectContaining({ path: ["PORT"] }), - expect.objectContaining({ path: ["CONCURRENCY"] }), - ]); - }); - - it("reports a malformed value rather than binding NaN", ({ read }) => { - // GIVEN the value `Number()` would silently turn into `NaN` - const source = { PORT: "abc" }; - - // WHEN it is validated - const env = read(source); - - // THEN it never reaches a socket - expect(env).toBeErrWith([expect.objectContaining({ path: ["PORT"] })]); - }); - - it("rejects a number that is not a whole one", ({ read }) => { - // GIVEN a value `Number()` reads happily and no socket could ever bind - const source = { PORT: "3.5" }; - - // WHEN it is validated - const env = read(source); - - // THEN it is an issue rather than a silent truncation - expect(env).toBeErrWith([expect.objectContaining({ path: ["PORT"] })]); - }); - - it("rejects a value outside the range, and says so in the deployment's own words", ({ read }) => { - // GIVEN a number that parses but cannot be bound - const source = { PORT: "99999" }; - - // WHEN its error channel is folded into the message a deployment prints - const described = read(source).match({ - ok: () => "WRONGLY ACCEPTED", - // oxlint-disable-next-line unthrown/no-catch-all-pattern -- `E` is one type (the schema's list of issues), not a union of cases to enumerate - errCases: (matcher) => matcher.with(P._, describeEnvIssues), - defect: () => "defect", - }); - - // THEN the range is the schema's business, not the OS's — and the issue - // arrives named after the variable it is about - expect(described).toContain("PORT: Too big"); - }); -}); diff --git a/examples/order-config/src/env.ts b/examples/order-config/src/env.ts deleted file mode 100644 index c889bc9..0000000 --- a/examples/order-config/src/env.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { SchemaIssues } from "@unthrown/standard-schema"; -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 - * `PROBE_PORT=` would bind the ephemeral port `0`, a probe endpoint nobody can - * find, and a bare `CONCURRENCY=` would build a worker that consumes nothing. - * The bounds cannot catch either, because a port's `min` **is** `0`: an - * ephemeral bind has to stay expressible. An empty value is a configuration - * error, not an absent one — `.default(...)` applies only when the variable is - * genuinely missing. - * - * With that guard, the bounds handle the rest — `abc` is `NaN`, `3.5` is not an - * integer, and anything outside `min`/`max` is out of range. - * - * 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); - -const nameOf = (segment: NonNullable[number]): string => - String(typeof segment === "object" ? segment.key : segment); - -/** One line per issue, each naming the variable it is about. */ -export const describeEnvIssues = (issues: SchemaIssues): string => - issues - .map( - (issue) => `${(issue.path ?? []).map(nameOf).join(".") || "(environment)"}: ${issue.message}`, - ) - .join("\n"); diff --git a/examples/order-config/src/index.ts b/examples/order-config/src/index.ts deleted file mode 100644 index e5b63f9..0000000 --- a/examples/order-config/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { describeEnvIssues, port, wholeNumber } from "./env.js"; diff --git a/examples/order-config/src/test-fixtures.ts b/examples/order-config/src/test-fixtures.ts deleted file mode 100644 index 60b1e11..0000000 --- a/examples/order-config/src/test-fixtures.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { fromSchema } from "@unthrown/standard-schema"; -import { test } from "vitest"; -import { z } from "zod"; - -import { port, wholeNumber } from "./env.js"; - -/** - * A deployment-shaped environment built from the shared fragments — one port, - * one bounded count — so the seven cases are pinned against the fragments - * themselves rather than against any one deployment's variables. - */ -const validate = fromSchema(z.object({ PORT: port(3000), CONCURRENCY: wholeNumber(1, 1, 64) })); - -export type ConfigFixtures = { - readonly read: typeof validate; -}; - -export const it = test.extend({ - // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture - read: async ({}, use) => { - await use(validate); - }, -}); diff --git a/examples/order-config/src/vitest.d.ts b/examples/order-config/src/vitest.d.ts deleted file mode 100644 index ad36daf..0000000 --- a/examples/order-config/src/vitest.d.ts +++ /dev/null @@ -1 +0,0 @@ -import type {} from "@unthrown/vitest"; diff --git a/examples/order-config/tsconfig.json b/examples/order-config/tsconfig.json deleted file mode 100644 index f4b5b3b..0000000 --- a/examples/order-config/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "@btravstack/tsconfig/base.json", - "compilerOptions": { - "noEmit": true, - "types": ["node"] - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "src/**/*.test-d.ts"] -} diff --git a/examples/order-config/vitest.config.ts b/examples/order-config/vitest.config.ts deleted file mode 100644 index fb76260..0000000 --- a/examples/order-config/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["src/**/*.spec.ts"], - setupFiles: ["@unthrown/vitest"], - }, -}); From 66b8024cc0de3d27c37e27e8a156520409e04ccd Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 17:51:40 +0200 Subject: [PATCH 08/11] docs: document the examples' configuration, and what phase 1 cannot do 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) --- CLAUDE.md | 72 +++++++++------- examples/README.md | 100 ++++++++++++++++++----- examples/order-amqp-worker/README.md | 72 ++++++++++++---- examples/order-api/README.md | 90 +++++++++++--------- examples/order-temporal-worker/README.md | 50 ++++++++++-- packages/config/README.md | 64 ++++++++++++++- 6 files changed, 334 insertions(+), 114 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a3c1ea0..cfc66d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -622,37 +622,49 @@ expect(r.error.code)… }` passes on the outer assertion alone the moment the A sixth rule is about production code that tests keep honest: -6. **Configuration is validated through a schema and returned as a value, never - `.parse()`d.** `examples/order-api/src/env.ts` is the shape: a schema over - `process.env` run through `@unthrown/standard-schema`'s `fromSchema`, whose - issues are the modeled `E`, folded by the entry point into a message and a - non-zero exit code. A schema's own `.parse()` **throws**, which - `unthrown/no-throw` bans and which would contradict the example it appears in. +6. **Configuration is declared with `@btravstack/config`, never hand-rolled over + `process.env`.** `Config(id)(shape, options?)` is one value that is both a di + port token and the module serving it from the environment, so a deployment + writes `imports: [amqpConfig]` and reads `ctx.get(amqpConfig)` — no port here + and an adapter there. `examples/order-amqp-worker` is the reference: configs + in `src/config.ts` (and `outboxRelayConfig` next to the loop it tunes in + `src/outbox-relay.ts`), `Config.source(process.env)` as the single place the + environment enters the composition root, a runtime that names its configs in + `needs` and reads them off `host.ctx` rather than taking them as parameters, + and a `main.ts` whose whole tail is + `Config.parse(Config.collect(Root), process.env).match({ … })` — one report + for every config in the graph, folded with + `matcher.with(P.tag("config/ConfigInvalid"), …)` and `describeIssues`. A + spec supplies its own `Config.source({ … })` instead; a `Provider` stub for + the port works too. + A numeric variable is a **non-empty string piped into a coercion** — - `z.string().trim().min(1).pipe(z.coerce.number().int().min(min).max(max)).default(f)` - — never a bare `z.coerce.number()`: coercion is `Number()` underneath, so - `PORT=abc` binds `NaN` and `PORT=` binds the ephemeral port `0` — the exact - silent failure the module exists to remove. The bounds catch the first (and - `3.5`, and out-of-range); they cannot catch the second, because a **port's - `min` is `0`** so that an ephemeral bind stays expressible, which is why the - string guard is not optional. An empty or whitespace-only value is a - configuration **error**, not an absent one — `.default(...)` applies only - when the variable is genuinely missing. The fragment is - `examples/order-config`'s `wholeNumber` / `port`, shared by all three - deployments, and its spec pins all seven cases (absent, `""`, whitespace, - `abc`, `3.5`, valid, out of range) **once**. Each deployment's own `env.ts` - is then its variables and their defaults, and its own spec pins what is - genuinely its own — `order-amqp-worker`'s `OUTBOX_POLL_MS` bound differs from a - port's, and `order-temporal-worker`'s two string variables have an emptiness rule - of their own. Triplicating the fragment was the earlier shape; it was cut - in the audit that also deleted this repo's planning documents. The `` type argument - is needed because `z.coerce.number()`'s input is `unknown`, which `.pipe` - will not accept from a `string`. The earlier digits-only regex plus - `.transform(Number)` was the over-built form of this; it was simplified in - the PR #7 review, and a bare `z.coerce.number()` was tried there and reverted - for the `min(0)` hole above. - Note `fromSchema` is **curried** — `fromSchema(schema)(input)`, not - `fromSchema(schema, input)`. + `@btravstack/config/zod`'s `wholeNumber(fallback, min, max)` and + `port(fallback)` — never a bare `z.coerce.number()`: coercion is `Number()` + underneath, so `PORT=abc` binds `NaN` and `PORT=` binds the ephemeral port + `0`, the exact silent failure the package exists to remove. The bounds catch + the first (and `3.5`, and out-of-range); they cannot catch the second, + because a **port's `min` is `0`** so that an ephemeral bind stays + expressible. An empty or whitespace-only value is a configuration **error**, + not an absent one — `.default(...)` applies only when the variable is + genuinely missing. `packages/config` pins all seven cases (absent, `""`, + whitespace, `abc`, `3.5`, valid, out of range) once; a deployment's specs + pin only what is genuinely its own. The `` type argument is needed + because `z.coerce.number()`'s input is `unknown`, which `.pipe` will not + accept from a `string`. + + The hand-rolled shape — a `z.object` of `SCREAMING_SNAKE` keys per + deployment, a `readEnv` over `@unthrown/standard-schema`'s `fromSchema`, and + an `examples/order-config` package holding the shared fragment — was the + earlier form and is gone. Its fold needed a `P._` catch-all behind an + `unthrown/no-catch-all-pattern` disable, because `SchemaIssues` is one type + with no discriminant; `ConfigInvalid` is a `TaggedError`, so the matcher + enumerates it and the disable is gone with it. Two things still lag: `start` + needs `probes: { port }` **before** the graph exists, so each `main.ts` + reads `PROBE_PORT` from `process.env` at that one line (declared and + validated with the rest all the same), and a composition root exporting a + config cannot emit declarations until `@btravstack/di` exports + `PortInstance`. Both are phase-2 items; see `packages/config/README.md`. ## Deferred, deliberately diff --git a/examples/README.md b/examples/README.md index 4c00464..e78b792 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,30 +1,29 @@ # Examples -Eleven small packages that are **one application booted four ways**: a clean +Nine small packages that are **one application booted four ways**: a clean architecture split across four layers, deployed once as an oRPC API, once as a queue worker, once as a Temporal worker and once as an AMQP consumer, with each transport's contract in a package of its own — and, at the same time, exercising `@btravstack/start-core` end to end from a consumer's own workspace, `workspace:*` and all. -| Package | Layer | Shows | -| ------------------------------------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`order-domain`](./order-domain) | domain | Entities and rules with no dependencies at all: branded fields, an `Entity.invariant` re-checked on every path, failures as values. | -| [`order-application`](./order-application) | use cases | Ports declared by the caller, interactors, and an `ApplicationModule` whose `OrderRepository` is deliberately an **unmet need**. | -| [`order-infrastructure`](./order-infrastructure) | adapters | A Prisma-backed repository over in-memory SQLite, translating P-codes into the domain's vocabulary and closing the application's one need. | -| [`order-config`](./order-config) | config | The one environment-variable idiom the three deployments share: a non-empty string piped into a coercion, validated as a value, with the seven cases pinned once. | -| [`order-api-contract`](./order-api-contract) | contract | The oRPC contract on its own — wire shapes and declared error codes — taken by the server that implements it **and** by any client. | -| [`order-api`](./order-api) | runtime | The first deployment: an oRPC router over `node:http`, a scope forked per request, and `Result` → `ORPCError`. | -| [`order-temporal-contract`](./order-temporal-contract) | contract | The Temporal contract on its own — one workflow, five activities, four declared `nonRetryable` errors — read by the worker, the sandbox and the client. | -| [`order-temporal-worker`](./order-temporal-worker) | runtime | The **orchestration** deployment: a fulfillment saga on `@btravstack/start-temporal` — place, reserve, ship, and compensation in reverse on a permanent no. | -| [`order-amqp-contract`](./order-amqp-contract) | contract | The AMQP contract on its own — one exchange, one event, one subscriber queue with a retry/dead-letter policy — read by the relay and by any subscriber. | -| [`order-amqp-worker`](./order-amqp-worker) | runtime | The **broadcast** deployment: a transactional outbox relayed onto RabbitMQ by `@btravstack/start-amqp`'s worker — every committed write becomes an event. | +| Package | Layer | Shows | +| ------------------------------------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`order-domain`](./order-domain) | domain | Entities and rules with no dependencies at all: branded fields, an `Entity.invariant` re-checked on every path, failures as values. | +| [`order-application`](./order-application) | use cases | Ports declared by the caller, interactors, and an `ApplicationModule` whose `OrderRepository` is deliberately an **unmet need**. | +| [`order-infrastructure`](./order-infrastructure) | adapters | A Prisma-backed repository over in-memory SQLite, translating P-codes into the domain's vocabulary and closing the application's one need. | +| [`order-api-contract`](./order-api-contract) | contract | The oRPC contract on its own — wire shapes and declared error codes — taken by the server that implements it **and** by any client. | +| [`order-api`](./order-api) | runtime | The first deployment: an oRPC router over `node:http`, a scope forked per request, and `Result` → `ORPCError`. | +| [`order-temporal-contract`](./order-temporal-contract) | contract | The Temporal contract on its own — one workflow, five activities, four declared `nonRetryable` errors — read by the worker, the sandbox and the client. | +| [`order-temporal-worker`](./order-temporal-worker) | runtime | The **orchestration** deployment: a fulfillment saga on `@btravstack/start-temporal` — place, reserve, ship, and compensation in reverse on a permanent no. | +| [`order-amqp-contract`](./order-amqp-contract) | contract | The AMQP contract on its own — one exchange, one event, one subscriber queue with a retry/dead-letter policy — read by the relay and by any subscriber. | +| [`order-amqp-worker`](./order-amqp-worker) | runtime | The **broadcast** deployment: a transactional outbox relayed onto RabbitMQ by `@btravstack/start-amqp`'s worker — every committed write becomes an event. | ## The layering, and which way the arrows point ``` order-api order-temporal-worker order-amqp-worker ← one runtime each; one process each - └────────────────┼──────────────────┘ ─────▶ order-config ← how all three read the environment + └────────────────┼──────────────────┘ ─────▶ @btravstack/config ← how all three read the environment ▼ order-infrastructure ← Prisma, SQLite, P-codes │ provides OrderRepository @@ -35,6 +34,14 @@ exercising `@btravstack/start-core` end to end from a consumer's own workspace, order-domain ← entities and rules; depends on nothing ``` +There used to be an `order-config` package on the right-hand arrow, holding the +one environment-variable idiom the three deployments shared. It is gone: +[`@btravstack/config`](../packages/config) owns that idiom now — `wholeNumber` +and `port` are its `@btravstack/config/zod` builders, `describeEnvIssues` is +its `describeIssues` — and each deployment declares its own configs with +`Config(id)(shape, options?)` instead of hand-rolling a schema over +`process.env`. See [Configuration](#configuration) below. + Every arrow points **inwards**, and the one that looks like it goes the wrong way is the whole idea: `order-infrastructure` imports `order-application`, because the port it implements — `OrderRepository`, spelled in the domain's @@ -150,11 +157,14 @@ the other does not. ## The runtimes with a non-empty `needs` -`order-api`'s `httpRuntime` call declares `[PlaceOrder, FindOrder, Logger]`, -while `queueWorkerRuntime`, `temporalWorkerRuntime` and `orderAmqpRuntime` each -declare `[PlaceOrder, Logger]` — two of the three the module exports, because a -runtime declares what _it_ needs. The kernel's own `testRuntime` needs nothing, -so these four are what exercise `start`'s phantom rest-tuple gate and +`orderApiRuntime` declares `[PlaceOrder, FindOrder, Logger, httpConfig]`, +`temporalWorkerRuntime` its five application ports plus `temporalConfig`, and +`orderAmqpRuntime` `[Outbox, Logger, amqpConfig, outboxRelayConfig]` — a +selection of what the module exports, because a runtime declares what _it_ +needs. **Configuration is inside that selection**, which is the point: a config +is a port like any other, so a deployment that forgot to import one fails to +compile rather than to boot. The kernel's own `testRuntime` needs nothing, so +these are what exercise `start`'s phantom rest-tuple gate and `RuntimeHost`'s `Context>` — where a runtime names port _classes_ while di parameterises contexts by port _instances_ — against a real module here. `@btravstack/start-http`'s own `AppModule`/`Greeting` fixture @@ -169,9 +179,59 @@ and `order-amqp-worker/src/needs-gate.test-d.ts`: the wired call is an ordinary two-argument one, and a module one port short fails on **arity**, naming the missing need. +## Configuration + +Every deployment declares its configuration with +[`@btravstack/config`](../packages/config), and none of them has an `env.ts`. + +| Variable | Deployment | Declared by | Default | +| -------------------- | ----------------------- | ------------------- | ----------------------- | +| `HTTP_PORT` | `order-api` | `httpConfig` | `3000` | +| `TEMPORAL_ADDRESS` | `order-temporal-worker` | `temporalConfig` | `127.0.0.1:7233` | +| `TEMPORAL_NAMESPACE` | `order-temporal-worker` | `temporalConfig` | `default` | +| `AMQP_URL` | `order-amqp-worker` | `amqpConfig` | `amqp://127.0.0.1:5672` | +| `OUTBOX_POLL_MS` | `order-amqp-worker` | `outboxRelayConfig` | `200` | +| `PROBE_PORT` | all three | `probeConfig` | `9000` | + +Every name is what it was before the package existed, bar one: `order-api`'s +`HTTP_PORT` was `PORT`, and a bare `PORT` is not expressible — a config's +variables are `PREFIX_KEY`, and no prefix and key join to it. + +Three things changed, and all three are worth copying: + +**A config is one value, and it is a di port.** +`Config("Amqp")({ url: … })` is at once the token `ctx.get(amqpConfig)` reads +and the module `imports: [amqpConfig]` provides — no port declared here and an +adapter written for it there. `Config.source(process.env)` is the single place +the environment enters a graph, so a spec swaps the whole environment by +importing a different one (`order-amqp-worker`'s fixtures give each test its +own vhost that way). + +**It travels through the graph, not through `main`.** A runtime is handed a +`Context` at `start`, so it names its configs in `needs` and reads them itself. +`main.ts` no longer knows what a broker URL, a namespace or a listening port +is, and the needs gate proves the graph carries them before anything runs. + +**One report, not one deploy per typo.** +`Config.parse(Config.collect(Root), process.env)` validates every config +reachable from the composition root against one source and aggregates the lot +into a single `ConfigInvalid`; `describeIssues` prints one line per wrong +variable. Because `ConfigInvalid` is a `TaggedError`, the fold that reports it +enumerates it properly — the `P._` catch-all and its lint-disable, which the +old hand-rolled `SchemaIssues` fold needed, are gone from all three entry +points. + +One thing is **not** clean yet, and each `main.ts` says so at the exact line: +`PROBE_PORT` (and `order-temporal-worker`'s `TEMPORAL_ADDRESS`) is still read +straight from `process.env`, because `start` binds the probe server — and the +Temporal connection is opened — before the graph exists, and phase 1 has no +way to read one config's value outside a graph. Both are still declared and +still validated in the report above. Kernel integration is phase 2's, in +`@btravstack/start-core`. + ## Why these are tests, not just illustrations -Each package reads as application code, and each is covered by real specs — 86 +Each package reads as application code, and each is covered by real specs — 68 of them, run by the repository's own `pnpm test`: ```sh diff --git a/examples/order-amqp-worker/README.md b/examples/order-amqp-worker/README.md index 1eca199..71fa248 100644 --- a/examples/order-amqp-worker/README.md +++ b/examples/order-amqp-worker/README.md @@ -14,9 +14,9 @@ binding its own queue to the `orders` exchange needs it and needs none of this. ``` src/outbox-relay.ts the publishing half: sweep the outbox, publish, mark sent src/amqp-runtime.ts the runtime: start-amqp's consumer with the relay layered on +src/config.ts amqpConfig / probeConfig — @btravstack/config declarations src/module.ts OrderAmqpModule — the composition root -src/env.ts process.env validated through a schema, as a Result -src/main.ts the process: readEnv + start + runMain +src/main.ts the process: Config.parse + start + runMain src/test-fixtures.ts serve / tapped, as Vitest fixtures, against a real RabbitMQ ``` @@ -66,22 +66,64 @@ the consumer's alone — draining means "stop taking new work", and the relay's work is outbound: pending rows are safer published during the drain window than abandoned to the next boot. -The relay's needs are ports (`Outbox`, `Logger`), resolved from the same -application context the consumer's handler resolves — `start`'s needs gate -(`src/needs-gate.test-d.ts`) proves the composition root exports both, at -compile time. +The relay's needs are ports (`Outbox`, `Logger`, and the two configs below), +resolved from the same application context the consumer's handler resolves — +`start`'s needs gate (`src/needs-gate.test-d.ts`) proves the composition root +exports all four, at compile time. -## The environment +## The configuration -| Variable | Default | What it is | -| ---------------- | ----------------------- | ------------------------------------- | -| `AMQP_URL` | `amqp://127.0.0.1:5672` | the broker, for consumer and relay | -| `PROBE_PORT` | `9000` | `/livez` / `/readyz` | -| `OUTBOX_POLL_MS` | `200` | the relay's idle sleep between sweeps | +Three [`@btravstack/config`](../../packages/config) values, and no `env.ts`: + +| Variable | Declared in | Default | What it is | +| ---------------- | --------------------------------------- | ----------------------- | ------------------------------------- | +| `AMQP_URL` | `amqpConfig` (`src/config.ts`) | `amqp://127.0.0.1:5672` | the broker, for consumer and relay | +| `OUTBOX_POLL_MS` | `outboxRelayConfig` (`outbox-relay.ts`) | `200` | the relay's idle sleep between sweeps | +| `PROBE_PORT` | `probeConfig` (`src/config.ts`) | `9000` | `/livez` / `/readyz` | + +The names are unchanged; what changed is where they arrive. Each declaration +is **one value that is both a port token and the module serving it**, so +`imports: [amqpConfig]` provides it and `ctx.get(amqpConfig)` reads it back. +`main.ts` no longer knows what a broker URL is: the runtime declares the +configs in its `needs` and resolves them from the context `start` hands it, +exactly as it resolves `Outbox`. `OUTBOX_POLL_MS` is declared next to the loop +it tunes rather than in a central file — which is also why its config's +identity (`OutboxRelay`) and its prefix (`OUTBOX`) differ. `OUTBOX_POLL_MS=0` is rejected at boot — a relay that never sleeps is a busy -loop, and the deployment's own spec pins that where the shared `wholeNumber` -fragment's bounds would not. +loop, and its `wholeNumber(200, 1, 60_000)` lower bound says so where a port's +own bounds would not. + +`main.ts` validates **every** config in the graph before building it, and +reports them together: + +```ts +await Config.parse(Config.collect(OrderAmqpModule), process.env).match({ + ok: () => + runMain( + start(OrderAmqpModule, { + runtime: orderAmqpRuntime(), + probes: { port: probePort }, + }), + ), + errCases: (matcher) => + matcher.with(P.tag("config/ConfigInvalid"), (error) => + abort(describeIssues(error.issues)), + ), + defect: (cause) => + abort(`the configuration could not be validated: ${String(cause)}`), +}); +``` + +`probePort` is the one variable still read from `process.env` by hand: `start` +binds the probe server before it builds the graph, so its port cannot come out +of one. It is still declared and still validated above — see the comment at +that line, and phase 2's kernel integration. + +The specs swap the environment rather than the values: `src/test-fixtures.ts` +imports `Config.source({ AMQP_URL: , OUTBOX_POLL_MS: "25" })` +where `OrderAmqpModule` imports `Config.source(process.env)`, and nothing below +it can tell the difference. ## Running the specs @@ -93,7 +135,7 @@ arriving as a tombstone behind its placement, and the same event delivered to a subscriber this contract never heard of. ```bash -pnpm --filter @btravstack/start-example-order-amqp-worker test # broadcast e2e + env specs +pnpm --filter @btravstack/start-example-order-amqp-worker test # the broadcast end-to-end specs pnpm --filter @btravstack/start-example-order-amqp-worker typecheck # the needs gate ``` diff --git a/examples/order-api/README.md b/examples/order-api/README.md index bed78ad..6082cc4 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -11,9 +11,10 @@ src/router.ts the implementation, and the one place a domain error becom src/request-scope.ts RequestModule — a scope forked per request over the application's src/handler.ts apiHandler — the per-request forkScope, handed to httpRuntime src/client.ts an AsyncResult client for the same contract +src/api-runtime.ts orderApiRuntime — httpRuntime, with its port read from the graph +src/config.ts httpConfig / probeConfig — @btravstack/config declarations src/module.ts OrderApiModule — the composition root -src/env.ts process.env validated through a schema, as a Result -src/main.ts the process: readEnv + start + runMain +src/main.ts the process: Config.parse + start + runMain src/test-fixtures.ts serve / clientFor / gate / tapped, as Vitest fixtures ``` @@ -128,7 +129,7 @@ the server's `mapErrCases`. ## Running it ```bash -pnpm --filter @btravstack/start-example-order-api test # 15 api specs + 6 env specs +pnpm --filter @btravstack/start-example-order-api test # the 15 api specs ``` The specs run against a real HTTP server and a real oRPC client — genuine JSON @@ -149,54 +150,69 @@ it("lets an in-flight call finish while draining", async ({ serve, clientFor, ga }); ``` -`src/main.ts` is the process itself — and it reads its configuration the same way -it reads everything else, as a value: +## The configuration + +Two [`@btravstack/config`](../../packages/config) values, and no `env.ts`: + +| Variable | Declared in | Default | What it is | +| ------------ | ------------------------------- | ------- | ---------------------- | +| `HTTP_PORT` | `httpConfig` (`src/config.ts`) | `3000` | the port the API binds | +| `PROBE_PORT` | `probeConfig` (`src/config.ts`) | `9000` | `/livez` / `/readyz` | + +> **`HTTP_PORT` was `PORT`.** It is the one name this deployment changed, and +> not by choice: a config's variables are `PREFIX_KEY`, and no prefix and key +> join to a bare `PORT`. Every other variable in `examples/` is byte-for-byte +> what it was. + +Each declaration is **one value that is both a port token and the module +serving it**, so `imports: [httpConfig]` provides it and `ctx.get(httpConfig)` +reads it back. `main.ts` never learns what a port is: `orderApiRuntime` +(`src/api-runtime.ts`) declares `httpConfig` in its `needs` and resolves it +from the context `start` hands it, then builds `@btravstack/start-http`'s +runtime with what it found. + +`src/main.ts` is the process itself — and it validates **every** config in the +graph before building it, reporting them together: ```ts -await readEnv().match({ - ok: (env) => +await Config.parse(Config.collect(OrderApiModule), process.env).match({ + ok: () => runMain( start(OrderApiModule, { - runtime: httpRuntime({ - port: env.PORT, - needs: [PlaceOrder, FindOrder, Logger], - handler: apiHandler, - }), - probes: { port: env.PROBE_PORT }, + runtime: orderApiRuntime(), + probes: { port: probePort }, }), ), errCases: (matcher) => - matcher.with(P._, (issues) => abort(describeEnvIssues(issues))), + matcher.with(P.tag("config/ConfigInvalid"), (error) => + abort(describeIssues(error.issues)), + ), defect: (cause) => - abort(`the environment could not be validated: ${String(cause)}`), + abort(`the configuration could not be validated: ${String(cause)}`), }); ``` -`src/env.ts` is where `PORT` and `PROBE_PORT` are validated. It goes through -`@unthrown/standard-schema`'s `fromSchema` rather than a schema's own `.parse()`, -because `.parse()` throws — which `unthrown/no-throw` bans, and which would -contradict the example it appears in. The issues are the modeled `E`, folded -above into a message and a non-zero exit code. - -A port is a **non-empty string piped into a coercion**, never a bare -`z.coerce.number()`: +`ConfigInvalid` is a `TaggedError`, so the matcher names it. The fold this +replaced reported one schema's issues and needed a `P._` escape hatch behind a +lint-disable, because a `SchemaIssues` array is a single type with no +discriminant to enumerate. And the report now covers the whole graph: an +operator who mistyped three variables across two packages learns all three +from one failed boot. -```ts -z.string() - .trim() - .min(1) - .pipe(z.coerce.number().int().min(0).max(65_535)) - .default(fallback); -``` +`probePort` is the one variable still read from `process.env` by hand: `start` +binds the probe server before it builds the graph, so its port cannot come out +of one. It is still declared and still validated above — see the comment at +that line, and phase 2's kernel integration. -Coercion is `Number()` underneath, so `PORT=abc` would bind `NaN` and `PORT=` -would bind `0`, the ephemeral port. The bounds catch the first — and every -`PORT=3.5` or `PORT=99999` after it — but they cannot catch the second, because -a port's `min` **is** `0` so that an ephemeral bind stays expressible. The -non-empty string in front is what closes it: an empty value is a configuration -error, not an absent one, and `.default(...)` applies only when the variable is -genuinely missing. A malformed value is a validation issue instead. +A port is a **non-empty string piped into a coercion**, never a bare +`z.coerce.number()` — `@btravstack/config/zod`'s `port(fallback)` is that +fragment, and its own docs carry the reasoning: coercion is `Number()` +underneath, so `HTTP_PORT=` would bind the ephemeral port `0` and the bounds +cannot catch it, because a port's `min` **is** `0`. It is typechecked by the gate rather than executed by it: the example packages are source-only — no build step, `main` pointing straight at `src/` — so there is no compiled entry for `node` to run, and every spec drives `start` directly. +The specs bind `port: 0` through `httpRuntime` directly, because an ephemeral +bind read back off `Serving.info` is a property of the test rather than of the +deployment; `src/needs-gate.test-d.ts` is what covers `orderApiRuntime`. diff --git a/examples/order-temporal-worker/README.md b/examples/order-temporal-worker/README.md index cd1ef3a..2c21a9c 100644 --- a/examples/order-temporal-worker/README.md +++ b/examples/order-temporal-worker/README.md @@ -15,9 +15,9 @@ starts these workflows needs it and needs none of this. src/workflows.ts fulfillOrder — the saga, in Temporal's deterministic sandbox src/temporal-runtime.ts the runtime: five activities and their triage into contract errors src/fulfillment.ts FulfillmentModule — the two external services, as stand-ins +src/config.ts temporalConfig / probeConfig — @btravstack/config declarations src/module.ts OrderTemporalModule — the composition root -src/env.ts process.env validated through a schema, as a Result -src/main.ts the process: readEnv + connect + start + runMain +src/main.ts the process: Config.parse + connect + start + runMain src/test-fixtures.ts serve / fulfilling / outOfStock / noShipping, against the time-skipping env ``` @@ -64,13 +64,45 @@ compensation paths run, against the real application and the real persistence: after a refusal, the spec reads the database through the same repository the saga used and finds the placement gone. -## The environment +## The configuration + +Two [`@btravstack/config`](../../packages/config) values, and no `env.ts`: + +| Variable | Declared in | Default | What it is | +| -------------------- | ---------------------------------- | ---------------- | -------------------- | +| `TEMPORAL_ADDRESS` | `temporalConfig` (`src/config.ts`) | `127.0.0.1:7233` | the Temporal service | +| `TEMPORAL_NAMESPACE` | `temporalConfig` (`src/config.ts`) | `default` | must not be blank | +| `PROBE_PORT` | `probeConfig` (`src/config.ts`) | `9000` | `/livez` / `/readyz` | + +The names are unchanged; what changed is where they arrive. A declaration is +**one value that is both a port token and the module serving it**, so +`imports: [temporalConfig]` provides it and `ctx.get(temporalConfig)` reads it +back — and `temporalWorkerRuntime` does exactly that for the namespace, +declaring the config in its `needs` instead of taking it as a parameter. + +`main.ts` validates every config in the graph before building it, in one +report: + +```ts +await Config.parse(Config.collect(OrderTemporalModule), process.env).match({ + ok: () => work(), + errCases: (matcher) => + matcher.with(P.tag("config/ConfigInvalid"), (error) => + abort(describeIssues(error.issues)), + ), + defect: (cause) => + abort(`the configuration could not be validated: ${String(cause)}`), +}); +``` -| Variable | Default | What it is | -| -------------------- | ---------------- | -------------------- | -| `TEMPORAL_ADDRESS` | `127.0.0.1:7233` | the Temporal service | -| `TEMPORAL_NAMESPACE` | `default` | must not be blank | -| `PROBE_PORT` | `9000` | `/livez` / `/readyz` | +Two of the three are still read from `process.env` by hand, and the comment at +those lines says why: `start` binds the probe server before it builds the +graph, and the `NativeConnection` has to be **open** before the runtime can be +handed it — so neither value can come out of a graph that does not exist yet. +Both are still declared and still validated above. Phase 2's kernel +integration is what removes them. The connection itself is not configuration: +it is a resource with an owner, opened and closed by whoever opened it, which +is why the runtime is handed one rather than resolving it. ## Running the specs @@ -81,7 +113,7 @@ saga fulfills, both refusals compensate, and the duplicate-order answer arrives at the client as a typed contract error it can branch on by name. ```bash -pnpm --filter @btravstack/start-example-order-temporal-worker test # the saga + env specs +pnpm --filter @btravstack/start-example-order-temporal-worker test # the saga specs pnpm --filter @btravstack/start-example-order-temporal-worker typecheck # the needs gate ``` diff --git a/packages/config/README.md b/packages/config/README.md index 87d3944..8ed3788 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -200,9 +200,67 @@ 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. +## Seeing it used + +[`examples/`](../../examples) is three real deployments on this package, and +none of them has an `env.ts` any more — `examples/order-config`, the hand-rolled +prototype of everything above, was deleted when they moved over. + +- [`order-amqp-worker`](../../examples/order-amqp-worker) is the fullest + version: `amqpConfig` and `probeConfig` in `src/config.ts`, + `outboxRelayConfig` declared next to the loop it tunes in + `src/outbox-relay.ts` (identity `OutboxRelay`, prefix `OUTBOX`), a runtime + naming all of them in its `needs` and reading them off `host.ctx` instead of + taking them as parameters, and a test fixture that swaps + `Config.source(process.env)` for a record of its own so each test gets its + own broker vhost. +- [`order-api`](../../examples/order-api) is the same shape one layer thinner, + and [`order-temporal-worker`](../../examples/order-temporal-worker) shows one + config split across the graph boundary: `TEMPORAL_NAMESPACE` travels through + di, `TEMPORAL_ADDRESS` cannot yet — see below. + +Each `main.ts` is the one-report boot in full: + +```ts +await Config.parse(Config.collect(OrderAmqpModule), process.env).match({ + ok: () => + runMain( + start(OrderAmqpModule, { + runtime: orderAmqpRuntime(), + probes: { port: probePort }, + }), + ), + errCases: (matcher) => + matcher.with(P.tag("config/ConfigInvalid"), (error) => + abort(describeIssues(error.issues)), + ), + defect: (cause) => + abort(`the configuration could not be validated: ${String(cause)}`), +}); +``` + +Two things `examples/` surfaced that nothing inside this package could, since +nothing here exports a declared config or boots a process: + +- **`DeclaredConfig` had to become a named, exported type.** A consumer that + `export`s a declared config has an inferred type its own declaration emit + must be able to _write_ down, and the inline intersection expanded to brands + neither di nor this package exported. Annotating `Config`'s return with one + exported alias closes the declaring half. The _exporting_ half — a + composition root whose `Module` carries a config **instance** — still + needs `@btravstack/di` to export `PortInstance`, so such a root has to be + compiled with `declaration` off for now, which is what the example + deployments do. +- **`probes: { port }` has no clean answer in phase 1**, because `start` binds + the probe server before the graph exists. The examples declare `PROBE_PORT` + as a config anyway — so it is validated and reported with everything else — + and read the raw variable at that one line, with a comment naming the + constraint. + ## 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. +`EX_CONFIG`, giving `start` a source of its own so `probes: { port }` and +anything else the kernel needs before the graph can come out of a 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. From d25ce6c865814faf55473d46b66cc2c486f81e5a Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 18:02:11 +0200 Subject: [PATCH 09/11] refactor(examples): restore declaration checking now that di names PortInstance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three deployables had turned off `declaration`/`declarationMap` because a composition root exporting a config emitted `Module>`, 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. --- examples/order-amqp-worker/tsconfig.json | 10 ---------- examples/order-api/tsconfig.json | 10 ---------- examples/order-temporal-worker/tsconfig.json | 10 ---------- 3 files changed, 30 deletions(-) diff --git a/examples/order-amqp-worker/tsconfig.json b/examples/order-amqp-worker/tsconfig.json index b414cfc..3faf372 100644 --- a/examples/order-amqp-worker/tsconfig.json +++ b/examples/order-amqp-worker/tsconfig.json @@ -1,16 +1,6 @@ { "extends": "@btravstack/tsconfig/base.json", "compilerOptions": { - // Off, with `declarationMap`, because this package emits no declarations - // at all — it is source-only, `main` points at `src/`, and nothing ever - // consumes a `.d.ts` of it. The check they enable is real elsewhere and - // unmeetable here: a composition root that exports a `@btravstack/config` - // value emits `Module | …>`, which reduces - // to `@btravstack/di`'s `PortInstance` — deliberately unexported, so a port - // instance cannot be hand-forged, and therefore unnameable by an emitted - // declaration. See `packages/config/src/slice.ts`'s `DeclaredConfig`. - "declaration": false, - "declarationMap": false, "noEmit": true, "types": ["node"], // Inherited from the persistence layer this package composes: the generated diff --git a/examples/order-api/tsconfig.json b/examples/order-api/tsconfig.json index b414cfc..3faf372 100644 --- a/examples/order-api/tsconfig.json +++ b/examples/order-api/tsconfig.json @@ -1,16 +1,6 @@ { "extends": "@btravstack/tsconfig/base.json", "compilerOptions": { - // Off, with `declarationMap`, because this package emits no declarations - // at all — it is source-only, `main` points at `src/`, and nothing ever - // consumes a `.d.ts` of it. The check they enable is real elsewhere and - // unmeetable here: a composition root that exports a `@btravstack/config` - // value emits `Module | …>`, which reduces - // to `@btravstack/di`'s `PortInstance` — deliberately unexported, so a port - // instance cannot be hand-forged, and therefore unnameable by an emitted - // declaration. See `packages/config/src/slice.ts`'s `DeclaredConfig`. - "declaration": false, - "declarationMap": false, "noEmit": true, "types": ["node"], // Inherited from the persistence layer this package composes: the generated diff --git a/examples/order-temporal-worker/tsconfig.json b/examples/order-temporal-worker/tsconfig.json index b414cfc..3faf372 100644 --- a/examples/order-temporal-worker/tsconfig.json +++ b/examples/order-temporal-worker/tsconfig.json @@ -1,16 +1,6 @@ { "extends": "@btravstack/tsconfig/base.json", "compilerOptions": { - // Off, with `declarationMap`, because this package emits no declarations - // at all — it is source-only, `main` points at `src/`, and nothing ever - // consumes a `.d.ts` of it. The check they enable is real elsewhere and - // unmeetable here: a composition root that exports a `@btravstack/config` - // value emits `Module | …>`, which reduces - // to `@btravstack/di`'s `PortInstance` — deliberately unexported, so a port - // instance cannot be hand-forged, and therefore unnameable by an emitted - // declaration. See `packages/config/src/slice.ts`'s `DeclaredConfig`. - "declaration": false, - "declarationMap": false, "noEmit": true, "types": ["node"], // Inherited from the persistence layer this package composes: the generated From 785ff8e1e1b1cb7b2a6864a5d91d204f88689846 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 18:27:35 +0200 Subject: [PATCH 10/11] docs(changeset): describe the API config actually ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/scoped-config-slices.md | 40 ++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/.changeset/scoped-config-slices.md b/.changeset/scoped-config-slices.md index 42fc7bd..3d35d79 100644 --- a/.changeset/scoped-config-slices.md +++ b/.changeset/scoped-config-slices.md @@ -2,17 +2,31 @@ "@btravstack/config": minor --- -configuration parsed from the environment, as a port and adapter +configuration parsed from the environment, as one value that is both port and module -`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. +`Config(id)(shape, options?)` declares a single value that is both a +`@btravstack/di` port token and the module serving it from the environment, +so `imports: [amqpConfig]` and `ctx.get(amqpConfig)` name the same thing. The +signature mirrors `@btravstack/entity`'s `Entity(tag)(fields, options?)`: +curried on the identity, then the field map, then an optional `{ prefix }` — +omitted, the prefix is the screaming-snake of the identity. Each field is +validated by any Standard Schema library. + +Being one value costs nothing in adaptability, because it is a token first: +its module statics are consulted only when it appears in a module's +`imports:`, so a test can hand it a literal `Provider(amqpConfig)({ value })` +without importing it, with no environment involved. + +`Config.source` provides the environment as a port rather than an ambient +`process.env` read, so validation and injection can never disagree about what +the environment was. `Config.collect` walks a module tree for every reachable +config, and `Config.parse` validates them all against one source, aggregating +every wrong variable into one `ConfigInvalid` instead of stopping at the +first — an operator who mistyped three variables learns all three from one +failed boot. `describeIssues` formats them, `ConfigType` names a parsed +config's type, and `@btravstack/config/zod` ships `wholeNumber` and `port` +builders that guard the `Number("") === 0` trap. + +Needs a `@btravstack/di` that exports `ConcretePortClass` and `PortInstance` +— the two names declaration emit requires for a port built from data, one for +declaring a config and one for exporting it from a composition root. From 82377ebb196d84c716883c22c04fdb65dff94d0c Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 18:31:07 +0200 Subject: [PATCH 11/11] chore: update the lockfile for the config package and order-config's 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. --- pnpm-lock.yaml | 55 +++++++++----------------------------------------- 1 file changed, 9 insertions(+), 46 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a09dbc9..e3fa96d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: '@amqp-contract/worker': specifier: 'catalog:' version: 3.0.0-beta.6(@opentelemetry/api@1.9.1)(unthrown@5.5.0) + '@btravstack/config': + specifier: workspace:* + version: link:../../packages/config '@btravstack/di': specifier: 'catalog:' version: 0.1.0(unthrown@5.5.0) @@ -240,18 +243,12 @@ importers: '@btravstack/start-example-order-application': specifier: workspace:* version: link:../order-application - '@btravstack/start-example-order-config': - specifier: workspace:* - version: link:../order-config '@btravstack/start-example-order-infrastructure': specifier: workspace:* version: link:../order-infrastructure '@opentelemetry/api': specifier: 'catalog:' version: 1.9.1 - '@unthrown/standard-schema': - specifier: 'catalog:' - version: 5.5.0 unthrown: specifier: 'catalog:' version: 5.5.0 @@ -280,6 +277,9 @@ importers: examples/order-api: dependencies: + '@btravstack/config': + specifier: workspace:* + version: link:../../packages/config '@btravstack/di': specifier: 'catalog:' version: 0.1.0(unthrown@5.5.0) @@ -292,9 +292,6 @@ importers: '@btravstack/start-example-order-application': specifier: workspace:* version: link:../order-application - '@btravstack/start-example-order-config': - specifier: workspace:* - version: link:../order-config '@btravstack/start-example-order-domain': specifier: workspace:* version: link:../order-domain @@ -313,9 +310,6 @@ importers: '@unthrown/orpc': specifier: 'catalog:' version: 0.1.2(@orpc/client@2.0.0-beta.23(@opentelemetry/api@1.9.1))(@orpc/server@2.0.0-beta.23(@opentelemetry/api@1.9.1)) - '@unthrown/standard-schema': - specifier: 'catalog:' - version: 5.5.0 unthrown: specifier: 'catalog:' version: 5.5.0 @@ -401,34 +395,6 @@ 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) - examples/order-config: - dependencies: - '@unthrown/standard-schema': - specifier: 'catalog:' - version: 5.5.0 - zod: - specifier: 'catalog:' - version: 4.4.3 - devDependencies: - '@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) - 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) - examples/order-domain: dependencies: '@btravstack/entity': @@ -536,6 +502,9 @@ importers: examples/order-temporal-worker: dependencies: + '@btravstack/config': + specifier: workspace:* + version: link:../../packages/config '@btravstack/di': specifier: 'catalog:' version: 0.1.0(unthrown@5.5.0) @@ -545,9 +514,6 @@ importers: '@btravstack/start-example-order-application': specifier: workspace:* version: link:../order-application - '@btravstack/start-example-order-config': - specifier: workspace:* - version: link:../order-config '@btravstack/start-example-order-domain': specifier: workspace:* version: link:../order-domain @@ -575,9 +541,6 @@ importers: '@temporalio/workflow': specifier: 'catalog:' version: 1.22.0 - '@unthrown/standard-schema': - specifier: 'catalog:' - version: 5.5.0 unthrown: specifier: 'catalog:' version: 5.5.0