@bymax-one/nest-config 1.2.2 → 1.2.3

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 CHANGED
@@ -11,6 +11,21 @@ as the GitHub Release body, so each released version needs a matching
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.3] - 2026-10-31
15
+
16
+ ### Fixed
17
+
18
+ - **A schema in which two leaves read one variable is rejected.** `a.bC` and `aB.c` both
19
+ derive `A_B_C`, and `httpServer.port` and `HTTPServer.port` both derive
20
+ `HTTP_SERVER_PORT`; so does giving two leaves the same `meta({ env })`. Each pair used to
21
+ read one value silently, so setting the variable satisfied both leaves and neither could
22
+ tell. `forRoot`, `createValidatedConfig` and the testing helpers now throw a
23
+ `BymaxConfigValidationError` before the source is read, with one `BYMAX_CONFIG_INVALID`
24
+ issue per colliding leaf naming the other leaves and the shared variable. There is no
25
+ opt-out. `resolveSourceNames` still returns every binding. **Apply to a derived
26
+ backend:** a colliding schema now fails at boot. Give one leaf of each pair a distinct
27
+ `meta({ env })`, or read one leaf and derive the other value from it in the consumer.
28
+
14
29
  ## [1.2.2] - 2026-10-31
15
30
 
16
31
  ### Added
@@ -468,4 +483,5 @@ have regressed from. They are kept because the reasoning is worth having.
468
483
  [1.2.0]: https://github.com/bymaxone/nest-config/compare/v1.1.3...v1.2.0
469
484
  [1.2.1]: https://github.com/bymaxone/nest-config/compare/v1.2.0...v1.2.1
470
485
  [1.2.2]: https://github.com/bymaxone/nest-config/compare/v1.2.1...v1.2.2
471
- [Unreleased]: https://github.com/bymaxone/nest-config/compare/v1.2.2...HEAD
486
+ [1.2.3]: https://github.com/bymaxone/nest-config/compare/v1.2.2...v1.2.3
487
+ [Unreleased]: https://github.com/bymaxone/nest-config/compare/v1.2.3...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-99.69%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></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-99.71%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>
@@ -638,8 +638,9 @@ it('documents every variable the schema reads in .env.example', () => {
638
638
  the schema reads it.
639
639
  - **Two paths can read one variable.** Names are derived from the keys, so `a.bC` and
640
640
  `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; validation reads the
642
- shared variable for both leaves.
641
+ `HTTP_SERVER_PORT`. Every binding is returned and none is dropped, so a tool can see the
642
+ collision; validation rejects such a schema (see the error catalog). Giving two leaves
643
+ the same `meta({ env })` is the same collision, and there is no opt-out.
643
644
  - **An open namespace changes nothing.** `meta({ open: true })` only waives strict
644
645
  unknown-key detection, so the list is the same whether a namespace is open or closed. It
645
646
  does not help to find variables in `.env.example` that no leaf reads.
@@ -648,12 +649,12 @@ it('documents every variable the schema reads in .env.example', () => {
648
649
 
649
650
  ## 🚨 Error Catalog
650
651
 
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` | Present but failed its constraint (issue code). |
656
- | `BYMAX_CONFIG_UNKNOWN_KEY` | Strict mode: source variable matches no declared leaf. |
652
+ | Code | Meaning |
653
+ | -------------------------- | --------------------------------------------------------- |
654
+ | `BYMAX_CONFIG_VALIDATION` | One or more schema violations (top-level error code). |
655
+ | `BYMAX_CONFIG_MISSING` | Required variable absent from the source (issue code). |
656
+ | `BYMAX_CONFIG_INVALID` | Failed its constraint, or shares a variable (issue code). |
657
+ | `BYMAX_CONFIG_UNKNOWN_KEY` | Strict mode: source variable matches no declared leaf. |
657
658
 
658
659
  ## 🏗️ Architecture
659
660
 
@@ -815,7 +816,7 @@ Configuration decides whether a process may start at all, so the suite is held t
815
816
  beyond "the tests pass".
816
817
 
817
818
  - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
818
- - ✅ **99.69% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
819
+ - ✅ **99.71% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
819
820
  `break: 95`; ten provable equivalents carry their reason inline, and the one survivor left
820
821
  is an equivalent no directive can reach, so it stays counted rather than silenced
821
822
  ([report](./docs/mutation_testing_results.md))
@@ -282,8 +282,29 @@ function detectUnknownKeys(prefixes, bindings, source) {
282
282
  (left, right) => Number(left.variable > right.variable) - Number(left.variable < right.variable)
283
283
  );
284
284
  }
285
+ function assertDistinctVariables(bindings) {
286
+ const byVariable = /* @__PURE__ */ new Map();
287
+ for (const { path, variable } of bindings) {
288
+ byVariable.set(variable, [...byVariable.get(variable) ?? [], path]);
289
+ }
290
+ const issues = [];
291
+ for (const [variable, paths] of byVariable) {
292
+ if (paths.length < 2) continue;
293
+ for (const path of paths) {
294
+ const others = paths.filter((other) => other !== path).join(", ");
295
+ issues.push({
296
+ path,
297
+ variable,
298
+ code: ConfigErrorCode.INVALID,
299
+ message: `shares this variable with ${others}; rename one with meta({ env })`
300
+ });
301
+ }
302
+ }
303
+ if (issues.length > 0) throw new BymaxConfigValidationError(issues);
304
+ }
285
305
  function validateEnv(schema, source, options) {
286
306
  const bindings = resolveSourceNames(schema);
307
+ assertDistinctVariables(bindings);
287
308
  const sourceMap = new Map(Object.entries(source));
288
309
  const namespaces = Object.keys(schema.shape);
289
310
  const candidate = buildCandidate(namespaces, collectPresentLeaves(bindings, sourceMap));
@@ -516,6 +537,7 @@ exports.BYMAX_CONFIG = BYMAX_CONFIG;
516
537
  exports.BYMAX_CONFIG_OPTIONS = BYMAX_CONFIG_OPTIONS;
517
538
  exports.BymaxConfigValidationError = BymaxConfigValidationError;
518
539
  exports.ConfigErrorCode = ConfigErrorCode;
540
+ exports.assertDistinctVariables = assertDistinctVariables;
519
541
  exports.createValidatedConfig = createValidatedConfig;
520
542
  exports.defineEnv = defineEnv;
521
543
  exports.resolveSourceNames = resolveSourceNames;
@@ -604,7 +604,7 @@ interface SourceBinding {
604
604
  * never a source record. Declared is not required: a leaf with a default or an
605
605
  * optional leaf is listed like any other. Two distinct paths can resolve to one
606
606
  * 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
607
+ * and none is dropped, though validation rejects a schema that holds such a pair. An open namespace changes nothing here, since opening
608
608
  * only waives strict unknown-key detection.
609
609
  *
610
610
  * @typeParam TShape - The two-level shape the schema was composed from.
@@ -619,4 +619,30 @@ interface SourceBinding {
619
619
  */
620
620
  declare function resolveSourceNames<TShape extends EnvShape = EnvShape>(schema: EnvSchema<TShape>): readonly SourceBinding[];
621
621
 
622
- 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, createValidatedConfig, defineEnv, resolveSourceNames };
622
+ /**
623
+ * @fileoverview Single-pass environment validator. Maps a flat source record
624
+ * onto the nested schema through the resolved source-name bindings, runs exactly
625
+ * one Zod parse, and either returns the typed parsed output or throws a
626
+ * BymaxConfigValidationError aggregating every violation. Zod issues are
627
+ * translated into value-free ConfigIssue descriptions that state only the
628
+ * expected constraint, never the received value. The one exception is the
629
+ * message a schema author attaches to a `custom` issue (`.check`, `.refine`,
630
+ * `.superRefine`), which is reported as written: it is schema text, not input,
631
+ * and it is the only place a conditional or cross-field rule can explain itself.
632
+ * @layer Service
633
+ */
634
+
635
+ /**
636
+ * Reject a schema in which two leaves read one source variable.
637
+ *
638
+ * Two leaves that derive, or are told through `meta({ env })`, the same variable would
639
+ * each read the one value and neither could report on its own, so the schema is wrong
640
+ * before any source is looked at. One issue is produced per colliding leaf, in order of
641
+ * first appearance, each naming the other leaves that share its variable.
642
+ *
643
+ * @param bindings - Every leaf binding of a schema, from `resolveSourceNames`.
644
+ * @throws {BymaxConfigValidationError} When any variable is bound by more than one leaf.
645
+ */
646
+ declare function assertDistinctVariables(bindings: ReadonlyArray<SourceBinding>): void;
647
+
648
+ 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 };
@@ -604,7 +604,7 @@ interface SourceBinding {
604
604
  * never a source record. Declared is not required: a leaf with a default or an
605
605
  * optional leaf is listed like any other. Two distinct paths can resolve to one
606
606
  * 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
607
+ * and none is dropped, though validation rejects a schema that holds such a pair. An open namespace changes nothing here, since opening
608
608
  * only waives strict unknown-key detection.
609
609
  *
610
610
  * @typeParam TShape - The two-level shape the schema was composed from.
@@ -619,4 +619,30 @@ interface SourceBinding {
619
619
  */
620
620
  declare function resolveSourceNames<TShape extends EnvShape = EnvShape>(schema: EnvSchema<TShape>): readonly SourceBinding[];
621
621
 
622
- 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, createValidatedConfig, defineEnv, resolveSourceNames };
622
+ /**
623
+ * @fileoverview Single-pass environment validator. Maps a flat source record
624
+ * onto the nested schema through the resolved source-name bindings, runs exactly
625
+ * one Zod parse, and either returns the typed parsed output or throws a
626
+ * BymaxConfigValidationError aggregating every violation. Zod issues are
627
+ * translated into value-free ConfigIssue descriptions that state only the
628
+ * expected constraint, never the received value. The one exception is the
629
+ * message a schema author attaches to a `custom` issue (`.check`, `.refine`,
630
+ * `.superRefine`), which is reported as written: it is schema text, not input,
631
+ * and it is the only place a conditional or cross-field rule can explain itself.
632
+ * @layer Service
633
+ */
634
+
635
+ /**
636
+ * Reject a schema in which two leaves read one source variable.
637
+ *
638
+ * Two leaves that derive, or are told through `meta({ env })`, the same variable would
639
+ * each read the one value and neither could report on its own, so the schema is wrong
640
+ * before any source is looked at. One issue is produced per colliding leaf, in order of
641
+ * first appearance, each naming the other leaves that share its variable.
642
+ *
643
+ * @param bindings - Every leaf binding of a schema, from `resolveSourceNames`.
644
+ * @throws {BymaxConfigValidationError} When any variable is bound by more than one leaf.
645
+ */
646
+ declare function assertDistinctVariables(bindings: ReadonlyArray<SourceBinding>): void;
647
+
648
+ 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 };
@@ -280,8 +280,29 @@ function detectUnknownKeys(prefixes, bindings, source) {
280
280
  (left, right) => Number(left.variable > right.variable) - Number(left.variable < right.variable)
281
281
  );
282
282
  }
283
+ function assertDistinctVariables(bindings) {
284
+ const byVariable = /* @__PURE__ */ new Map();
285
+ for (const { path, variable } of bindings) {
286
+ byVariable.set(variable, [...byVariable.get(variable) ?? [], path]);
287
+ }
288
+ const issues = [];
289
+ for (const [variable, paths] of byVariable) {
290
+ if (paths.length < 2) continue;
291
+ for (const path of paths) {
292
+ const others = paths.filter((other) => other !== path).join(", ");
293
+ issues.push({
294
+ path,
295
+ variable,
296
+ code: ConfigErrorCode.INVALID,
297
+ message: `shares this variable with ${others}; rename one with meta({ env })`
298
+ });
299
+ }
300
+ }
301
+ if (issues.length > 0) throw new BymaxConfigValidationError(issues);
302
+ }
283
303
  function validateEnv(schema, source, options) {
284
304
  const bindings = resolveSourceNames(schema);
305
+ assertDistinctVariables(bindings);
285
306
  const sourceMap = new Map(Object.entries(source));
286
307
  const namespaces = Object.keys(schema.shape);
287
308
  const candidate = buildCandidate(namespaces, collectPresentLeaves(bindings, sourceMap));
@@ -510,4 +531,4 @@ function defineEnv(shape) {
510
531
  return z.object(shape);
511
532
  }
512
533
 
513
- export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigValidationError, ConfigErrorCode, ConfigService, createValidatedConfig, defineEnv, resolveSourceNames };
534
+ export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigValidationError, ConfigErrorCode, ConfigService, assertDistinctVariables, createValidatedConfig, defineEnv, resolveSourceNames };
@@ -200,6 +200,7 @@ function explainUnsatisfiedLeaves(error, overridden) {
200
200
  );
201
201
  }
202
202
  function prepareTestConfig(schema, overrides) {
203
+ internal.assertDistinctVariables(internal.resolveSourceNames(schema));
203
204
  const overridden = /* @__PURE__ */ new Set();
204
205
  const source = applyOverrides(schema, synthesizePlaceholderSource(schema), overrides, overridden);
205
206
  try {
@@ -1,4 +1,4 @@
1
- import { BymaxConfigModule, createValidatedConfig, BymaxConfigValidationError, resolveSourceNames } from '@bymax-one/nest-config/internal';
1
+ import { BymaxConfigModule, assertDistinctVariables, resolveSourceNames, createValidatedConfig, BymaxConfigValidationError } from '@bymax-one/nest-config/internal';
2
2
 
3
3
  // src/testing/create-test-config.ts
4
4
  var FILLER_CHARACTER = "a";
@@ -198,6 +198,7 @@ function explainUnsatisfiedLeaves(error, overridden) {
198
198
  );
199
199
  }
200
200
  function prepareTestConfig(schema, overrides) {
201
+ assertDistinctVariables(resolveSourceNames(schema));
201
202
  const overridden = /* @__PURE__ */ new Set();
202
203
  const source = applyOverrides(schema, synthesizePlaceholderSource(schema), overrides, overridden);
203
204
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-config",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
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",