-
Notifications
You must be signed in to change notification settings - Fork 0
feat(config): move @btravstack/config into the monorepo #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)<Service>` — | ||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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)<Service>` — 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, type ValueOf } from "@btravstack/config"; | ||||||||||||||||||||||||||||||||||||||
| import { Port } from "@btravstack/di"; | ||||||||||||||||||||||||||||||||||||||
| import { z } from "zod"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const shape = { | ||||||||||||||||||||||||||||||||||||||
| url: z.string().min(1).default("amqp://127.0.0.1:5672"), | ||||||||||||||||||||||||||||||||||||||
| prefetch: z.string().min(1).pipe(z.coerce.number<string>().int()).default(10), | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
| export class AmqpConfig extends Port("AmqpConfig")<ValueOf<typeof shape>> {} | ||||||||||||||||||||||||||||||||||||||
| export const AmqpConfigFromEnv = Config(AmqpConfig, "AMQP")(shape); | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+18
to
+27
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why cannot we do that ?
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can — but not for the reason I gave you earlier, and I owe you a correction first. What I got wrong. I argued the welded slice could not be adapted: that a test could not write What does block your snippet, concretely: injection needs a constructor. The way to avoid the accessor is to make the returned value both a module and a port class — which is exactly the shape we removed, and the reason is mechanical rather than architectural: a factory that applies So your snippet is available at a known price: reinstate that di export, and re-open the PR I closed. No other cost — the emit fix was one exported type plus a regression guard, both already written and verified. And What the split still buys, stated honestly now that adaptability is off the table: no library change (di stays as published), the port declared where a reader looks for it, and a compile-time check that the adapter's parsed output satisfies the port's service — which a single welded value has no way to state, because there is nothing to check it against. What it costs: two names, and the Your call — I will take either, and the one-value version is a small, already-proven change. |
||||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| Each value in `shape` is a [Standard Schema](https://standardschema.dev) | ||||||||||||||||||||||||||||||||||||||
| validator — a `zod` schema above, but any Standard-Schema-compliant library | ||||||||||||||||||||||||||||||||||||||
| works. `ValueOf<typeof shape>` 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<string, string | undefined>` 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")<ValueOf<typeof httpShape>> {} | ||||||||||||||||||||||||||||||||||||||
| 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. | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <benoit.travers.fr@gmail.com>", | ||
| "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" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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")<ValueOf<typeof aShape>> {} | ||
| const A = Config(APort, "A")(aShape); | ||
|
|
||
| const bShape = { y: z.string().default("b") }; | ||
| class BPort extends Port("B")<ValueOf<typeof bShape>> {} | ||
| 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([]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
illustrate it using it in the examples/