@bymax-one/nest-config 1.2.1 → 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,78 @@ 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
+
29
+ ## [1.2.2] - 2026-10-31
30
+
31
+ ### Added
32
+
33
+ - **`resolveSourceNames` and the `SourceBinding` type are exported from the package root.**
34
+ `resolveSourceNames(schema)` returns one `{ path, variable }` binding per leaf of a
35
+ `defineEnv` schema, in declaration order: the environment variable that leaf reads. It is the
36
+ function validation resolves its variables through, so the list equals what `forRoot` reads,
37
+ and a test can assert that every variable the schema declares appears in `.env.example`
38
+ without re-deriving the naming rules. Two distinct paths can derive one variable and both
39
+ bindings are returned, and declared is not required; the README section lists what the list
40
+ does not tell you. Both names were already importable from `./internal`, which promises
41
+ nothing; they are public now. **Apply to a derived backend:** nothing is required; import
42
+ `resolveSourceNames` from `@bymax-one/nest-config` rather than from `./internal`.
43
+
44
+ ### Fixed
45
+
46
+ - **A rule on a namespace that names no leaf is reported, not dropped.** A `.check`, `.refine` or
47
+ `.superRefine` on a namespace whose issue sets no `path` made the validation fail with
48
+ `environment validation failed (0 issues)` and an empty report. It is reported against the
49
+ namespace now: the issue's `path` is the namespace key, the variable column shows its prefix
50
+ followed by `*` (`STORAGE_*`), and the author's message is kept; a rule on the whole schema
51
+ reports `(schema)`. Such a rule runs once the namespace's own leaves are valid.
52
+ - **`meta({ env })` is found wherever it sits in the chain.** `z.string().meta({ env: 'L' }).default('x')`
53
+ silently read the derived name, because a wrapper builds a node that carries no metadata.
54
+ The override is read through them, the outermost usable one wins, and `resolveSourceNames` gives
55
+ the same answer as validation. **Apply to a derived backend:** a leaf that wrote `meta({ env })`
56
+ before a wrapper now reads that variable instead of the derived one; check that the variable is
57
+ set where the derived name was.
58
+ - **An asynchronous `onValidationError` that rejects no longer leaves an unhandled rejection.** The
59
+ provider awaits the hook, so the boot fails after it finishes, with the original validation error;
60
+ the hook's error, thrown or rejected, is attached to it as a non-enumerable `cause`. A hook that
61
+ threw was swallowed with no trace before. The hook's type returns `unknown`, so every existing
62
+ hook still compiles; a hook that never settles now holds the boot, since there is no timeout.
63
+ - **`createTestConfig` and `configTestingModule` synthesize more leaf types and say what to
64
+ override.** Leaf types that used to fail validation now get a placeholder or are left to the
65
+ schema. A leaf no placeholder can satisfy (a regex, a `.refine`)
66
+ fails with an issue that names it and says to override it, and `configTestingModule` raises
67
+ that when it is called instead of when the module compiles.
68
+
69
+ ### Documentation
70
+
71
+ - **The README now states what the code does.** The sample failure report printed messages
72
+ the validator never produces and was misaligned; it is now the exact output for the inputs
73
+ it documents. The Tech Stack and mutation-score figures, and the `configTestingModule`
74
+ example, now match the code, and the library is no longer said to have property-based
75
+ tests (the value-free guarantee is held by sentinel-secret tests).
76
+
77
+ - **Where "deep-frozen" stops is stated.** Plain objects and arrays are frozen; a `Date`, `Map`
78
+ or `Set` leaf is frozen as an object but keeps its mutators.
79
+
80
+ ### Tests
81
+
82
+ - **A contract spec pins the README to the code.** `src/readme-contract.spec.ts` parses the
83
+ README and asserts its documented defaults, error catalog, peer table, mutation score and
84
+ first sample report against the code, so the next drift fails CI.
85
+
14
86
  ## [1.2.1] - 2026-10-30
15
87
 
16
88
  ### Security
@@ -410,4 +482,6 @@ have regressed from. They are kept because the reasoning is worth having.
410
482
  [1.1.3]: https://github.com/bymaxone/nest-config/compare/v1.1.2...v1.1.3
411
483
  [1.2.0]: https://github.com/bymaxone/nest-config/compare/v1.1.3...v1.2.0
412
484
  [1.2.1]: https://github.com/bymaxone/nest-config/compare/v1.2.0...v1.2.1
413
- [Unreleased]: https://github.com/bymaxone/nest-config/compare/v1.2.1...HEAD
485
+ [1.2.2]: https://github.com/bymaxone/nest-config/compare/v1.2.1...v1.2.2
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.62%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>
@@ -75,8 +75,10 @@ minimal.
75
75
 
76
76
  ### 🔒 The Validated Object
77
77
 
78
- - ✅ **Immutable by construction** — deep-frozen before it enters the DI container;
79
- configuration is a fact about the process, not a mutable bag
78
+ - ✅ **Immutable by construction** — plain objects and arrays are deep-frozen before the
79
+ object enters the DI container; configuration is a fact about the process, not a mutable
80
+ bag (a `Date`, `Map` or `Set` leaf is frozen as an object but keeps its own mutators, see
81
+ [The validated object is frozen](#the-validated-object-is-frozen))
80
82
  - ✅ **Typed dot-path access** — `get('database.url')` is `string`, `get('server.port')` is
81
83
  `number`, with no casts and no `any` in the chain
82
84
  - ✅ **Injectable environment** — the raw source defaults to `process.env` but is a plain
@@ -101,7 +103,7 @@ minimal.
101
103
 
102
104
  | Subpath | Contents |
103
105
  | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104
- | `.` | `BymaxConfigModule`, `ConfigService`, `defineEnv`, the DI tokens, `BymaxConfigValidationError` and every option type |
106
+ | `.` | `BymaxConfigModule`, `ConfigService`, `defineEnv`, `resolveSourceNames`, the DI tokens, `BymaxConfigValidationError` and every option type |
105
107
  | `./testing` | `configTestingModule`, `createTestConfig` — a validated config for a test without booting the application's own |
106
108
  | `./internal` | The shared runtime both entry points build on. **Not public API**: it is in the `exports` map because it has to resolve at runtime, not because it is meant to be imported |
107
109
 
@@ -197,13 +199,16 @@ aggregated report, never one violation at a time:
197
199
  ```
198
200
  BymaxConfigValidationError: environment validation failed (3 issues)
199
201
 
200
- DATABASE_URL missing required value (expected: url)
201
- AUTH_JWT_SECRET too short (expected: string, minimum 32 characters)
202
- SERVER_PORT invalid value (expected: integer between 1 and 65535)
202
+ DATABASE_URL missing required value
203
+ AUTH_JWT_SECRET too short (expected: string, minimum 32 characters)
204
+ SERVER_PORT out of range (expected: number <= 65535)
203
205
 
204
206
  Fix the variables above and restart the process.
205
207
  ```
206
208
 
209
+ The report has one line per variable: 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.
211
+
207
212
  In a message this package generates, raw values are never printed — not
208
213
  truncated, not masked, absent. That is a hard guarantee, verified by tests. The
209
214
  one exception is a message you write yourself, described next.
@@ -237,6 +242,13 @@ that exact string is reserved — a schema that authors it verbatim reports
237
242
  custom error map) the default no longer matches, so the localized default is
238
243
  reported as written instead.
239
244
 
245
+ A rule on a namespace as a whole, such as a `.check` that compares two of its leaves, names no
246
+ leaf unless its issue sets a `path`. Without one it is reported against the namespace: the
247
+ `path` is the namespace key, the variable column shows its prefix followed by `*`
248
+ (`STORAGE_*`), and the message is the one the rule wrote. A rule on the whole schema is reported
249
+ with the variable column `(schema)` and an empty `path`. Zod does not run a namespace's own rule
250
+ once one of that namespace's leaves has failed, so such a rule appears once the leaves are valid.
251
+
240
252
  ## 📖 API Reference
241
253
 
242
254
  ### `defineEnv(shape)`
@@ -248,6 +260,11 @@ rewriting, cloning, or wrapping them, so coercion and defaults stay explicit
248
260
  consumer choices. Leaves use `z.coerce.*` because the source arrives as
249
261
  strings (the shape of `process.env`).
250
262
 
263
+ Two things follow from that. `z.coerce.boolean()` is `Boolean(value)`: any non-empty string,
264
+ including `'false'`, becomes `true`, so use `z.stringbool()` for a flag. And a variable set to the
265
+ empty string is present, not missing: `z.string()` accepts it and `z.coerce.number()` turns it into
266
+ `0`, so add `.min(1)` or a bound where empty should fail.
267
+
251
268
  A leaf can override its derived variable name through schema metadata when a
252
269
  legacy name must be preserved:
253
270
 
@@ -258,6 +275,11 @@ database: z.object({
258
275
  })
259
276
  ```
260
277
 
278
+ 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, and an
280
+ empty or non-string value is skipped. `resolveSourceNames` answers the same, because validation
281
+ reads its variables through it.
282
+
261
283
  A namespace can declare itself **open**, which waives strict unknown-key
262
284
  detection for the rest of its prefix. Use it when the prefix is shared with
263
285
  another program that reads its own variables — see
@@ -272,6 +294,9 @@ otel: z.object({ enabled: z.stringbool().default(false) }).meta({ open: true })
272
294
  Only the literal `true` opens a namespace. Any other value, and any metadata
273
295
  without the key, leaves detection in force.
274
296
 
297
+ Write `.meta({ open: true })` last: methods that build a new object schema, such as `.strict()`,
298
+ `.extend()` or `.pick()`, drop the metadata and close the namespace again.
299
+
275
300
  ### `BymaxConfigModule`
276
301
 
277
302
  | Method | Signature |
@@ -281,12 +306,12 @@ without the key, leaves detection in force.
281
306
 
282
307
  `BymaxConfigModuleOptions`:
283
308
 
284
- | Field | Type | Required | Notes |
285
- | ------------------- | ---------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
286
- | `schema` | `EnvSchema` | yes | Produced by `defineEnv`. |
287
- | `source` | `Record<string, string \| undefined>` | no | Defaults to `process.env` in the provider factory. Injectable for tests. |
288
- | `onValidationError` | `(issues: ReadonlyArray<ConfigIssue>) => void` | no | Observability hook invoked before the fail-fast throw. Cannot suppress the failure. |
289
- | `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. |
309
+ | Field | Type | Required | Notes |
310
+ | ------------------- | ------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
311
+ | `schema` | `EnvSchema` | yes | Produced by `defineEnv`. |
312
+ | `source` | `Record<string, string \| undefined>` | no | Defaults to `process.env` in the provider factory. Injectable for tests. |
313
+ | `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
+ | `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. |
290
315
 
291
316
  `forRootAsync` resolves the source through another provider, for example a
292
317
  secrets-manager client composed with `process.env`:
@@ -304,6 +329,21 @@ BymaxConfigModule.forRootAsync({
304
329
  })
305
330
  ```
306
331
 
332
+ `forRootAsync` takes Nest's configurable-module options: `useFactory` with
333
+ `inject` and `imports`, shown above; `useClass`, a class whose `create()` returns the options; and
334
+ `useExisting`, which reuses a provider that is already registered (import its module through
335
+ `imports`):
336
+
337
+ ```typescript
338
+ class ConfigOptionsFactory {
339
+ public create(): { schema: typeof envSchema; source: Record<string, string | undefined> } {
340
+ return { schema: envSchema, source: { ...process.env } }
341
+ }
342
+ }
343
+
344
+ BymaxConfigModule.forRootAsync({ useClass: ConfigOptionsFactory })
345
+ ```
346
+
307
347
  `isGlobal` defaults to `true` and can be set to `false` through the standard
308
348
  extras when an application intentionally scopes configuration to a submodule.
309
349
 
@@ -417,6 +457,10 @@ Because validation completed at bootstrap, `get` never throws for a declared
417
457
  path: every leaf either passed validation, received its default, or the
418
458
  process did not start.
419
459
 
460
+ The class is named `ConfigService`, as the one in `@nestjs/config` is; a backend that imports both
461
+ aliases one of them: `import { ConfigService as BymaxConfigService } from '@bymax-one/nest-config'`.
462
+ `has` is `value !== undefined`, so an absent optional leaf is `false` and a `null` is `true`.
463
+
420
464
  ### `BymaxConfigValidationError` and `ConfigIssue`
421
465
 
422
466
  ```typescript
@@ -446,6 +490,13 @@ exception is the one described above: a `custom` message written by your own sch
446
490
  is printed as written, so if one of yours interpolates a value, reviewing it before
447
491
  it reaches a log is the schema author's job, not this package's.
448
492
 
493
+ The hook may be asynchronous: a promise it returns is awaited before the boot fails, so a
494
+ report on its way to a log sink is not cut off by the process exiting. There is no timeout, so
495
+ a hook that never settles holds the boot. If the hook throws or rejects, the boot still fails
496
+ with the original validation error, unchanged in class, `code`, `issues` and `message`, and the
497
+ hook's error is attached to it as `error.cause` (non-enumerable, so `code` and `issues` stay
498
+ the only own enumerable properties).
499
+
449
500
  If you also log the thrown error from a bootstrap `catch`, how you pass it decides
450
501
  what survives. `code` and `issues` are the **only two own enumerable properties** of
451
502
  `BymaxConfigValidationError`; `name`, `message` and `stack` are non-enumerable, as
@@ -516,9 +567,10 @@ failure, and `issues` is what an alert or a dashboard keys on per variable.
516
567
 
517
568
  ### Injection tokens
518
569
 
519
- `BYMAX_CONFIG` and `BYMAX_CONFIG_OPTIONS` are module-local `Symbol` tokens,
520
- exported for advanced factory-style wiring that needs the raw frozen config
521
- or the resolved options directly instead of going through `ConfigService`:
570
+ `BYMAX_CONFIG` is a `Symbol` token for the raw frozen config, exported by the module for advanced
571
+ factory-style wiring that does not go through `ConfigService`. `BYMAX_CONFIG_OPTIONS` is the token of
572
+ the module's own options: it is provided inside the module only, so a provider in another module
573
+ cannot inject it, and the options it holds include the raw `source` record. Do not build on it.
522
574
 
523
575
  ```typescript
524
576
  import { Inject, Injectable } from '@nestjs/common'
@@ -536,18 +588,73 @@ export class RawConfigConsumer {
536
588
  `EnvSchema`, `EnvShape`, `EnvOutput`, `EnvLeaf`, and `EnvNamespace` type the
537
589
  two-level schema shape produced by `defineEnv`; `Path<T>` and `PathValue<T, P>`
538
590
  are the template-literal utilities behind `ConfigService.get`'s compile-time
539
- dot-path inference, limited to the two-level namespace convention. All five
591
+ dot-path inference, limited to the two-level namespace convention. All of them
540
592
  are exported for advanced generic code (custom decorators, typed wrappers)
541
593
  that needs to reference the schema shape directly.
542
594
 
595
+ ### `resolveSourceNames(schema)`
596
+
597
+ Lists the variable every leaf of a schema reads. It is the function validation itself
598
+ resolves its variables through, so the list cannot drift from what `forRoot` reads.
599
+
600
+ | Parameter | Type | Returns |
601
+ | --------- | ---------------------------- | -------------------------- |
602
+ | `schema` | `EnvSchema` from `defineEnv` | `readonly SourceBinding[]` |
603
+
604
+ `SourceBinding` is `{ path: string; variable: string }`: the leaf's dot path and the
605
+ environment variable it reads. The list has one entry per declared leaf, in declaration
606
+ order. A leaf reads its `meta({ env })` override when that is a non-empty string, and
607
+ otherwise `NAMESPACE_LEAF` in SCREAMING_SNAKE_CASE, with a camelCase key split into words.
608
+ It reads the schema only, never a source record, so it works on a schema exported as a
609
+ value.
610
+
611
+ ```typescript
612
+ // For the Quick Start schema:
613
+ resolveSourceNames(envSchema).map((binding) => binding.variable)
614
+ // ['SERVER_PORT', 'SERVER_ENV', 'DATABASE_URL', 'REDIS_URL', 'LOG_LEVEL']
615
+ ```
616
+
617
+ A test that keeps `.env.example` honest is the usual use:
618
+
619
+ ```typescript
620
+ import { readFileSync } from 'node:fs'
621
+ import { resolveSourceNames } from '@bymax-one/nest-config'
622
+ import { envSchema } from './config/env.schema'
623
+
624
+ it('documents every variable the schema reads in .env.example', () => {
625
+ const example = readFileSync('.env.example', 'utf8')
626
+ const documented = new Set(
627
+ example.split('\n').map((line) => /^#?\s*([A-Z][A-Z0-9_]*)=/.exec(line)?.[1])
628
+ )
629
+ const missing = resolveSourceNames(envSchema).filter(({ variable }) => !documented.has(variable))
630
+ expect(missing).toEqual([])
631
+ })
632
+ ```
633
+
634
+ #### What it does not tell you
635
+
636
+ - **Declared is not required.** A leaf with a default, or an optional leaf, is listed like
637
+ any other. Whether it belongs in a template file is your policy; the list says only that
638
+ the schema reads it.
639
+ - **Two paths can read one variable.** Names are derived from the keys, so `a.bC` and
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, 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.
644
+ - **An open namespace changes nothing.** `meta({ open: true })` only waives strict
645
+ unknown-key detection, so the list is the same whether a namespace is open or closed. It
646
+ does not help to find variables in `.env.example` that no leaf reads.
647
+ - **Two-level schemas only.** Deeper nesting is outside this contract, as it
648
+ is for `Path` and `PathValue`.
649
+
543
650
  ## 🚨 Error Catalog
544
651
 
545
- | Code | Meaning |
546
- | -------------------------- | ------------------------------------------------------ |
547
- | `BYMAX_CONFIG_VALIDATION` | One or more schema violations (top-level error code). |
548
- | `BYMAX_CONFIG_MISSING` | Required variable absent from the source (issue code). |
549
- | `BYMAX_CONFIG_INVALID` | Present but failed its constraint (issue code). |
550
- | `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. |
551
658
 
552
659
  ## 🏗️ Architecture
553
660
 
@@ -621,7 +728,7 @@ guaranteed to be printed, logged, and pasted into an issue. That shapes the whol
621
728
  Every message this library generates states the _expected_ constraint — the type, the
622
729
  allowed enum options taken from the schema, the bound — and never the received value.
623
730
  `env-validator` and `report-formatter` each say so in their own contracts, and
624
- property-based tests hold them to it. The messages you write yourself are the exception,
731
+ sentinel-secret tests hold them to it. The messages you write yourself are the exception,
625
732
  covered below.
626
733
 
627
734
  ### The service does not serialize its configuration
@@ -643,8 +750,16 @@ does not serve one request.
643
750
 
644
751
  ### The validated object is frozen
645
752
 
646
- It is deep-frozen before it enters the container, so nothing in the running process
647
- rewrites configuration under a component that already read it.
753
+ Plain objects and arrays are deep-frozen before the object enters the container, so nothing
754
+ in the running process rewrites those values under a component that already read them.
755
+
756
+ A `Date`, a `Map` or a `Set` leaf is frozen as an object but is **not** made immutable:
757
+ `setTime`, `Map.prototype.set` and `Set.prototype.add` still work, because the state they
758
+ change lives in internal slots that `Object.freeze` cannot reach, and the entries of a `Map` or
759
+ `Set` are not visited. Wrapping them in a read-only proxy was measured and rejected: it fails
760
+ `util.types.isDate`, reports `[object Object]` to `Object.prototype.toString`, cannot be given to
761
+ `structuredClone`, and does not satisfy `assert.deepStrictEqual` against a real `Date`. Prefer a
762
+ string or number leaf, or a `.transform` to a plain value, where immutability matters.
648
763
 
649
764
  ### Nothing is read from anywhere but the source you name
650
765
 
@@ -669,7 +784,7 @@ report.
669
784
  | Error messages | Expected constraint only — type, schema-declared enum options, bounds; never the received value. A `custom` message from your own schema is the exception: it prints as written |
670
785
  | Failure mode | Refuse to start; the whole schema is validated before the context finishes building |
671
786
  | Aggregation | Every failure reported at once, so a fix cycle is one restart rather than one per variable |
672
- | Immutability | The validated result is frozen before it is provided |
787
+ | Immutability | Plain objects and arrays in the validated result are frozen before it is provided; a `Date`, `Map` or `Set` keeps its mutators |
673
788
  | Service state | The root lives in a private field; serializing the injected service yields namespace names only |
674
789
  | Sources | `process.env` only — no file, no network, no remote provider |
675
790
  | Type surface | Dot paths checked against the schema at compile time; a typo is a build error, not a runtime `undefined` |
@@ -686,11 +801,11 @@ report.
686
801
 
687
802
  - **Runtime:** Node.js 24+
688
803
  - **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `@Global()`, `Symbol()` tokens)
689
- - **Validation:** Zod `^4` (peer) — the schema language, the coercion and the error source
804
+ - **Validation:** Zod `^4.6.6` (peer) — the schema language, the coercion and the error source
690
805
  - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations,
691
806
  and one shared `./internal` bundle so the module and its testing entry point hold
692
807
  a single class identity
693
- - **Tests:** Jest, including property-based tests over the report formatter + Stryker (mutation)
808
+ - **Tests:** Jest, including sentinel-secret tests over the validator and the report formatter, + Stryker (mutation)
694
809
  - **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
695
810
 
696
811
  ---
@@ -701,7 +816,7 @@ Configuration decides whether a process may start at all, so the suite is held t
701
816
  beyond "the tests pass".
702
817
 
703
818
  - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
704
- - ✅ **99.61% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
819
+ - ✅ **99.71% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
705
820
  `break: 95`; ten provable equivalents carry their reason inline, and the one survivor left
706
821
  is an equivalent no directive can reach, so it stays counted rather than silenced
707
822
  ([report](./docs/mutation_testing_results.md))
@@ -736,6 +851,30 @@ values elsewhere), applies selective overrides, then runs the exact
736
851
  production pipeline (validate, freeze), so a test exercises the same code
737
852
  path a running application does.
738
853
 
854
+ ```typescript
855
+ // Leaves createTestConfig fills without an override:
856
+ defineEnv({
857
+ text: z.object({ a: z.string().min(8), b: z.email(), c: z.url(), d: z.uuid() }),
858
+ time: z.object({
859
+ at: z.iso.datetime(),
860
+ on: z.iso.date(),
861
+ clock: z.iso.time(),
862
+ when: z.coerce.date()
863
+ }),
864
+ scalar: z.object({
865
+ n: z.coerce.number().int().min(1),
866
+ flag: z.stringbool(),
867
+ kind: z.enum(['a', 'b']),
868
+ fixed: z.literal('x')
869
+ })
870
+ })
871
+ ```
872
+
873
+ A leaf no placeholder can be known to satisfy, such as a
874
+ `z.string().regex(...)` or a `.refine(...)` rule, makes the helper throw a
875
+ `BymaxConfigValidationError` whose issue says `no placeholder satisfies this leaf` and names it
876
+ (`pass an override for region.code`); give that leaf an override and the helper builds.
877
+
739
878
  ```typescript
740
879
  import { createTestConfig } from '@bymax-one/nest-config/testing'
741
880
  import { envSchema } from './config/env.schema'
@@ -755,11 +894,18 @@ import { envSchema } from './config/env.schema'
755
894
  import { FeatureProvider } from './feature.provider'
756
895
 
757
896
  const moduleRef = await Test.createTestingModule({
758
- imports: [configTestingModule(envSchema, { server: { port: 0 } })],
897
+ imports: [configTestingModule(envSchema, { server: { port: 8080 } })],
759
898
  providers: [FeatureProvider]
760
899
  }).compile()
761
900
  ```
762
901
 
902
+ 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
903
+ a leaf what its environment variable would hold; an object becomes `'[object Object]'`, and an override
904
+ of `undefined` is ignored, so a defaulted leaf cannot be unset. Neither helper takes `strict`, a
905
+ custom `source`, a hook or `isGlobal`: `configTestingModule` registers the module globally, and a
906
+ failure case (a missing variable, a strict violation) is built with
907
+ `BymaxConfigModule.forRoot({ schema, source, strict: true })` and a source you write.
908
+
763
909
  Placeholder synthesis never fabricates values that could accidentally pass a
764
910
  secret-strength constraint; length and format constraints are honored. The
765
911
  subpath has no Jest dependency and works with any runner, though the family
package/dist/index.cjs CHANGED
@@ -32,3 +32,7 @@ Object.defineProperty(exports, "defineEnv", {
32
32
  enumerable: true,
33
33
  get: function () { return internal.defineEnv; }
34
34
  });
35
+ Object.defineProperty(exports, "resolveSourceNames", {
36
+ enumerable: true,
37
+ get: function () { return internal.resolveSourceNames; }
38
+ });
package/dist/index.d.cts CHANGED
@@ -1 +1 @@
1
- export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, ConfigIssue, ConfigIssueCode, ConfigService, ConfigValidationCode, EnvLeaf, EnvNamespace, EnvOutput, EnvSchema, EnvShape, Path, PathValue, defineEnv } from '@bymax-one/nest-config/internal';
1
+ export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, ConfigIssue, ConfigIssueCode, ConfigService, ConfigValidationCode, EnvLeaf, EnvNamespace, EnvOutput, EnvSchema, EnvShape, Path, PathValue, SourceBinding, defineEnv, resolveSourceNames } from '@bymax-one/nest-config/internal';
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, ConfigIssue, ConfigIssueCode, ConfigService, ConfigValidationCode, EnvLeaf, EnvNamespace, EnvOutput, EnvSchema, EnvShape, Path, PathValue, defineEnv } from '@bymax-one/nest-config/internal';
1
+ export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigModuleOptions, BymaxConfigValidationError, ConfigErrorCode, ConfigIssue, ConfigIssueCode, ConfigService, ConfigValidationCode, EnvLeaf, EnvNamespace, EnvOutput, EnvSchema, EnvShape, Path, PathValue, SourceBinding, defineEnv, resolveSourceNames } from '@bymax-one/nest-config/internal';
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigValidationError, ConfigErrorCode, ConfigService, defineEnv } from '@bymax-one/nest-config/internal';
1
+ export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS, BymaxConfigModule, BymaxConfigValidationError, ConfigErrorCode, ConfigService, defineEnv, resolveSourceNames } from '@bymax-one/nest-config/internal';
@@ -122,8 +122,12 @@ function toScreamingSnake(segment) {
122
122
  return segment.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").toUpperCase();
123
123
  }
124
124
  function readEnvOverride(leaf) {
125
- const override = leaf.meta()?.env;
126
- return typeof override === "string" && override.length > 0 ? override : void 0;
125
+ for (let node = leaf; node !== void 0; ) {
126
+ const override = node.meta()?.env;
127
+ if (typeof override === "string" && override.length > 0) return override;
128
+ node = node._zod.def.innerType ?? node._zod.def.in;
129
+ }
130
+ return void 0;
127
131
  }
128
132
  function readOpenFlag(namespace) {
129
133
  return namespace.meta()?.open === true;
@@ -210,13 +214,28 @@ function toConfigIssue(binding, issue, source) {
210
214
  message: authored ?? (isMissing ? MISSING_MESSAGE : describeConstraint(issue))
211
215
  };
212
216
  }
213
- function translateIssues(zodIssues, bindings, source) {
217
+ function toNamespaceIssue(path, variable, issue) {
218
+ return {
219
+ path,
220
+ variable,
221
+ code: ConfigErrorCode.INVALID,
222
+ message: authoredMessage(issue) ?? describeConstraint(issue)
223
+ };
224
+ }
225
+ function translateIssues(zodIssues, bindings, prefixes, source) {
226
+ const leafPaths = new Set(bindings.map((binding) => binding.path));
227
+ const namespaces = new Set(prefixes.map((entry) => entry.namespace));
214
228
  const issueByPath = /* @__PURE__ */ new Map();
229
+ const issueByNamespace = /* @__PURE__ */ new Map();
215
230
  for (const issue of zodIssues) {
216
231
  const path = issue.path.slice(0, 2).join(".");
217
- if (!issueByPath.has(path)) {
218
- issueByPath.set(path, issue);
232
+ if (leafPaths.has(path)) {
233
+ if (!issueByPath.has(path)) issueByPath.set(path, issue);
234
+ continue;
219
235
  }
236
+ const namespace = issue.path.length > 0 && namespaces.has(String(issue.path[0]));
237
+ const key = namespace ? String(issue.path[0]) : "";
238
+ if (!issueByNamespace.has(key)) issueByNamespace.set(key, issue);
220
239
  }
221
240
  const result = [];
222
241
  for (const binding of bindings) {
@@ -224,6 +243,13 @@ function translateIssues(zodIssues, bindings, source) {
224
243
  if (issue === void 0) continue;
225
244
  result.push(toConfigIssue(binding, issue, source));
226
245
  }
246
+ for (const entry of prefixes) {
247
+ const issue = issueByNamespace.get(entry.namespace);
248
+ if (issue === void 0) continue;
249
+ result.push(toNamespaceIssue(entry.namespace, `${entry.prefix}*`, issue));
250
+ }
251
+ const wholeSchema = issueByNamespace.get("");
252
+ if (wholeSchema !== void 0) result.push(toNamespaceIssue("", "(schema)", wholeSchema));
227
253
  return result;
228
254
  }
229
255
  function longestMatchingPrefix(prefixes, key) {
@@ -256,43 +282,74 @@ function detectUnknownKeys(prefixes, bindings, source) {
256
282
  (left, right) => Number(left.variable > right.variable) - Number(left.variable < right.variable)
257
283
  );
258
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
+ }
259
305
  function validateEnv(schema, source, options) {
260
306
  const bindings = resolveSourceNames(schema);
307
+ assertDistinctVariables(bindings);
261
308
  const sourceMap = new Map(Object.entries(source));
262
309
  const namespaces = Object.keys(schema.shape);
263
310
  const candidate = buildCandidate(namespaces, collectPresentLeaves(bindings, sourceMap));
264
311
  const result = schema.safeParse(candidate);
265
- const unknown = options?.strict === true ? detectUnknownKeys(resolveNamespacePrefixes(schema), bindings, sourceMap) : [];
312
+ const prefixes = resolveNamespacePrefixes(schema);
313
+ const unknown = options?.strict === true ? detectUnknownKeys(prefixes, bindings, sourceMap) : [];
266
314
  if (result.success) {
267
315
  if (unknown.length === 0) return result.data;
268
316
  throw new BymaxConfigValidationError(unknown);
269
317
  }
270
- const issues = translateIssues(result.error.issues, bindings, sourceMap);
318
+ const issues = translateIssues(result.error.issues, bindings, prefixes, sourceMap);
271
319
  throw new BymaxConfigValidationError([...issues, ...unknown]);
272
320
  }
273
321
 
274
322
  // src/config.providers.ts
275
- function reportIssues(hook, issues) {
323
+ async function reportIssues(hook, error) {
276
324
  if (hook === void 0) return;
277
325
  try {
278
- hook(issues);
279
- } catch {
326
+ await hook(error.issues);
327
+ } catch (hookError) {
328
+ Object.defineProperty(error, "cause", {
329
+ value: hookError,
330
+ enumerable: false,
331
+ writable: false,
332
+ configurable: false
333
+ });
280
334
  }
281
335
  }
282
336
  function createValidatedConfig(options) {
283
337
  const source = options.source ?? process.env;
338
+ return deepFreeze(validateEnv(options.schema, source, { strict: options.strict === true }));
339
+ }
340
+ async function provideValidatedConfig(options) {
284
341
  try {
285
- return deepFreeze(validateEnv(options.schema, source, { strict: options.strict === true }));
342
+ return createValidatedConfig(options);
286
343
  } catch (error) {
287
344
  if (error instanceof BymaxConfigValidationError) {
288
- reportIssues(options.onValidationError, error.issues);
345
+ await reportIssues(options.onValidationError, error);
289
346
  }
290
347
  throw error;
291
348
  }
292
349
  }
293
350
  var bymaxConfigProvider = {
294
351
  provide: BYMAX_CONFIG,
295
- useFactory: createValidatedConfig,
352
+ useFactory: provideValidatedConfig,
296
353
  inject: [BYMAX_CONFIG_OPTIONS]
297
354
  };
298
355
  var _config, _leaves;
@@ -480,6 +537,7 @@ exports.BYMAX_CONFIG = BYMAX_CONFIG;
480
537
  exports.BYMAX_CONFIG_OPTIONS = BYMAX_CONFIG_OPTIONS;
481
538
  exports.BymaxConfigValidationError = BymaxConfigValidationError;
482
539
  exports.ConfigErrorCode = ConfigErrorCode;
540
+ exports.assertDistinctVariables = assertDistinctVariables;
483
541
  exports.createValidatedConfig = createValidatedConfig;
484
542
  exports.defineEnv = defineEnv;
485
543
  exports.resolveSourceNames = resolveSourceNames;