@bymax-one/nest-config 1.2.2 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -1
- package/README.md +35 -22
- package/dist/internal/index.cjs +80 -7
- package/dist/internal/index.d.cts +42 -2
- package/dist/internal/index.d.ts +42 -2
- package/dist/internal/index.mjs +79 -8
- package/dist/testing/index.cjs +39 -5
- package/dist/testing/index.mjs +40 -6
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,92 @@ as the GitHub Release body, so each released version needs a matching
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.2.4] - 2026-11-01
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **A `source` that is present but `undefined` fails the boot.** `BymaxConfigModule.forRoot({ schema,
|
|
19
|
+
source: loadSource() })` used to validate `process.env` when the loader returned nothing, and boot
|
|
20
|
+
successfully on configuration the host never meant to use. Passing the key with the value `undefined` or
|
|
21
|
+
`null` now throws a `TypeError` ("source was provided but is undefined or null"); omitting the key still reads
|
|
22
|
+
`process.env`.
|
|
23
|
+
|
|
24
|
+
#### Apply to a derived backend
|
|
25
|
+
|
|
26
|
+
Search for a `source:` option that can be `undefined`. If the host relied on the fall-back to
|
|
27
|
+
`process.env`, leave the key out instead of passing `undefined`.
|
|
28
|
+
|
|
29
|
+
- **A `meta({ env })` that cannot name a variable is an issue.** An `env` that is present but is not
|
|
30
|
+
a non-empty string (`''`, `undefined`, a number) used to be skipped, and the leaf read the variable
|
|
31
|
+
derived from its path. The aggregated validation report now lists it, naming the variable the leaf
|
|
32
|
+
would read and the kind of value found, on any layer of the leaf: an inner layer whose `env` is
|
|
33
|
+
unusable is reported even when an outer layer's usable one wins. The issue is its own line for
|
|
34
|
+
that leaf, before the line about its value. A leaf with no `env` at all is unchanged, and
|
|
35
|
+
`resolveSourceNames` still answers the derived name.
|
|
36
|
+
|
|
37
|
+
#### Apply to a derived backend
|
|
38
|
+
|
|
39
|
+
Search the schemas for `meta({ env: ... })` whose argument can be empty or undefined (for example
|
|
40
|
+
`meta({ env: process.env.X })`) and give it a real name or remove the call.
|
|
41
|
+
|
|
42
|
+
- **A test override that matches no leaf throws.** `createTestConfig` and `configTestingModule` used to
|
|
43
|
+
drop an override for a path the schema does not declare (a misspelled leaf, an undeclared namespace,
|
|
44
|
+
a stale override after a rename) and answer with a config that lacked it. They now throw a
|
|
45
|
+
`BymaxConfigValidationError` listing every unmatched path, before any provider is built, even when
|
|
46
|
+
the override's value is `undefined`. The path is the name shown in the report, and its footer asks
|
|
47
|
+
for a fix in the test rather than a restart of the process. A namespace given something other than
|
|
48
|
+
an object is reported once, by name, instead of once per character.
|
|
49
|
+
|
|
50
|
+
#### Apply to a derived backend
|
|
51
|
+
|
|
52
|
+
Run the test suites: an override that used to be silently ignored now fails. Correct the path or
|
|
53
|
+
delete the override.
|
|
54
|
+
|
|
55
|
+
### Documentation
|
|
56
|
+
|
|
57
|
+
- A leaf that ends in `.catch(fallback)` replaces an invalid value with its fallback and reports no
|
|
58
|
+
issue; the README now says so.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **A release that changes the public declarations must say so.** `check:api`
|
|
63
|
+
(`scripts/check-api-surface.mjs`) compares the built declarations with the previous published
|
|
64
|
+
version and fails when they differ unless the released version's CHANGELOG section has an
|
|
65
|
+
"Apply to a derived backend" note. Tooling only: nothing to apply in a derived backend.
|
|
66
|
+
- **The mutation gate is 100.** `thresholds.break` moves from 95 to 100, and `high` and `low` with it,
|
|
67
|
+
because a cold run on `main` now has no survivor. Tooling only: nothing to apply in
|
|
68
|
+
a derived backend.
|
|
69
|
+
- **The camelCase-to-variable converter has no equivalent mutant.** The acronym boundary is now a
|
|
70
|
+
zero-width lookaround instead of a capturing group with a quantifier whose shrunken form no input
|
|
71
|
+
could tell apart. Every derived variable name is unchanged: a new spec compares the converter
|
|
72
|
+
with the previous two-expression form on every string up to seven characters over
|
|
73
|
+
`a`, `B`, `C`, `1` and `_`. Nothing to apply in a derived backend.
|
|
74
|
+
|
|
75
|
+
### Tests
|
|
76
|
+
|
|
77
|
+
- **The README Quick Start is executed, not only type-checked.** A new spec takes the schema, the
|
|
78
|
+
`BymaxConfigModule.forRoot` registration and the `FeatureProvider` from the README text,
|
|
79
|
+
compiles and runs them against a real Nest testing graph, and asserts the stated outcomes: a
|
|
80
|
+
complete environment boots and the provider returns `<database url>::3000`, and an environment
|
|
81
|
+
missing both required variables fails with one error naming both. A wrong argument shape or a
|
|
82
|
+
value that fails validation now fails a test instead of reaching a reader. No source or
|
|
83
|
+
published-surface change, so there is nothing to apply in a derived backend.
|
|
84
|
+
|
|
85
|
+
## [1.2.3] - 2026-10-31
|
|
86
|
+
|
|
87
|
+
### Fixed
|
|
88
|
+
|
|
89
|
+
- **A schema in which two leaves read one variable is rejected.** `a.bC` and `aB.c` both
|
|
90
|
+
derive `A_B_C`, and `httpServer.port` and `HTTPServer.port` both derive
|
|
91
|
+
`HTTP_SERVER_PORT`; so does giving two leaves the same `meta({ env })`. Each pair used to
|
|
92
|
+
read one value silently, so setting the variable satisfied both leaves and neither could
|
|
93
|
+
tell. `forRoot`, `createValidatedConfig` and the testing helpers now throw a
|
|
94
|
+
`BymaxConfigValidationError` before the source is read, with one `BYMAX_CONFIG_INVALID`
|
|
95
|
+
issue per colliding leaf naming the other leaves and the shared variable. There is no
|
|
96
|
+
opt-out. `resolveSourceNames` still returns every binding. **Apply to a derived
|
|
97
|
+
backend:** a colliding schema now fails at boot. Give one leaf of each pair a distinct
|
|
98
|
+
`meta({ env })`, or read one leaf and derive the other value from it in the consumer.
|
|
99
|
+
|
|
14
100
|
## [1.2.2] - 2026-10-31
|
|
15
101
|
|
|
16
102
|
### Added
|
|
@@ -468,4 +554,6 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
468
554
|
[1.2.0]: https://github.com/bymaxone/nest-config/compare/v1.1.3...v1.2.0
|
|
469
555
|
[1.2.1]: https://github.com/bymaxone/nest-config/compare/v1.2.0...v1.2.1
|
|
470
556
|
[1.2.2]: https://github.com/bymaxone/nest-config/compare/v1.2.1...v1.2.2
|
|
471
|
-
[
|
|
557
|
+
[1.2.3]: https://github.com/bymaxone/nest-config/compare/v1.2.2...v1.2.3
|
|
558
|
+
[1.2.4]: https://github.com/bymaxone/nest-config/compare/v1.2.3...v1.2.4
|
|
559
|
+
[Unreleased]: https://github.com/bymaxone/nest-config/compare/v1.2.4...HEAD
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<a href="https://www.npmjs.com/package/@bymax-one/nest-config"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-config?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
|
|
15
15
|
<a href="https://github.com/bymaxone/nest-config/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-config/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
|
|
16
16
|
<a href="https://github.com/bymaxone/nest-config/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square&colorA=000000" alt="coverage" /></a>
|
|
17
|
-
<a href="https://github.com/bymaxone/nest-config/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-
|
|
17
|
+
<a href="https://github.com/bymaxone/nest-config/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-100.00%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
|
|
18
18
|
<a href="https://scorecard.dev/viewer/?uri=github.com/bymaxone/nest-config"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-config/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
|
|
19
19
|
<a href="https://github.com/bymaxone/nest-config/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-config?style=flat-square&colorA=000000&colorB=000000" alt="license" /></a>
|
|
20
20
|
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
|
|
@@ -206,8 +206,10 @@ BymaxConfigValidationError: environment validation failed (3 issues)
|
|
|
206
206
|
Fix the variables above and restart the process.
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
-
The report has one line per
|
|
210
|
-
one Zod lists, and fixing it can reveal the next on the following start.
|
|
209
|
+
The report has one line per violation: when a leaf fails several rules, the line shows the first
|
|
210
|
+
one Zod lists, and fixing it can reveal the next on the following start. A `meta({ env })` that
|
|
211
|
+
cannot name a variable adds its own line for that leaf, before the line about its value, because it
|
|
212
|
+
is a different mistake: merging the two would hide the one that explains the other.
|
|
211
213
|
|
|
212
214
|
In a message this package generates, raw values are never printed — not
|
|
213
215
|
truncated, not masked, absent. That is a hard guarantee, verified by tests. The
|
|
@@ -265,6 +267,10 @@ including `'false'`, becomes `true`, so use `z.stringbool()` for a flag. And a v
|
|
|
265
267
|
empty string is present, not missing: `z.string()` accepts it and `z.coerce.number()` turns it into
|
|
266
268
|
`0`, so add `.min(1)` or a bound where empty should fail.
|
|
267
269
|
|
|
270
|
+
A leaf that ends in `.catch(fallback)` replaces an invalid value with its fallback and reports no
|
|
271
|
+
issue, so a mistyped variable reaches the application as the fallback; leave `.catch()` off a
|
|
272
|
+
leaf whose failure should stop the boot.
|
|
273
|
+
|
|
268
274
|
A leaf can override its derived variable name through schema metadata when a
|
|
269
275
|
legacy name must be preserved:
|
|
270
276
|
|
|
@@ -276,9 +282,12 @@ database: z.object({
|
|
|
276
282
|
```
|
|
277
283
|
|
|
278
284
|
The override is found wherever `meta` sits in the chain: `z.string().meta({ env: 'LEGACY' }).default('x')`
|
|
279
|
-
and `z.string().default('x').meta({ env: 'LEGACY' })` both read `LEGACY`. When more than one layer sets it, the outermost usable one wins
|
|
280
|
-
|
|
281
|
-
|
|
285
|
+
and `z.string().default('x').meta({ env: 'LEGACY' })` both read `LEGACY`. When more than one layer sets it, the outermost usable one wins. An `env` that is present but
|
|
286
|
+
is not a non-empty string (`''`, `undefined`, a number) cannot name a variable, so validation reports it
|
|
287
|
+
as an issue instead of letting the leaf read the variable derived from its path, on any layer: an
|
|
288
|
+
inner layer whose `env` is unusable is reported even when an outer layer's usable one wins. A leaf
|
|
289
|
+
with no `env` at all is unaffected. `resolveSourceNames` skips an unusable `env` and answers the
|
|
290
|
+
derived name.
|
|
282
291
|
|
|
283
292
|
A namespace can declare itself **open**, which waives strict unknown-key
|
|
284
293
|
detection for the rest of its prefix. Use it when the prefix is shared with
|
|
@@ -309,7 +318,7 @@ Write `.meta({ open: true })` last: methods that build a new object schema, such
|
|
|
309
318
|
| Field | Type | Required | Notes |
|
|
310
319
|
| ------------------- | ------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
311
320
|
| `schema` | `EnvSchema` | yes | Produced by `defineEnv`. |
|
|
312
|
-
| `source` | `Record<string, string \| undefined>` | no | Defaults to `process.env`
|
|
321
|
+
| `source` | `Record<string, string \| undefined>` | no | Defaults to `process.env` when the key is omitted; a key present with the value `undefined` or `null` throws at boot. Injectable for tests. |
|
|
313
322
|
| `onValidationError` | `(issues: ReadonlyArray<ConfigIssue>) => unknown` | no | Observability hook invoked before the fail-fast throw; a promise it returns is awaited. Cannot suppress the failure. |
|
|
314
323
|
| `strict` | `boolean` | no | When true, source variables matching a namespace prefix but no declared leaf raise `BYMAX_CONFIG_UNKNOWN_KEY`. Defaults to `false`. Read [Strict mode and shared prefixes](#strict-mode-and-shared-prefixes) before enabling it. |
|
|
315
324
|
|
|
@@ -604,7 +613,8 @@ resolves its variables through, so the list cannot drift from what `forRoot` rea
|
|
|
604
613
|
`SourceBinding` is `{ path: string; variable: string }`: the leaf's dot path and the
|
|
605
614
|
environment variable it reads. The list has one entry per declared leaf, in declaration
|
|
606
615
|
order. A leaf reads its `meta({ env })` override when that is a non-empty string, and
|
|
607
|
-
|
|
616
|
+
`NAMESPACE_LEAF` in SCREAMING_SNAKE_CASE, with a camelCase key split into words, when it has no
|
|
617
|
+
`env` at all.
|
|
608
618
|
It reads the schema only, never a source record, so it works on a schema exported as a
|
|
609
619
|
value.
|
|
610
620
|
|
|
@@ -638,8 +648,9 @@ it('documents every variable the schema reads in .env.example', () => {
|
|
|
638
648
|
the schema reads it.
|
|
639
649
|
- **Two paths can read one variable.** Names are derived from the keys, so `a.bC` and
|
|
640
650
|
`aB.c` both derive `A_B_C`, and `httpServer.port` and `HTTPServer.port` both derive
|
|
641
|
-
`HTTP_SERVER_PORT`. Every binding is returned and none is dropped
|
|
642
|
-
|
|
651
|
+
`HTTP_SERVER_PORT`. Every binding is returned and none is dropped, so a tool can see the
|
|
652
|
+
collision; validation rejects such a schema (see the error catalog). Giving two leaves
|
|
653
|
+
the same `meta({ env })` is the same collision, and there is no opt-out.
|
|
643
654
|
- **An open namespace changes nothing.** `meta({ open: true })` only waives strict
|
|
644
655
|
unknown-key detection, so the list is the same whether a namespace is open or closed. It
|
|
645
656
|
does not help to find variables in `.env.example` that no leaf reads.
|
|
@@ -648,12 +659,12 @@ it('documents every variable the schema reads in .env.example', () => {
|
|
|
648
659
|
|
|
649
660
|
## 🚨 Error Catalog
|
|
650
661
|
|
|
651
|
-
| Code | Meaning
|
|
652
|
-
| -------------------------- |
|
|
653
|
-
| `BYMAX_CONFIG_VALIDATION` | One or more schema violations (top-level error code).
|
|
654
|
-
| `BYMAX_CONFIG_MISSING` | Required variable absent from the source (issue code).
|
|
655
|
-
| `BYMAX_CONFIG_INVALID` |
|
|
656
|
-
| `BYMAX_CONFIG_UNKNOWN_KEY` | Strict mode: source variable matches no declared leaf.
|
|
662
|
+
| Code | Meaning |
|
|
663
|
+
| -------------------------- | --------------------------------------------------------- |
|
|
664
|
+
| `BYMAX_CONFIG_VALIDATION` | One or more schema violations (top-level error code). |
|
|
665
|
+
| `BYMAX_CONFIG_MISSING` | Required variable absent from the source (issue code). |
|
|
666
|
+
| `BYMAX_CONFIG_INVALID` | Failed its constraint, or shares a variable (issue code). |
|
|
667
|
+
| `BYMAX_CONFIG_UNKNOWN_KEY` | Strict mode: source variable matches no declared leaf. |
|
|
657
668
|
|
|
658
669
|
## 🏗️ Architecture
|
|
659
670
|
|
|
@@ -815,9 +826,8 @@ Configuration decides whether a process may start at all, so the suite is held t
|
|
|
815
826
|
beyond "the tests pass".
|
|
816
827
|
|
|
817
828
|
- ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
|
|
818
|
-
- ✅ **
|
|
819
|
-
`break:
|
|
820
|
-
is an equivalent no directive can reach, so it stays counted rather than silenced
|
|
829
|
+
- ✅ **100.00% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
|
|
830
|
+
`break: 100`; provable equivalents carry their reason inline and nothing else survives
|
|
821
831
|
([report](./docs/mutation_testing_results.md))
|
|
822
832
|
- ✅ **The value-free guarantee is tested, not assumed** — a sentinel secret is placed in a
|
|
823
833
|
source and the assertion is that it appears nowhere in the rendered report or in the thrown
|
|
@@ -826,7 +836,8 @@ beyond "the tests pass".
|
|
|
826
836
|
proven is the guarantee this package makes for its own generated text
|
|
827
837
|
- ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
|
|
828
838
|
system does, `check:runtime` loads every subpath from the packed tarball in ESM and
|
|
829
|
-
CommonJS,
|
|
839
|
+
CommonJS, `check:published` compiles this README's snippets against `dist/`, and `check:api`
|
|
840
|
+
requires an "Apply to a derived backend" CHANGELOG note when the built declarations change
|
|
830
841
|
- ✅ **Every suppression carries its reason** — no coverage directives anywhere; each
|
|
831
842
|
`// Stryker disable` in the production source names, after the `:` Stryker reads it from,
|
|
832
843
|
why the mutant it silences is behaviour-preserving, and `check:mutants` proves those
|
|
@@ -836,7 +847,7 @@ beyond "the tests pass".
|
|
|
836
847
|
```bash
|
|
837
848
|
pnpm test # unit suite
|
|
838
849
|
pnpm test:cov # unit suite with the 100% coverage gate
|
|
839
|
-
pnpm mutation # Stryker mutation testing (break:
|
|
850
|
+
pnpm mutation # Stryker mutation testing (break: 100)
|
|
840
851
|
pnpm typecheck # tsc strict check
|
|
841
852
|
pnpm lint # ESLint
|
|
842
853
|
```
|
|
@@ -900,7 +911,9 @@ const moduleRef = await Test.createTestingModule({
|
|
|
900
911
|
|
|
901
912
|
Overrides mirror the `namespace.leaf` shape with every key optional and typed from the schema. Each value is converted with `String(value)` before validation, so give
|
|
902
913
|
a leaf what its environment variable would hold; an object becomes `'[object Object]'`, and an override
|
|
903
|
-
of `undefined` is ignored, so a defaulted leaf cannot be unset.
|
|
914
|
+
of `undefined` is ignored, so a defaulted leaf cannot be unset. An override whose path matches no leaf
|
|
915
|
+
of the schema throws, listing every such path, even when its value is `undefined`; a namespace given
|
|
916
|
+
something other than an object is reported once, by name. Neither helper takes `strict`, a
|
|
904
917
|
custom `source`, a hook or `isGlobal`: `configTestingModule` registers the module globally, and a
|
|
905
918
|
failure case (a missing variable, a strict violation) is built with
|
|
906
919
|
`BymaxConfigModule.forRoot({ schema, source, strict: true })` and a source you write.
|
package/dist/internal/index.cjs
CHANGED
|
@@ -53,25 +53,30 @@ var MIN_VARIABLE_COLUMN = 20;
|
|
|
53
53
|
var VARIABLE_GUTTER = 2;
|
|
54
54
|
var REPORT_HEADER = "environment validation failed";
|
|
55
55
|
var REPORT_FOOTER = "Fix the variables above and restart the process.";
|
|
56
|
+
var OVERRIDE_FOOTER = "Fix the overrides above in the test.";
|
|
56
57
|
function pluralizeIssues(count) {
|
|
57
58
|
const noun = count === 1 ? "issue" : "issues";
|
|
58
59
|
return `${count} ${noun}`;
|
|
59
60
|
}
|
|
61
|
+
function nameOf(issue) {
|
|
62
|
+
return issue.variable === "" ? issue.path : issue.variable;
|
|
63
|
+
}
|
|
60
64
|
function computeColumnWidth(issues) {
|
|
61
65
|
let longest = MIN_VARIABLE_COLUMN;
|
|
62
66
|
for (const issue of issues) {
|
|
63
|
-
longest = Math.max(longest, issue.
|
|
67
|
+
longest = Math.max(longest, nameOf(issue).length);
|
|
64
68
|
}
|
|
65
69
|
return longest + VARIABLE_GUTTER;
|
|
66
70
|
}
|
|
67
71
|
function formatIssueLine(issue, columnWidth) {
|
|
68
|
-
return `${REPORT_INDENT}${issue.
|
|
72
|
+
return `${REPORT_INDENT}${nameOf(issue).padEnd(columnWidth)}${issue.message}`;
|
|
69
73
|
}
|
|
70
74
|
function formatIssueReport(issues) {
|
|
71
75
|
const columnWidth = computeColumnWidth(issues);
|
|
72
76
|
const lines = issues.map((issue) => formatIssueLine(issue, columnWidth));
|
|
73
77
|
const header = `${REPORT_HEADER} (${pluralizeIssues(issues.length)})`;
|
|
74
|
-
|
|
78
|
+
const aboutOverrides = issues.length > 0 && issues.every((issue) => issue.variable === "");
|
|
79
|
+
return [header, "", ...lines, "", aboutOverrides ? OVERRIDE_FOOTER : REPORT_FOOTER].join("\n");
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
// src/errors.ts
|
|
@@ -119,7 +124,7 @@ var BymaxConfigValidationError = class _BymaxConfigValidationError extends Error
|
|
|
119
124
|
|
|
120
125
|
// src/source-mapping.ts
|
|
121
126
|
function toScreamingSnake(segment) {
|
|
122
|
-
return segment.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]
|
|
127
|
+
return segment.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/(?<=[A-Z])(?=[A-Z][a-z])/g, "_").toUpperCase();
|
|
123
128
|
}
|
|
124
129
|
function readEnvOverride(leaf) {
|
|
125
130
|
for (let node = leaf; node !== void 0; ) {
|
|
@@ -143,6 +148,33 @@ function resolveSourceNames(schema) {
|
|
|
143
148
|
}))
|
|
144
149
|
);
|
|
145
150
|
}
|
|
151
|
+
function describeUnusable(value) {
|
|
152
|
+
if (value === "") return "an empty string";
|
|
153
|
+
if (value === void 0) return "undefined";
|
|
154
|
+
if (value === null) return "null";
|
|
155
|
+
return `${typeof value === "object" ? "an" : "a"} ${typeof value}`;
|
|
156
|
+
}
|
|
157
|
+
function describeUnusableEnv(leaf) {
|
|
158
|
+
for (let node = leaf; node !== void 0; ) {
|
|
159
|
+
const meta = node.meta();
|
|
160
|
+
if (meta !== void 0 && "env" in meta) {
|
|
161
|
+
const value = meta.env;
|
|
162
|
+
if (typeof value !== "string" || value.length === 0) return describeUnusable(value);
|
|
163
|
+
}
|
|
164
|
+
node = node._zod.def.innerType ?? node._zod.def.in;
|
|
165
|
+
}
|
|
166
|
+
return void 0;
|
|
167
|
+
}
|
|
168
|
+
function findUnusableEnvOverrides(schema) {
|
|
169
|
+
const unusable = [];
|
|
170
|
+
for (const [namespace, namespaceSchema] of Object.entries(schema.shape)) {
|
|
171
|
+
for (const [leafKey, leafSchema] of Object.entries(namespaceSchema.shape)) {
|
|
172
|
+
const found = describeUnusableEnv(leafSchema);
|
|
173
|
+
if (found !== void 0) unusable.push({ path: `${namespace}.${leafKey}`, found });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return unusable;
|
|
177
|
+
}
|
|
146
178
|
function resolveNamespacePrefixes(schema) {
|
|
147
179
|
return Object.entries(schema.shape).map(([namespace, namespaceSchema]) => ({
|
|
148
180
|
namespace,
|
|
@@ -282,20 +314,54 @@ function detectUnknownKeys(prefixes, bindings, source) {
|
|
|
282
314
|
(left, right) => Number(left.variable > right.variable) - Number(left.variable < right.variable)
|
|
283
315
|
);
|
|
284
316
|
}
|
|
317
|
+
function assertDistinctVariables(bindings) {
|
|
318
|
+
const byVariable = /* @__PURE__ */ new Map();
|
|
319
|
+
for (const { path, variable } of bindings) {
|
|
320
|
+
byVariable.set(variable, [...byVariable.get(variable) ?? [], path]);
|
|
321
|
+
}
|
|
322
|
+
const issues = [];
|
|
323
|
+
for (const [variable, paths] of byVariable) {
|
|
324
|
+
if (paths.length < 2) continue;
|
|
325
|
+
for (const path of paths) {
|
|
326
|
+
const others = paths.filter((other) => other !== path).join(", ");
|
|
327
|
+
issues.push({
|
|
328
|
+
path,
|
|
329
|
+
variable,
|
|
330
|
+
code: ConfigErrorCode.INVALID,
|
|
331
|
+
message: `shares this variable with ${others}; rename one with meta({ env })`
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (issues.length > 0) throw new BymaxConfigValidationError(issues);
|
|
336
|
+
}
|
|
337
|
+
function unusableOverrideIssues(schema, bindings) {
|
|
338
|
+
const variables = new Map(bindings.map((binding) => [binding.path, binding.variable]));
|
|
339
|
+
return findUnusableEnvOverrides(schema).map(({ path, found }) => {
|
|
340
|
+
const variable = variables.get(path);
|
|
341
|
+
return {
|
|
342
|
+
path,
|
|
343
|
+
variable,
|
|
344
|
+
code: ConfigErrorCode.INVALID,
|
|
345
|
+
message: `meta({ env }) must be a non-empty string, found ${found}; the leaf would read ${variable}`
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
}
|
|
285
349
|
function validateEnv(schema, source, options) {
|
|
286
350
|
const bindings = resolveSourceNames(schema);
|
|
351
|
+
assertDistinctVariables(bindings);
|
|
287
352
|
const sourceMap = new Map(Object.entries(source));
|
|
288
353
|
const namespaces = Object.keys(schema.shape);
|
|
289
354
|
const candidate = buildCandidate(namespaces, collectPresentLeaves(bindings, sourceMap));
|
|
290
355
|
const result = schema.safeParse(candidate);
|
|
291
356
|
const prefixes = resolveNamespacePrefixes(schema);
|
|
292
357
|
const unknown = options?.strict === true ? detectUnknownKeys(prefixes, bindings, sourceMap) : [];
|
|
358
|
+
const overrides = unusableOverrideIssues(schema, bindings);
|
|
293
359
|
if (result.success) {
|
|
294
|
-
if (unknown.length === 0) return result.data;
|
|
295
|
-
throw new BymaxConfigValidationError(unknown);
|
|
360
|
+
if (unknown.length === 0 && overrides.length === 0) return result.data;
|
|
361
|
+
throw new BymaxConfigValidationError([...overrides, ...unknown]);
|
|
296
362
|
}
|
|
297
363
|
const issues = translateIssues(result.error.issues, bindings, prefixes, sourceMap);
|
|
298
|
-
throw new BymaxConfigValidationError([...issues, ...unknown]);
|
|
364
|
+
throw new BymaxConfigValidationError([...overrides, ...issues, ...unknown]);
|
|
299
365
|
}
|
|
300
366
|
|
|
301
367
|
// src/config.providers.ts
|
|
@@ -313,6 +379,11 @@ async function reportIssues(hook, error) {
|
|
|
313
379
|
}
|
|
314
380
|
}
|
|
315
381
|
function createValidatedConfig(options) {
|
|
382
|
+
if ("source" in options && (options.source === void 0 || options.source === null)) {
|
|
383
|
+
throw new TypeError(
|
|
384
|
+
"BymaxConfigModule: source was provided but is undefined or null; omit the key to validate process.env, or pass the source that failed to load"
|
|
385
|
+
);
|
|
386
|
+
}
|
|
316
387
|
const source = options.source ?? process.env;
|
|
317
388
|
return deepFreeze(validateEnv(options.schema, source, { strict: options.strict === true }));
|
|
318
389
|
}
|
|
@@ -516,6 +587,8 @@ exports.BYMAX_CONFIG = BYMAX_CONFIG;
|
|
|
516
587
|
exports.BYMAX_CONFIG_OPTIONS = BYMAX_CONFIG_OPTIONS;
|
|
517
588
|
exports.BymaxConfigValidationError = BymaxConfigValidationError;
|
|
518
589
|
exports.ConfigErrorCode = ConfigErrorCode;
|
|
590
|
+
exports.assertDistinctVariables = assertDistinctVariables;
|
|
519
591
|
exports.createValidatedConfig = createValidatedConfig;
|
|
520
592
|
exports.defineEnv = defineEnv;
|
|
521
593
|
exports.resolveSourceNames = resolveSourceNames;
|
|
594
|
+
exports.unusableOverrideIssues = unusableOverrideIssues;
|
|
@@ -557,6 +557,8 @@ declare function defineEnv<TShape extends EnvShape>(shape: TShape): EnvSchema<TS
|
|
|
557
557
|
* @param options - The resolved module options (schema, source, strict).
|
|
558
558
|
* @returns The validated, deep-frozen configuration output.
|
|
559
559
|
* @throws {BymaxConfigValidationError} When the source fails the schema.
|
|
560
|
+
* @throws {TypeError} When `source` is present with the value `undefined` or `null`, which would
|
|
561
|
+
* otherwise validate `process.env` in place of the source that failed to load.
|
|
560
562
|
* @example
|
|
561
563
|
* ```typescript
|
|
562
564
|
* const config = createValidatedConfig({ schema: envSchema });
|
|
@@ -604,7 +606,7 @@ interface SourceBinding {
|
|
|
604
606
|
* never a source record. Declared is not required: a leaf with a default or an
|
|
605
607
|
* optional leaf is listed like any other. Two distinct paths can resolve to one
|
|
606
608
|
* variable (`a.bC` and `aB.c` both derive `A_B_C`); both bindings are returned
|
|
607
|
-
* and none is dropped. An open namespace changes nothing here, since opening
|
|
609
|
+
* and none is dropped, though validation rejects a schema that holds such a pair. An open namespace changes nothing here, since opening
|
|
608
610
|
* only waives strict unknown-key detection.
|
|
609
611
|
*
|
|
610
612
|
* @typeParam TShape - The two-level shape the schema was composed from.
|
|
@@ -619,4 +621,42 @@ interface SourceBinding {
|
|
|
619
621
|
*/
|
|
620
622
|
declare function resolveSourceNames<TShape extends EnvShape = EnvShape>(schema: EnvSchema<TShape>): readonly SourceBinding[];
|
|
621
623
|
|
|
622
|
-
|
|
624
|
+
/**
|
|
625
|
+
* @fileoverview Single-pass environment validator. Maps a flat source record
|
|
626
|
+
* onto the nested schema through the resolved source-name bindings, runs exactly
|
|
627
|
+
* one Zod parse, and either returns the typed parsed output or throws a
|
|
628
|
+
* BymaxConfigValidationError aggregating every violation. Zod issues are
|
|
629
|
+
* translated into value-free ConfigIssue descriptions that state only the
|
|
630
|
+
* expected constraint, never the received value. The one exception is the
|
|
631
|
+
* message a schema author attaches to a `custom` issue (`.check`, `.refine`,
|
|
632
|
+
* `.superRefine`), which is reported as written: it is schema text, not input,
|
|
633
|
+
* and it is the only place a conditional or cross-field rule can explain itself.
|
|
634
|
+
* @layer Service
|
|
635
|
+
*/
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Reject a schema in which two leaves read one source variable.
|
|
639
|
+
*
|
|
640
|
+
* Two leaves that derive, or are told through `meta({ env })`, the same variable would
|
|
641
|
+
* each read the one value and neither could report on its own, so the schema is wrong
|
|
642
|
+
* before any source is looked at. One issue is produced per colliding leaf, in order of
|
|
643
|
+
* first appearance, each naming the other leaves that share its variable.
|
|
644
|
+
*
|
|
645
|
+
* @param bindings - Every leaf binding of a schema, from `resolveSourceNames`.
|
|
646
|
+
* @throws {BymaxConfigValidationError} When any variable is bound by more than one leaf.
|
|
647
|
+
*/
|
|
648
|
+
declare function assertDistinctVariables(bindings: ReadonlyArray<SourceBinding>): void;
|
|
649
|
+
/**
|
|
650
|
+
* Describe every leaf whose `meta({ env })` is present but cannot name a variable.
|
|
651
|
+
*
|
|
652
|
+
* The leaf would silently read the variable derived from its path, so it is a mistake in the
|
|
653
|
+
* schema, reported beside the violations of the source. The message names the variable the
|
|
654
|
+
* leaf reads and the kind of value `env` held, never the value.
|
|
655
|
+
*
|
|
656
|
+
* @param schema - A schema produced by `defineEnv`.
|
|
657
|
+
* @param bindings - Every leaf binding of the schema, from `resolveSourceNames`.
|
|
658
|
+
* @returns One issue per affected leaf, in schema order.
|
|
659
|
+
*/
|
|
660
|
+
declare function unusableOverrideIssues<TShape extends EnvShape>(schema: EnvSchema<TShape>, bindings: ReadonlyArray<SourceBinding>): ConfigIssue[];
|
|
661
|
+
|
|
662
|
+
export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, type BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, type ConfigIssue, type ConfigIssueCode, ConfigService, type ConfigValidationCode, type EnvLeaf, type EnvNamespace, type EnvOutput, type EnvSchema, type EnvShape, type Path, type PathValue, type SourceBinding, assertDistinctVariables, createValidatedConfig, defineEnv, resolveSourceNames, unusableOverrideIssues };
|
package/dist/internal/index.d.ts
CHANGED
|
@@ -557,6 +557,8 @@ declare function defineEnv<TShape extends EnvShape>(shape: TShape): EnvSchema<TS
|
|
|
557
557
|
* @param options - The resolved module options (schema, source, strict).
|
|
558
558
|
* @returns The validated, deep-frozen configuration output.
|
|
559
559
|
* @throws {BymaxConfigValidationError} When the source fails the schema.
|
|
560
|
+
* @throws {TypeError} When `source` is present with the value `undefined` or `null`, which would
|
|
561
|
+
* otherwise validate `process.env` in place of the source that failed to load.
|
|
560
562
|
* @example
|
|
561
563
|
* ```typescript
|
|
562
564
|
* const config = createValidatedConfig({ schema: envSchema });
|
|
@@ -604,7 +606,7 @@ interface SourceBinding {
|
|
|
604
606
|
* never a source record. Declared is not required: a leaf with a default or an
|
|
605
607
|
* optional leaf is listed like any other. Two distinct paths can resolve to one
|
|
606
608
|
* variable (`a.bC` and `aB.c` both derive `A_B_C`); both bindings are returned
|
|
607
|
-
* and none is dropped. An open namespace changes nothing here, since opening
|
|
609
|
+
* and none is dropped, though validation rejects a schema that holds such a pair. An open namespace changes nothing here, since opening
|
|
608
610
|
* only waives strict unknown-key detection.
|
|
609
611
|
*
|
|
610
612
|
* @typeParam TShape - The two-level shape the schema was composed from.
|
|
@@ -619,4 +621,42 @@ interface SourceBinding {
|
|
|
619
621
|
*/
|
|
620
622
|
declare function resolveSourceNames<TShape extends EnvShape = EnvShape>(schema: EnvSchema<TShape>): readonly SourceBinding[];
|
|
621
623
|
|
|
622
|
-
|
|
624
|
+
/**
|
|
625
|
+
* @fileoverview Single-pass environment validator. Maps a flat source record
|
|
626
|
+
* onto the nested schema through the resolved source-name bindings, runs exactly
|
|
627
|
+
* one Zod parse, and either returns the typed parsed output or throws a
|
|
628
|
+
* BymaxConfigValidationError aggregating every violation. Zod issues are
|
|
629
|
+
* translated into value-free ConfigIssue descriptions that state only the
|
|
630
|
+
* expected constraint, never the received value. The one exception is the
|
|
631
|
+
* message a schema author attaches to a `custom` issue (`.check`, `.refine`,
|
|
632
|
+
* `.superRefine`), which is reported as written: it is schema text, not input,
|
|
633
|
+
* and it is the only place a conditional or cross-field rule can explain itself.
|
|
634
|
+
* @layer Service
|
|
635
|
+
*/
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Reject a schema in which two leaves read one source variable.
|
|
639
|
+
*
|
|
640
|
+
* Two leaves that derive, or are told through `meta({ env })`, the same variable would
|
|
641
|
+
* each read the one value and neither could report on its own, so the schema is wrong
|
|
642
|
+
* before any source is looked at. One issue is produced per colliding leaf, in order of
|
|
643
|
+
* first appearance, each naming the other leaves that share its variable.
|
|
644
|
+
*
|
|
645
|
+
* @param bindings - Every leaf binding of a schema, from `resolveSourceNames`.
|
|
646
|
+
* @throws {BymaxConfigValidationError} When any variable is bound by more than one leaf.
|
|
647
|
+
*/
|
|
648
|
+
declare function assertDistinctVariables(bindings: ReadonlyArray<SourceBinding>): void;
|
|
649
|
+
/**
|
|
650
|
+
* Describe every leaf whose `meta({ env })` is present but cannot name a variable.
|
|
651
|
+
*
|
|
652
|
+
* The leaf would silently read the variable derived from its path, so it is a mistake in the
|
|
653
|
+
* schema, reported beside the violations of the source. The message names the variable the
|
|
654
|
+
* leaf reads and the kind of value `env` held, never the value.
|
|
655
|
+
*
|
|
656
|
+
* @param schema - A schema produced by `defineEnv`.
|
|
657
|
+
* @param bindings - Every leaf binding of the schema, from `resolveSourceNames`.
|
|
658
|
+
* @returns One issue per affected leaf, in schema order.
|
|
659
|
+
*/
|
|
660
|
+
declare function unusableOverrideIssues<TShape extends EnvShape>(schema: EnvSchema<TShape>, bindings: ReadonlyArray<SourceBinding>): ConfigIssue[];
|
|
661
|
+
|
|
662
|
+
export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, type BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, type ConfigIssue, type ConfigIssueCode, ConfigService, type ConfigValidationCode, type EnvLeaf, type EnvNamespace, type EnvOutput, type EnvSchema, type EnvShape, type Path, type PathValue, type SourceBinding, assertDistinctVariables, createValidatedConfig, defineEnv, resolveSourceNames, unusableOverrideIssues };
|
package/dist/internal/index.mjs
CHANGED
|
@@ -51,25 +51,30 @@ var MIN_VARIABLE_COLUMN = 20;
|
|
|
51
51
|
var VARIABLE_GUTTER = 2;
|
|
52
52
|
var REPORT_HEADER = "environment validation failed";
|
|
53
53
|
var REPORT_FOOTER = "Fix the variables above and restart the process.";
|
|
54
|
+
var OVERRIDE_FOOTER = "Fix the overrides above in the test.";
|
|
54
55
|
function pluralizeIssues(count) {
|
|
55
56
|
const noun = count === 1 ? "issue" : "issues";
|
|
56
57
|
return `${count} ${noun}`;
|
|
57
58
|
}
|
|
59
|
+
function nameOf(issue) {
|
|
60
|
+
return issue.variable === "" ? issue.path : issue.variable;
|
|
61
|
+
}
|
|
58
62
|
function computeColumnWidth(issues) {
|
|
59
63
|
let longest = MIN_VARIABLE_COLUMN;
|
|
60
64
|
for (const issue of issues) {
|
|
61
|
-
longest = Math.max(longest, issue.
|
|
65
|
+
longest = Math.max(longest, nameOf(issue).length);
|
|
62
66
|
}
|
|
63
67
|
return longest + VARIABLE_GUTTER;
|
|
64
68
|
}
|
|
65
69
|
function formatIssueLine(issue, columnWidth) {
|
|
66
|
-
return `${REPORT_INDENT}${issue.
|
|
70
|
+
return `${REPORT_INDENT}${nameOf(issue).padEnd(columnWidth)}${issue.message}`;
|
|
67
71
|
}
|
|
68
72
|
function formatIssueReport(issues) {
|
|
69
73
|
const columnWidth = computeColumnWidth(issues);
|
|
70
74
|
const lines = issues.map((issue) => formatIssueLine(issue, columnWidth));
|
|
71
75
|
const header = `${REPORT_HEADER} (${pluralizeIssues(issues.length)})`;
|
|
72
|
-
|
|
76
|
+
const aboutOverrides = issues.length > 0 && issues.every((issue) => issue.variable === "");
|
|
77
|
+
return [header, "", ...lines, "", aboutOverrides ? OVERRIDE_FOOTER : REPORT_FOOTER].join("\n");
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
// src/errors.ts
|
|
@@ -117,7 +122,7 @@ var BymaxConfigValidationError = class _BymaxConfigValidationError extends Error
|
|
|
117
122
|
|
|
118
123
|
// src/source-mapping.ts
|
|
119
124
|
function toScreamingSnake(segment) {
|
|
120
|
-
return segment.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]
|
|
125
|
+
return segment.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/(?<=[A-Z])(?=[A-Z][a-z])/g, "_").toUpperCase();
|
|
121
126
|
}
|
|
122
127
|
function readEnvOverride(leaf) {
|
|
123
128
|
for (let node = leaf; node !== void 0; ) {
|
|
@@ -141,6 +146,33 @@ function resolveSourceNames(schema) {
|
|
|
141
146
|
}))
|
|
142
147
|
);
|
|
143
148
|
}
|
|
149
|
+
function describeUnusable(value) {
|
|
150
|
+
if (value === "") return "an empty string";
|
|
151
|
+
if (value === void 0) return "undefined";
|
|
152
|
+
if (value === null) return "null";
|
|
153
|
+
return `${typeof value === "object" ? "an" : "a"} ${typeof value}`;
|
|
154
|
+
}
|
|
155
|
+
function describeUnusableEnv(leaf) {
|
|
156
|
+
for (let node = leaf; node !== void 0; ) {
|
|
157
|
+
const meta = node.meta();
|
|
158
|
+
if (meta !== void 0 && "env" in meta) {
|
|
159
|
+
const value = meta.env;
|
|
160
|
+
if (typeof value !== "string" || value.length === 0) return describeUnusable(value);
|
|
161
|
+
}
|
|
162
|
+
node = node._zod.def.innerType ?? node._zod.def.in;
|
|
163
|
+
}
|
|
164
|
+
return void 0;
|
|
165
|
+
}
|
|
166
|
+
function findUnusableEnvOverrides(schema) {
|
|
167
|
+
const unusable = [];
|
|
168
|
+
for (const [namespace, namespaceSchema] of Object.entries(schema.shape)) {
|
|
169
|
+
for (const [leafKey, leafSchema] of Object.entries(namespaceSchema.shape)) {
|
|
170
|
+
const found = describeUnusableEnv(leafSchema);
|
|
171
|
+
if (found !== void 0) unusable.push({ path: `${namespace}.${leafKey}`, found });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return unusable;
|
|
175
|
+
}
|
|
144
176
|
function resolveNamespacePrefixes(schema) {
|
|
145
177
|
return Object.entries(schema.shape).map(([namespace, namespaceSchema]) => ({
|
|
146
178
|
namespace,
|
|
@@ -280,20 +312,54 @@ function detectUnknownKeys(prefixes, bindings, source) {
|
|
|
280
312
|
(left, right) => Number(left.variable > right.variable) - Number(left.variable < right.variable)
|
|
281
313
|
);
|
|
282
314
|
}
|
|
315
|
+
function assertDistinctVariables(bindings) {
|
|
316
|
+
const byVariable = /* @__PURE__ */ new Map();
|
|
317
|
+
for (const { path, variable } of bindings) {
|
|
318
|
+
byVariable.set(variable, [...byVariable.get(variable) ?? [], path]);
|
|
319
|
+
}
|
|
320
|
+
const issues = [];
|
|
321
|
+
for (const [variable, paths] of byVariable) {
|
|
322
|
+
if (paths.length < 2) continue;
|
|
323
|
+
for (const path of paths) {
|
|
324
|
+
const others = paths.filter((other) => other !== path).join(", ");
|
|
325
|
+
issues.push({
|
|
326
|
+
path,
|
|
327
|
+
variable,
|
|
328
|
+
code: ConfigErrorCode.INVALID,
|
|
329
|
+
message: `shares this variable with ${others}; rename one with meta({ env })`
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (issues.length > 0) throw new BymaxConfigValidationError(issues);
|
|
334
|
+
}
|
|
335
|
+
function unusableOverrideIssues(schema, bindings) {
|
|
336
|
+
const variables = new Map(bindings.map((binding) => [binding.path, binding.variable]));
|
|
337
|
+
return findUnusableEnvOverrides(schema).map(({ path, found }) => {
|
|
338
|
+
const variable = variables.get(path);
|
|
339
|
+
return {
|
|
340
|
+
path,
|
|
341
|
+
variable,
|
|
342
|
+
code: ConfigErrorCode.INVALID,
|
|
343
|
+
message: `meta({ env }) must be a non-empty string, found ${found}; the leaf would read ${variable}`
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
}
|
|
283
347
|
function validateEnv(schema, source, options) {
|
|
284
348
|
const bindings = resolveSourceNames(schema);
|
|
349
|
+
assertDistinctVariables(bindings);
|
|
285
350
|
const sourceMap = new Map(Object.entries(source));
|
|
286
351
|
const namespaces = Object.keys(schema.shape);
|
|
287
352
|
const candidate = buildCandidate(namespaces, collectPresentLeaves(bindings, sourceMap));
|
|
288
353
|
const result = schema.safeParse(candidate);
|
|
289
354
|
const prefixes = resolveNamespacePrefixes(schema);
|
|
290
355
|
const unknown = options?.strict === true ? detectUnknownKeys(prefixes, bindings, sourceMap) : [];
|
|
356
|
+
const overrides = unusableOverrideIssues(schema, bindings);
|
|
291
357
|
if (result.success) {
|
|
292
|
-
if (unknown.length === 0) return result.data;
|
|
293
|
-
throw new BymaxConfigValidationError(unknown);
|
|
358
|
+
if (unknown.length === 0 && overrides.length === 0) return result.data;
|
|
359
|
+
throw new BymaxConfigValidationError([...overrides, ...unknown]);
|
|
294
360
|
}
|
|
295
361
|
const issues = translateIssues(result.error.issues, bindings, prefixes, sourceMap);
|
|
296
|
-
throw new BymaxConfigValidationError([...issues, ...unknown]);
|
|
362
|
+
throw new BymaxConfigValidationError([...overrides, ...issues, ...unknown]);
|
|
297
363
|
}
|
|
298
364
|
|
|
299
365
|
// src/config.providers.ts
|
|
@@ -311,6 +377,11 @@ async function reportIssues(hook, error) {
|
|
|
311
377
|
}
|
|
312
378
|
}
|
|
313
379
|
function createValidatedConfig(options) {
|
|
380
|
+
if ("source" in options && (options.source === void 0 || options.source === null)) {
|
|
381
|
+
throw new TypeError(
|
|
382
|
+
"BymaxConfigModule: source was provided but is undefined or null; omit the key to validate process.env, or pass the source that failed to load"
|
|
383
|
+
);
|
|
384
|
+
}
|
|
314
385
|
const source = options.source ?? process.env;
|
|
315
386
|
return deepFreeze(validateEnv(options.schema, source, { strict: options.strict === true }));
|
|
316
387
|
}
|
|
@@ -510,4 +581,4 @@ function defineEnv(shape) {
|
|
|
510
581
|
return z.object(shape);
|
|
511
582
|
}
|
|
512
583
|
|
|
513
|
-
export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigValidationError, ConfigErrorCode, ConfigService, createValidatedConfig, defineEnv, resolveSourceNames };
|
|
584
|
+
export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigValidationError, ConfigErrorCode, ConfigService, assertDistinctVariables, createValidatedConfig, defineEnv, resolveSourceNames, unusableOverrideIssues };
|
package/dist/testing/index.cjs
CHANGED
|
@@ -167,19 +167,49 @@ function synthesizePlaceholderSource(schema) {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
// src/testing/create-test-config.ts
|
|
170
|
-
function
|
|
170
|
+
function unplacedIssue(path, code, message) {
|
|
171
|
+
return { path, variable: "", code, message };
|
|
172
|
+
}
|
|
173
|
+
function flattenOverrides(overrides, declared) {
|
|
171
174
|
const flat = /* @__PURE__ */ new Map();
|
|
175
|
+
const unplaced = [];
|
|
172
176
|
for (const [namespace, leaves] of Object.entries(overrides)) {
|
|
177
|
+
if (typeof leaves !== "object") {
|
|
178
|
+
unplaced.push(
|
|
179
|
+
unplacedIssue(
|
|
180
|
+
namespace,
|
|
181
|
+
internal.ConfigErrorCode.INVALID,
|
|
182
|
+
`the override for \`${namespace}\` must be an object`
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
173
187
|
for (const [leaf, value] of Object.entries(leaves)) {
|
|
174
|
-
|
|
188
|
+
const path = `${namespace}.${leaf}`;
|
|
189
|
+
if (declared.has(path)) {
|
|
190
|
+
flat.set(path, value);
|
|
191
|
+
} else {
|
|
192
|
+
unplaced.push(
|
|
193
|
+
unplacedIssue(
|
|
194
|
+
path,
|
|
195
|
+
internal.ConfigErrorCode.UNKNOWN_KEY,
|
|
196
|
+
"no leaf of the schema is at this path; the override was not applied"
|
|
197
|
+
)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
175
200
|
}
|
|
176
201
|
}
|
|
177
|
-
return flat;
|
|
202
|
+
return { flat, unplaced };
|
|
178
203
|
}
|
|
179
204
|
function applyOverrides(schema, base, overrides, overridden) {
|
|
180
|
-
const
|
|
205
|
+
const bindings = internal.resolveSourceNames(schema);
|
|
206
|
+
const { flat, unplaced } = flattenOverrides(
|
|
207
|
+
overrides ?? {},
|
|
208
|
+
new Set(bindings.map((binding) => binding.path))
|
|
209
|
+
);
|
|
210
|
+
if (unplaced.length > 0) throw new internal.BymaxConfigValidationError(unplaced);
|
|
181
211
|
const entries = new Map(Object.entries(base));
|
|
182
|
-
for (const binding of
|
|
212
|
+
for (const binding of bindings) {
|
|
183
213
|
const value = flat.get(binding.path);
|
|
184
214
|
if (value === void 0) continue;
|
|
185
215
|
entries.set(binding.variable, String(value));
|
|
@@ -200,6 +230,10 @@ function explainUnsatisfiedLeaves(error, overridden) {
|
|
|
200
230
|
);
|
|
201
231
|
}
|
|
202
232
|
function prepareTestConfig(schema, overrides) {
|
|
233
|
+
const bindings = internal.resolveSourceNames(schema);
|
|
234
|
+
internal.assertDistinctVariables(bindings);
|
|
235
|
+
const unusable = internal.unusableOverrideIssues(schema, bindings);
|
|
236
|
+
if (unusable.length > 0) throw new internal.BymaxConfigValidationError(unusable);
|
|
203
237
|
const overridden = /* @__PURE__ */ new Set();
|
|
204
238
|
const source = applyOverrides(schema, synthesizePlaceholderSource(schema), overrides, overridden);
|
|
205
239
|
try {
|
package/dist/testing/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BymaxConfigModule,
|
|
1
|
+
import { BymaxConfigModule, resolveSourceNames, assertDistinctVariables, unusableOverrideIssues, BymaxConfigValidationError, createValidatedConfig, ConfigErrorCode } from '@bymax-one/nest-config/internal';
|
|
2
2
|
|
|
3
3
|
// src/testing/create-test-config.ts
|
|
4
4
|
var FILLER_CHARACTER = "a";
|
|
@@ -165,19 +165,49 @@ function synthesizePlaceholderSource(schema) {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
// src/testing/create-test-config.ts
|
|
168
|
-
function
|
|
168
|
+
function unplacedIssue(path, code, message) {
|
|
169
|
+
return { path, variable: "", code, message };
|
|
170
|
+
}
|
|
171
|
+
function flattenOverrides(overrides, declared) {
|
|
169
172
|
const flat = /* @__PURE__ */ new Map();
|
|
173
|
+
const unplaced = [];
|
|
170
174
|
for (const [namespace, leaves] of Object.entries(overrides)) {
|
|
175
|
+
if (typeof leaves !== "object") {
|
|
176
|
+
unplaced.push(
|
|
177
|
+
unplacedIssue(
|
|
178
|
+
namespace,
|
|
179
|
+
ConfigErrorCode.INVALID,
|
|
180
|
+
`the override for \`${namespace}\` must be an object`
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
171
185
|
for (const [leaf, value] of Object.entries(leaves)) {
|
|
172
|
-
|
|
186
|
+
const path = `${namespace}.${leaf}`;
|
|
187
|
+
if (declared.has(path)) {
|
|
188
|
+
flat.set(path, value);
|
|
189
|
+
} else {
|
|
190
|
+
unplaced.push(
|
|
191
|
+
unplacedIssue(
|
|
192
|
+
path,
|
|
193
|
+
ConfigErrorCode.UNKNOWN_KEY,
|
|
194
|
+
"no leaf of the schema is at this path; the override was not applied"
|
|
195
|
+
)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
173
198
|
}
|
|
174
199
|
}
|
|
175
|
-
return flat;
|
|
200
|
+
return { flat, unplaced };
|
|
176
201
|
}
|
|
177
202
|
function applyOverrides(schema, base, overrides, overridden) {
|
|
178
|
-
const
|
|
203
|
+
const bindings = resolveSourceNames(schema);
|
|
204
|
+
const { flat, unplaced } = flattenOverrides(
|
|
205
|
+
overrides ?? {},
|
|
206
|
+
new Set(bindings.map((binding) => binding.path))
|
|
207
|
+
);
|
|
208
|
+
if (unplaced.length > 0) throw new BymaxConfigValidationError(unplaced);
|
|
179
209
|
const entries = new Map(Object.entries(base));
|
|
180
|
-
for (const binding of
|
|
210
|
+
for (const binding of bindings) {
|
|
181
211
|
const value = flat.get(binding.path);
|
|
182
212
|
if (value === void 0) continue;
|
|
183
213
|
entries.set(binding.variable, String(value));
|
|
@@ -198,6 +228,10 @@ function explainUnsatisfiedLeaves(error, overridden) {
|
|
|
198
228
|
);
|
|
199
229
|
}
|
|
200
230
|
function prepareTestConfig(schema, overrides) {
|
|
231
|
+
const bindings = resolveSourceNames(schema);
|
|
232
|
+
assertDistinctVariables(bindings);
|
|
233
|
+
const unusable = unusableOverrideIssues(schema, bindings);
|
|
234
|
+
if (unusable.length > 0) throw new BymaxConfigValidationError(unusable);
|
|
201
235
|
const overridden = /* @__PURE__ */ new Set();
|
|
202
236
|
const source = applyOverrides(schema, synthesizePlaceholderSource(schema), overrides, overridden);
|
|
203
237
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bymax-one/nest-config",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"description": "Typed and validated environment configuration entry point for NestJS 11 applications, built on Zod v4.",
|
|
5
5
|
"author": "Bymax One <support@bymax.one>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "pnpm clean && tsup",
|
|
58
|
+
"check:api": "node scripts/check-api-surface.mjs",
|
|
58
59
|
"check:exports": "attw --pack . --profile strict",
|
|
59
60
|
"check:lint-graph": "node scripts/check-lint-graph-rules.mjs",
|
|
60
61
|
"check:mutants": "node scripts/check-mutation-directives.mjs",
|