@bymax-one/nest-config 1.2.1 → 1.2.2
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 +59 -1
- package/README.md +170 -25
- package/dist/index.cjs +4 -0
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/internal/index.cjs +49 -13
- package/dist/internal/index.d.cts +30 -17
- package/dist/internal/index.d.ts +30 -17
- package/dist/internal/index.mjs +49 -13
- package/dist/testing/index.cjs +58 -9
- package/dist/testing/index.d.cts +4 -1
- package/dist/testing/index.d.ts +4 -1
- package/dist/testing/index.mjs +59 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,63 @@ as the GitHub Release body, so each released version needs a matching
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.2.2] - 2026-10-31
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`resolveSourceNames` and the `SourceBinding` type are exported from the package root.**
|
|
19
|
+
`resolveSourceNames(schema)` returns one `{ path, variable }` binding per leaf of a
|
|
20
|
+
`defineEnv` schema, in declaration order: the environment variable that leaf reads. It is the
|
|
21
|
+
function validation resolves its variables through, so the list equals what `forRoot` reads,
|
|
22
|
+
and a test can assert that every variable the schema declares appears in `.env.example`
|
|
23
|
+
without re-deriving the naming rules. Two distinct paths can derive one variable and both
|
|
24
|
+
bindings are returned, and declared is not required; the README section lists what the list
|
|
25
|
+
does not tell you. Both names were already importable from `./internal`, which promises
|
|
26
|
+
nothing; they are public now. **Apply to a derived backend:** nothing is required; import
|
|
27
|
+
`resolveSourceNames` from `@bymax-one/nest-config` rather than from `./internal`.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **A rule on a namespace that names no leaf is reported, not dropped.** A `.check`, `.refine` or
|
|
32
|
+
`.superRefine` on a namespace whose issue sets no `path` made the validation fail with
|
|
33
|
+
`environment validation failed (0 issues)` and an empty report. It is reported against the
|
|
34
|
+
namespace now: the issue's `path` is the namespace key, the variable column shows its prefix
|
|
35
|
+
followed by `*` (`STORAGE_*`), and the author's message is kept; a rule on the whole schema
|
|
36
|
+
reports `(schema)`. Such a rule runs once the namespace's own leaves are valid.
|
|
37
|
+
- **`meta({ env })` is found wherever it sits in the chain.** `z.string().meta({ env: 'L' }).default('x')`
|
|
38
|
+
silently read the derived name, because a wrapper builds a node that carries no metadata.
|
|
39
|
+
The override is read through them, the outermost usable one wins, and `resolveSourceNames` gives
|
|
40
|
+
the same answer as validation. **Apply to a derived backend:** a leaf that wrote `meta({ env })`
|
|
41
|
+
before a wrapper now reads that variable instead of the derived one; check that the variable is
|
|
42
|
+
set where the derived name was.
|
|
43
|
+
- **An asynchronous `onValidationError` that rejects no longer leaves an unhandled rejection.** The
|
|
44
|
+
provider awaits the hook, so the boot fails after it finishes, with the original validation error;
|
|
45
|
+
the hook's error, thrown or rejected, is attached to it as a non-enumerable `cause`. A hook that
|
|
46
|
+
threw was swallowed with no trace before. The hook's type returns `unknown`, so every existing
|
|
47
|
+
hook still compiles; a hook that never settles now holds the boot, since there is no timeout.
|
|
48
|
+
- **`createTestConfig` and `configTestingModule` synthesize more leaf types and say what to
|
|
49
|
+
override.** Leaf types that used to fail validation now get a placeholder or are left to the
|
|
50
|
+
schema. A leaf no placeholder can satisfy (a regex, a `.refine`)
|
|
51
|
+
fails with an issue that names it and says to override it, and `configTestingModule` raises
|
|
52
|
+
that when it is called instead of when the module compiles.
|
|
53
|
+
|
|
54
|
+
### Documentation
|
|
55
|
+
|
|
56
|
+
- **The README now states what the code does.** The sample failure report printed messages
|
|
57
|
+
the validator never produces and was misaligned; it is now the exact output for the inputs
|
|
58
|
+
it documents. The Tech Stack and mutation-score figures, and the `configTestingModule`
|
|
59
|
+
example, now match the code, and the library is no longer said to have property-based
|
|
60
|
+
tests (the value-free guarantee is held by sentinel-secret tests).
|
|
61
|
+
|
|
62
|
+
- **Where "deep-frozen" stops is stated.** Plain objects and arrays are frozen; a `Date`, `Map`
|
|
63
|
+
or `Set` leaf is frozen as an object but keeps its mutators.
|
|
64
|
+
|
|
65
|
+
### Tests
|
|
66
|
+
|
|
67
|
+
- **A contract spec pins the README to the code.** `src/readme-contract.spec.ts` parses the
|
|
68
|
+
README and asserts its documented defaults, error catalog, peer table, mutation score and
|
|
69
|
+
first sample report against the code, so the next drift fails CI.
|
|
70
|
+
|
|
14
71
|
## [1.2.1] - 2026-10-30
|
|
15
72
|
|
|
16
73
|
### Security
|
|
@@ -410,4 +467,5 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
410
467
|
[1.1.3]: https://github.com/bymaxone/nest-config/compare/v1.1.2...v1.1.3
|
|
411
468
|
[1.2.0]: https://github.com/bymaxone/nest-config/compare/v1.1.3...v1.2.0
|
|
412
469
|
[1.2.1]: https://github.com/bymaxone/nest-config/compare/v1.2.0...v1.2.1
|
|
413
|
-
[
|
|
470
|
+
[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
|
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.
|
|
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>
|
|
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
|
|
79
|
-
configuration is a fact about the process, not a mutable
|
|
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
|
|
201
|
-
AUTH_JWT_SECRET
|
|
202
|
-
SERVER_PORT
|
|
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
|
|
285
|
-
| ------------------- |
|
|
286
|
-
| `schema` | `EnvSchema`
|
|
287
|
-
| `source` | `Record<string, string \| undefined>`
|
|
288
|
-
| `onValidationError` | `(issues: ReadonlyArray<ConfigIssue>) =>
|
|
289
|
-
| `strict` | `boolean`
|
|
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`
|
|
520
|
-
|
|
521
|
-
|
|
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,10 +588,64 @@ 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
|
|
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; validation reads the
|
|
642
|
+
shared variable for both leaves.
|
|
643
|
+
- **An open namespace changes nothing.** `meta({ open: true })` only waives strict
|
|
644
|
+
unknown-key detection, so the list is the same whether a namespace is open or closed. It
|
|
645
|
+
does not help to find variables in `.env.example` that no leaf reads.
|
|
646
|
+
- **Two-level schemas only.** Deeper nesting is outside this contract, as it
|
|
647
|
+
is for `Path` and `PathValue`.
|
|
648
|
+
|
|
543
649
|
## 🚨 Error Catalog
|
|
544
650
|
|
|
545
651
|
| Code | Meaning |
|
|
@@ -621,7 +727,7 @@ guaranteed to be printed, logged, and pasted into an issue. That shapes the whol
|
|
|
621
727
|
Every message this library generates states the _expected_ constraint — the type, the
|
|
622
728
|
allowed enum options taken from the schema, the bound — and never the received value.
|
|
623
729
|
`env-validator` and `report-formatter` each say so in their own contracts, and
|
|
624
|
-
|
|
730
|
+
sentinel-secret tests hold them to it. The messages you write yourself are the exception,
|
|
625
731
|
covered below.
|
|
626
732
|
|
|
627
733
|
### The service does not serialize its configuration
|
|
@@ -643,8 +749,16 @@ does not serve one request.
|
|
|
643
749
|
|
|
644
750
|
### The validated object is frozen
|
|
645
751
|
|
|
646
|
-
|
|
647
|
-
rewrites
|
|
752
|
+
Plain objects and arrays are deep-frozen before the object enters the container, so nothing
|
|
753
|
+
in the running process rewrites those values under a component that already read them.
|
|
754
|
+
|
|
755
|
+
A `Date`, a `Map` or a `Set` leaf is frozen as an object but is **not** made immutable:
|
|
756
|
+
`setTime`, `Map.prototype.set` and `Set.prototype.add` still work, because the state they
|
|
757
|
+
change lives in internal slots that `Object.freeze` cannot reach, and the entries of a `Map` or
|
|
758
|
+
`Set` are not visited. Wrapping them in a read-only proxy was measured and rejected: it fails
|
|
759
|
+
`util.types.isDate`, reports `[object Object]` to `Object.prototype.toString`, cannot be given to
|
|
760
|
+
`structuredClone`, and does not satisfy `assert.deepStrictEqual` against a real `Date`. Prefer a
|
|
761
|
+
string or number leaf, or a `.transform` to a plain value, where immutability matters.
|
|
648
762
|
|
|
649
763
|
### Nothing is read from anywhere but the source you name
|
|
650
764
|
|
|
@@ -669,7 +783,7 @@ report.
|
|
|
669
783
|
| 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
784
|
| Failure mode | Refuse to start; the whole schema is validated before the context finishes building |
|
|
671
785
|
| Aggregation | Every failure reported at once, so a fix cycle is one restart rather than one per variable |
|
|
672
|
-
| Immutability |
|
|
786
|
+
| Immutability | Plain objects and arrays in the validated result are frozen before it is provided; a `Date`, `Map` or `Set` keeps its mutators |
|
|
673
787
|
| Service state | The root lives in a private field; serializing the injected service yields namespace names only |
|
|
674
788
|
| Sources | `process.env` only — no file, no network, no remote provider |
|
|
675
789
|
| Type surface | Dot paths checked against the schema at compile time; a typo is a build error, not a runtime `undefined` |
|
|
@@ -686,11 +800,11 @@ report.
|
|
|
686
800
|
|
|
687
801
|
- **Runtime:** Node.js 24+
|
|
688
802
|
- **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `@Global()`, `Symbol()` tokens)
|
|
689
|
-
- **Validation:** Zod `^4` (peer) — the schema language, the coercion and the error source
|
|
803
|
+
- **Validation:** Zod `^4.6.6` (peer) — the schema language, the coercion and the error source
|
|
690
804
|
- **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations,
|
|
691
805
|
and one shared `./internal` bundle so the module and its testing entry point hold
|
|
692
806
|
a single class identity
|
|
693
|
-
- **Tests:** Jest, including
|
|
807
|
+
- **Tests:** Jest, including sentinel-secret tests over the validator and the report formatter, + Stryker (mutation)
|
|
694
808
|
- **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
|
|
695
809
|
|
|
696
810
|
---
|
|
@@ -701,7 +815,7 @@ Configuration decides whether a process may start at all, so the suite is held t
|
|
|
701
815
|
beyond "the tests pass".
|
|
702
816
|
|
|
703
817
|
- ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
|
|
704
|
-
- ✅ **99.
|
|
818
|
+
- ✅ **99.69% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
|
|
705
819
|
`break: 95`; ten provable equivalents carry their reason inline, and the one survivor left
|
|
706
820
|
is an equivalent no directive can reach, so it stays counted rather than silenced
|
|
707
821
|
([report](./docs/mutation_testing_results.md))
|
|
@@ -736,6 +850,30 @@ values elsewhere), applies selective overrides, then runs the exact
|
|
|
736
850
|
production pipeline (validate, freeze), so a test exercises the same code
|
|
737
851
|
path a running application does.
|
|
738
852
|
|
|
853
|
+
```typescript
|
|
854
|
+
// Leaves createTestConfig fills without an override:
|
|
855
|
+
defineEnv({
|
|
856
|
+
text: z.object({ a: z.string().min(8), b: z.email(), c: z.url(), d: z.uuid() }),
|
|
857
|
+
time: z.object({
|
|
858
|
+
at: z.iso.datetime(),
|
|
859
|
+
on: z.iso.date(),
|
|
860
|
+
clock: z.iso.time(),
|
|
861
|
+
when: z.coerce.date()
|
|
862
|
+
}),
|
|
863
|
+
scalar: z.object({
|
|
864
|
+
n: z.coerce.number().int().min(1),
|
|
865
|
+
flag: z.stringbool(),
|
|
866
|
+
kind: z.enum(['a', 'b']),
|
|
867
|
+
fixed: z.literal('x')
|
|
868
|
+
})
|
|
869
|
+
})
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
A leaf no placeholder can be known to satisfy, such as a
|
|
873
|
+
`z.string().regex(...)` or a `.refine(...)` rule, makes the helper throw a
|
|
874
|
+
`BymaxConfigValidationError` whose issue says `no placeholder satisfies this leaf` and names it
|
|
875
|
+
(`pass an override for region.code`); give that leaf an override and the helper builds.
|
|
876
|
+
|
|
739
877
|
```typescript
|
|
740
878
|
import { createTestConfig } from '@bymax-one/nest-config/testing'
|
|
741
879
|
import { envSchema } from './config/env.schema'
|
|
@@ -755,11 +893,18 @@ import { envSchema } from './config/env.schema'
|
|
|
755
893
|
import { FeatureProvider } from './feature.provider'
|
|
756
894
|
|
|
757
895
|
const moduleRef = await Test.createTestingModule({
|
|
758
|
-
imports: [configTestingModule(envSchema, { server: { port:
|
|
896
|
+
imports: [configTestingModule(envSchema, { server: { port: 8080 } })],
|
|
759
897
|
providers: [FeatureProvider]
|
|
760
898
|
}).compile()
|
|
761
899
|
```
|
|
762
900
|
|
|
901
|
+
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
|
+
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. Neither helper takes `strict`, a
|
|
904
|
+
custom `source`, a hook or `isGlobal`: `configTestingModule` registers the module globally, and a
|
|
905
|
+
failure case (a missing variable, a strict violation) is built with
|
|
906
|
+
`BymaxConfigModule.forRoot({ schema, source, strict: true })` and a source you write.
|
|
907
|
+
|
|
763
908
|
Placeholder synthesis never fabricates values that could accidentally pass a
|
|
764
909
|
secret-strength constraint; length and format constraints are honored. The
|
|
765
910
|
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';
|
package/dist/internal/index.cjs
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
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
|
|
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 (
|
|
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) {
|
|
@@ -262,37 +288,47 @@ function validateEnv(schema, source, options) {
|
|
|
262
288
|
const namespaces = Object.keys(schema.shape);
|
|
263
289
|
const candidate = buildCandidate(namespaces, collectPresentLeaves(bindings, sourceMap));
|
|
264
290
|
const result = schema.safeParse(candidate);
|
|
265
|
-
const
|
|
291
|
+
const prefixes = resolveNamespacePrefixes(schema);
|
|
292
|
+
const unknown = options?.strict === true ? detectUnknownKeys(prefixes, bindings, sourceMap) : [];
|
|
266
293
|
if (result.success) {
|
|
267
294
|
if (unknown.length === 0) return result.data;
|
|
268
295
|
throw new BymaxConfigValidationError(unknown);
|
|
269
296
|
}
|
|
270
|
-
const issues = translateIssues(result.error.issues, bindings, sourceMap);
|
|
297
|
+
const issues = translateIssues(result.error.issues, bindings, prefixes, sourceMap);
|
|
271
298
|
throw new BymaxConfigValidationError([...issues, ...unknown]);
|
|
272
299
|
}
|
|
273
300
|
|
|
274
301
|
// src/config.providers.ts
|
|
275
|
-
function reportIssues(hook,
|
|
302
|
+
async function reportIssues(hook, error) {
|
|
276
303
|
if (hook === void 0) return;
|
|
277
304
|
try {
|
|
278
|
-
hook(issues);
|
|
279
|
-
} catch {
|
|
305
|
+
await hook(error.issues);
|
|
306
|
+
} catch (hookError) {
|
|
307
|
+
Object.defineProperty(error, "cause", {
|
|
308
|
+
value: hookError,
|
|
309
|
+
enumerable: false,
|
|
310
|
+
writable: false,
|
|
311
|
+
configurable: false
|
|
312
|
+
});
|
|
280
313
|
}
|
|
281
314
|
}
|
|
282
315
|
function createValidatedConfig(options) {
|
|
283
316
|
const source = options.source ?? process.env;
|
|
317
|
+
return deepFreeze(validateEnv(options.schema, source, { strict: options.strict === true }));
|
|
318
|
+
}
|
|
319
|
+
async function provideValidatedConfig(options) {
|
|
284
320
|
try {
|
|
285
|
-
return
|
|
321
|
+
return createValidatedConfig(options);
|
|
286
322
|
} catch (error) {
|
|
287
323
|
if (error instanceof BymaxConfigValidationError) {
|
|
288
|
-
reportIssues(options.onValidationError, error
|
|
324
|
+
await reportIssues(options.onValidationError, error);
|
|
289
325
|
}
|
|
290
326
|
throw error;
|
|
291
327
|
}
|
|
292
328
|
}
|
|
293
329
|
var bymaxConfigProvider = {
|
|
294
330
|
provide: BYMAX_CONFIG,
|
|
295
|
-
useFactory:
|
|
331
|
+
useFactory: provideValidatedConfig,
|
|
296
332
|
inject: [BYMAX_CONFIG_OPTIONS]
|
|
297
333
|
};
|
|
298
334
|
var _config, _leaves;
|
|
@@ -230,13 +230,16 @@ interface BymaxConfigModuleOptions<TSchema extends EnvSchema = EnvSchema> {
|
|
|
230
230
|
readonly source?: Record<string, string | undefined>;
|
|
231
231
|
/**
|
|
232
232
|
* Observability hook invoked once with the structured issue list immediately
|
|
233
|
-
* before the validation error is thrown.
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
233
|
+
* before the validation error is thrown. Its return value is ignored unless it is a
|
|
234
|
+
* promise, which the module waits for before the boot fails; there is no timeout, so a
|
|
235
|
+
* hook that never settles holds the boot. The issues are value-free on the terms stated by
|
|
236
|
+
* {@link ConfigIssue}: generated descriptions never carry a received value, while a
|
|
237
|
+
* schema author's own `custom` message is passed through as written. It is
|
|
238
|
+
* observability only and cannot suppress the failure: the validation error still
|
|
239
|
+
* propagates when the hook throws or rejects, and the hook's error is attached to it
|
|
240
|
+
* as its non-enumerable `cause`.
|
|
238
241
|
*/
|
|
239
|
-
readonly onValidationError?: (issues: ReadonlyArray<ConfigIssue>) =>
|
|
242
|
+
readonly onValidationError?: (issues: ReadonlyArray<ConfigIssue>) => unknown;
|
|
240
243
|
/**
|
|
241
244
|
* When true, source variables that match a declared namespace prefix but no
|
|
242
245
|
* declared leaf produce `BYMAX_CONFIG_UNKNOWN_KEY` issues. Defaults to false.
|
|
@@ -534,24 +537,24 @@ declare function defineEnv<TShape extends EnvShape>(shape: TShape): EnvSchema<TS
|
|
|
534
537
|
* @fileoverview The BYMAX_CONFIG provider factory: the fail-fast heart of the
|
|
535
538
|
* module. It resolves the source (defaulting to `process.env`, using a custom
|
|
536
539
|
* source verbatim), runs the single-pass validator, and on success returns the
|
|
537
|
-
* deep-frozen typed config. On failure
|
|
538
|
-
*
|
|
540
|
+
* deep-frozen typed config. On failure the factory runs the observability hook
|
|
541
|
+
* and waits for it, then rethrows the aggregated error; the hook is
|
|
539
542
|
* observability only and can never suppress or replace the failure, so a
|
|
540
543
|
* misconfigured process is stopped before any consumer provider is constructed.
|
|
544
|
+
* A failing hook is recorded as the `cause` of that same error.
|
|
541
545
|
* @layer Provider
|
|
542
546
|
*/
|
|
543
547
|
|
|
544
548
|
/**
|
|
545
|
-
* Validate the source against the schema, freeze the result, or
|
|
549
|
+
* Validate the source against the schema, freeze the result, or throw.
|
|
546
550
|
*
|
|
547
551
|
* Resolves the source to the supplied record or `process.env`, runs exactly one
|
|
548
|
-
* validation pass, and returns the deep-frozen typed output.
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
* is instantiated.
|
|
552
|
+
* validation pass, and returns the deep-frozen typed output. A validation failure is
|
|
553
|
+
* thrown as the aggregated error; the observability hook belongs to the provider factory,
|
|
554
|
+
* which can wait for it, and this synchronous function never calls it.
|
|
552
555
|
*
|
|
553
556
|
* @typeParam TShape - The two-level schema shape.
|
|
554
|
-
* @param options - The resolved module options (schema, source,
|
|
557
|
+
* @param options - The resolved module options (schema, source, strict).
|
|
555
558
|
* @returns The validated, deep-frozen configuration output.
|
|
556
559
|
* @throws {BymaxConfigValidationError} When the source fails the schema.
|
|
557
560
|
* @example
|
|
@@ -560,7 +563,7 @@ declare function defineEnv<TShape extends EnvShape>(shape: TShape): EnvSchema<TS
|
|
|
560
563
|
* config.server.port; // typed, validated, frozen
|
|
561
564
|
* ```
|
|
562
565
|
*/
|
|
563
|
-
declare function createValidatedConfig<TShape extends EnvShape>(options: Omit<BymaxConfigModuleOptions, 'schema'> & {
|
|
566
|
+
declare function createValidatedConfig<TShape extends EnvShape>(options: Omit<BymaxConfigModuleOptions, 'schema' | 'onValidationError'> & {
|
|
564
567
|
readonly schema: EnvSchema<TShape>;
|
|
565
568
|
}): Readonly<EnvOutput<TShape>>;
|
|
566
569
|
|
|
@@ -571,13 +574,15 @@ declare function createValidatedConfig<TShape extends EnvShape>(options: Omit<By
|
|
|
571
574
|
* unless the leaf declares a `meta({ env })` override, which wins. It also
|
|
572
575
|
* resolves the shared prefix of each namespace and whether that namespace
|
|
573
576
|
* declared `meta({ open: true })`, the two facts strict unknown-key detection
|
|
574
|
-
* runs on.
|
|
575
|
-
*
|
|
577
|
+
* runs on. The validator and the package root share this one module:
|
|
578
|
+
* `resolveSourceNames` and `SourceBinding` are exported from the package root,
|
|
579
|
+
* while the namespace-prefix helpers stay internal.
|
|
576
580
|
* @layer Utility
|
|
577
581
|
*/
|
|
578
582
|
|
|
579
583
|
/**
|
|
580
584
|
* The resolved binding between one config leaf and its source variable name.
|
|
585
|
+
* Part of the public API, as the element type of `resolveSourceNames`.
|
|
581
586
|
*/
|
|
582
587
|
interface SourceBinding {
|
|
583
588
|
/** Nested config path, e.g. `database.url`. */
|
|
@@ -594,6 +599,14 @@ interface SourceBinding {
|
|
|
594
599
|
* is total: every declared leaf maps to exactly one variable, in declaration
|
|
595
600
|
* order.
|
|
596
601
|
*
|
|
602
|
+
* This is the list the validator reads, not a copy of its rules: validation
|
|
603
|
+
* resolves its bindings through this same function. It reads the schema only,
|
|
604
|
+
* never a source record. Declared is not required: a leaf with a default or an
|
|
605
|
+
* optional leaf is listed like any other. Two distinct paths can resolve to one
|
|
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
|
|
608
|
+
* only waives strict unknown-key detection.
|
|
609
|
+
*
|
|
597
610
|
* @typeParam TShape - The two-level shape the schema was composed from.
|
|
598
611
|
* @param schema - A schema produced by `defineEnv`.
|
|
599
612
|
* @returns The ordered list of leaf-to-variable bindings, typed `readonly`.
|
package/dist/internal/index.d.ts
CHANGED
|
@@ -230,13 +230,16 @@ interface BymaxConfigModuleOptions<TSchema extends EnvSchema = EnvSchema> {
|
|
|
230
230
|
readonly source?: Record<string, string | undefined>;
|
|
231
231
|
/**
|
|
232
232
|
* Observability hook invoked once with the structured issue list immediately
|
|
233
|
-
* before the validation error is thrown.
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
233
|
+
* before the validation error is thrown. Its return value is ignored unless it is a
|
|
234
|
+
* promise, which the module waits for before the boot fails; there is no timeout, so a
|
|
235
|
+
* hook that never settles holds the boot. The issues are value-free on the terms stated by
|
|
236
|
+
* {@link ConfigIssue}: generated descriptions never carry a received value, while a
|
|
237
|
+
* schema author's own `custom` message is passed through as written. It is
|
|
238
|
+
* observability only and cannot suppress the failure: the validation error still
|
|
239
|
+
* propagates when the hook throws or rejects, and the hook's error is attached to it
|
|
240
|
+
* as its non-enumerable `cause`.
|
|
238
241
|
*/
|
|
239
|
-
readonly onValidationError?: (issues: ReadonlyArray<ConfigIssue>) =>
|
|
242
|
+
readonly onValidationError?: (issues: ReadonlyArray<ConfigIssue>) => unknown;
|
|
240
243
|
/**
|
|
241
244
|
* When true, source variables that match a declared namespace prefix but no
|
|
242
245
|
* declared leaf produce `BYMAX_CONFIG_UNKNOWN_KEY` issues. Defaults to false.
|
|
@@ -534,24 +537,24 @@ declare function defineEnv<TShape extends EnvShape>(shape: TShape): EnvSchema<TS
|
|
|
534
537
|
* @fileoverview The BYMAX_CONFIG provider factory: the fail-fast heart of the
|
|
535
538
|
* module. It resolves the source (defaulting to `process.env`, using a custom
|
|
536
539
|
* source verbatim), runs the single-pass validator, and on success returns the
|
|
537
|
-
* deep-frozen typed config. On failure
|
|
538
|
-
*
|
|
540
|
+
* deep-frozen typed config. On failure the factory runs the observability hook
|
|
541
|
+
* and waits for it, then rethrows the aggregated error; the hook is
|
|
539
542
|
* observability only and can never suppress or replace the failure, so a
|
|
540
543
|
* misconfigured process is stopped before any consumer provider is constructed.
|
|
544
|
+
* A failing hook is recorded as the `cause` of that same error.
|
|
541
545
|
* @layer Provider
|
|
542
546
|
*/
|
|
543
547
|
|
|
544
548
|
/**
|
|
545
|
-
* Validate the source against the schema, freeze the result, or
|
|
549
|
+
* Validate the source against the schema, freeze the result, or throw.
|
|
546
550
|
*
|
|
547
551
|
* Resolves the source to the supplied record or `process.env`, runs exactly one
|
|
548
|
-
* validation pass, and returns the deep-frozen typed output.
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
* is instantiated.
|
|
552
|
+
* validation pass, and returns the deep-frozen typed output. A validation failure is
|
|
553
|
+
* thrown as the aggregated error; the observability hook belongs to the provider factory,
|
|
554
|
+
* which can wait for it, and this synchronous function never calls it.
|
|
552
555
|
*
|
|
553
556
|
* @typeParam TShape - The two-level schema shape.
|
|
554
|
-
* @param options - The resolved module options (schema, source,
|
|
557
|
+
* @param options - The resolved module options (schema, source, strict).
|
|
555
558
|
* @returns The validated, deep-frozen configuration output.
|
|
556
559
|
* @throws {BymaxConfigValidationError} When the source fails the schema.
|
|
557
560
|
* @example
|
|
@@ -560,7 +563,7 @@ declare function defineEnv<TShape extends EnvShape>(shape: TShape): EnvSchema<TS
|
|
|
560
563
|
* config.server.port; // typed, validated, frozen
|
|
561
564
|
* ```
|
|
562
565
|
*/
|
|
563
|
-
declare function createValidatedConfig<TShape extends EnvShape>(options: Omit<BymaxConfigModuleOptions, 'schema'> & {
|
|
566
|
+
declare function createValidatedConfig<TShape extends EnvShape>(options: Omit<BymaxConfigModuleOptions, 'schema' | 'onValidationError'> & {
|
|
564
567
|
readonly schema: EnvSchema<TShape>;
|
|
565
568
|
}): Readonly<EnvOutput<TShape>>;
|
|
566
569
|
|
|
@@ -571,13 +574,15 @@ declare function createValidatedConfig<TShape extends EnvShape>(options: Omit<By
|
|
|
571
574
|
* unless the leaf declares a `meta({ env })` override, which wins. It also
|
|
572
575
|
* resolves the shared prefix of each namespace and whether that namespace
|
|
573
576
|
* declared `meta({ open: true })`, the two facts strict unknown-key detection
|
|
574
|
-
* runs on.
|
|
575
|
-
*
|
|
577
|
+
* runs on. The validator and the package root share this one module:
|
|
578
|
+
* `resolveSourceNames` and `SourceBinding` are exported from the package root,
|
|
579
|
+
* while the namespace-prefix helpers stay internal.
|
|
576
580
|
* @layer Utility
|
|
577
581
|
*/
|
|
578
582
|
|
|
579
583
|
/**
|
|
580
584
|
* The resolved binding between one config leaf and its source variable name.
|
|
585
|
+
* Part of the public API, as the element type of `resolveSourceNames`.
|
|
581
586
|
*/
|
|
582
587
|
interface SourceBinding {
|
|
583
588
|
/** Nested config path, e.g. `database.url`. */
|
|
@@ -594,6 +599,14 @@ interface SourceBinding {
|
|
|
594
599
|
* is total: every declared leaf maps to exactly one variable, in declaration
|
|
595
600
|
* order.
|
|
596
601
|
*
|
|
602
|
+
* This is the list the validator reads, not a copy of its rules: validation
|
|
603
|
+
* resolves its bindings through this same function. It reads the schema only,
|
|
604
|
+
* never a source record. Declared is not required: a leaf with a default or an
|
|
605
|
+
* optional leaf is listed like any other. Two distinct paths can resolve to one
|
|
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
|
|
608
|
+
* only waives strict unknown-key detection.
|
|
609
|
+
*
|
|
597
610
|
* @typeParam TShape - The two-level shape the schema was composed from.
|
|
598
611
|
* @param schema - A schema produced by `defineEnv`.
|
|
599
612
|
* @returns The ordered list of leaf-to-variable bindings, typed `readonly`.
|
package/dist/internal/index.mjs
CHANGED
|
@@ -120,8 +120,12 @@ function toScreamingSnake(segment) {
|
|
|
120
120
|
return segment.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").toUpperCase();
|
|
121
121
|
}
|
|
122
122
|
function readEnvOverride(leaf) {
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
for (let node = leaf; node !== void 0; ) {
|
|
124
|
+
const override = node.meta()?.env;
|
|
125
|
+
if (typeof override === "string" && override.length > 0) return override;
|
|
126
|
+
node = node._zod.def.innerType ?? node._zod.def.in;
|
|
127
|
+
}
|
|
128
|
+
return void 0;
|
|
125
129
|
}
|
|
126
130
|
function readOpenFlag(namespace) {
|
|
127
131
|
return namespace.meta()?.open === true;
|
|
@@ -208,13 +212,28 @@ function toConfigIssue(binding, issue, source) {
|
|
|
208
212
|
message: authored ?? (isMissing ? MISSING_MESSAGE : describeConstraint(issue))
|
|
209
213
|
};
|
|
210
214
|
}
|
|
211
|
-
function
|
|
215
|
+
function toNamespaceIssue(path, variable, issue) {
|
|
216
|
+
return {
|
|
217
|
+
path,
|
|
218
|
+
variable,
|
|
219
|
+
code: ConfigErrorCode.INVALID,
|
|
220
|
+
message: authoredMessage(issue) ?? describeConstraint(issue)
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function translateIssues(zodIssues, bindings, prefixes, source) {
|
|
224
|
+
const leafPaths = new Set(bindings.map((binding) => binding.path));
|
|
225
|
+
const namespaces = new Set(prefixes.map((entry) => entry.namespace));
|
|
212
226
|
const issueByPath = /* @__PURE__ */ new Map();
|
|
227
|
+
const issueByNamespace = /* @__PURE__ */ new Map();
|
|
213
228
|
for (const issue of zodIssues) {
|
|
214
229
|
const path = issue.path.slice(0, 2).join(".");
|
|
215
|
-
if (
|
|
216
|
-
issueByPath.set(path, issue);
|
|
230
|
+
if (leafPaths.has(path)) {
|
|
231
|
+
if (!issueByPath.has(path)) issueByPath.set(path, issue);
|
|
232
|
+
continue;
|
|
217
233
|
}
|
|
234
|
+
const namespace = issue.path.length > 0 && namespaces.has(String(issue.path[0]));
|
|
235
|
+
const key = namespace ? String(issue.path[0]) : "";
|
|
236
|
+
if (!issueByNamespace.has(key)) issueByNamespace.set(key, issue);
|
|
218
237
|
}
|
|
219
238
|
const result = [];
|
|
220
239
|
for (const binding of bindings) {
|
|
@@ -222,6 +241,13 @@ function translateIssues(zodIssues, bindings, source) {
|
|
|
222
241
|
if (issue === void 0) continue;
|
|
223
242
|
result.push(toConfigIssue(binding, issue, source));
|
|
224
243
|
}
|
|
244
|
+
for (const entry of prefixes) {
|
|
245
|
+
const issue = issueByNamespace.get(entry.namespace);
|
|
246
|
+
if (issue === void 0) continue;
|
|
247
|
+
result.push(toNamespaceIssue(entry.namespace, `${entry.prefix}*`, issue));
|
|
248
|
+
}
|
|
249
|
+
const wholeSchema = issueByNamespace.get("");
|
|
250
|
+
if (wholeSchema !== void 0) result.push(toNamespaceIssue("", "(schema)", wholeSchema));
|
|
225
251
|
return result;
|
|
226
252
|
}
|
|
227
253
|
function longestMatchingPrefix(prefixes, key) {
|
|
@@ -260,37 +286,47 @@ function validateEnv(schema, source, options) {
|
|
|
260
286
|
const namespaces = Object.keys(schema.shape);
|
|
261
287
|
const candidate = buildCandidate(namespaces, collectPresentLeaves(bindings, sourceMap));
|
|
262
288
|
const result = schema.safeParse(candidate);
|
|
263
|
-
const
|
|
289
|
+
const prefixes = resolveNamespacePrefixes(schema);
|
|
290
|
+
const unknown = options?.strict === true ? detectUnknownKeys(prefixes, bindings, sourceMap) : [];
|
|
264
291
|
if (result.success) {
|
|
265
292
|
if (unknown.length === 0) return result.data;
|
|
266
293
|
throw new BymaxConfigValidationError(unknown);
|
|
267
294
|
}
|
|
268
|
-
const issues = translateIssues(result.error.issues, bindings, sourceMap);
|
|
295
|
+
const issues = translateIssues(result.error.issues, bindings, prefixes, sourceMap);
|
|
269
296
|
throw new BymaxConfigValidationError([...issues, ...unknown]);
|
|
270
297
|
}
|
|
271
298
|
|
|
272
299
|
// src/config.providers.ts
|
|
273
|
-
function reportIssues(hook,
|
|
300
|
+
async function reportIssues(hook, error) {
|
|
274
301
|
if (hook === void 0) return;
|
|
275
302
|
try {
|
|
276
|
-
hook(issues);
|
|
277
|
-
} catch {
|
|
303
|
+
await hook(error.issues);
|
|
304
|
+
} catch (hookError) {
|
|
305
|
+
Object.defineProperty(error, "cause", {
|
|
306
|
+
value: hookError,
|
|
307
|
+
enumerable: false,
|
|
308
|
+
writable: false,
|
|
309
|
+
configurable: false
|
|
310
|
+
});
|
|
278
311
|
}
|
|
279
312
|
}
|
|
280
313
|
function createValidatedConfig(options) {
|
|
281
314
|
const source = options.source ?? process.env;
|
|
315
|
+
return deepFreeze(validateEnv(options.schema, source, { strict: options.strict === true }));
|
|
316
|
+
}
|
|
317
|
+
async function provideValidatedConfig(options) {
|
|
282
318
|
try {
|
|
283
|
-
return
|
|
319
|
+
return createValidatedConfig(options);
|
|
284
320
|
} catch (error) {
|
|
285
321
|
if (error instanceof BymaxConfigValidationError) {
|
|
286
|
-
reportIssues(options.onValidationError, error
|
|
322
|
+
await reportIssues(options.onValidationError, error);
|
|
287
323
|
}
|
|
288
324
|
throw error;
|
|
289
325
|
}
|
|
290
326
|
}
|
|
291
327
|
var bymaxConfigProvider = {
|
|
292
328
|
provide: BYMAX_CONFIG,
|
|
293
|
-
useFactory:
|
|
329
|
+
useFactory: provideValidatedConfig,
|
|
294
330
|
inject: [BYMAX_CONFIG_OPTIONS]
|
|
295
331
|
};
|
|
296
332
|
var _config, _leaves;
|
package/dist/testing/index.cjs
CHANGED
|
@@ -9,6 +9,10 @@ var PLACEHOLDER_EMAIL_DOMAIN = "@placeholder.local";
|
|
|
9
9
|
var DEFAULT_NUMBER = 1;
|
|
10
10
|
var DEFAULT_STRING_LENGTH = 1;
|
|
11
11
|
var PLACEHOLDER_BOOLEAN = "true";
|
|
12
|
+
var PLACEHOLDER_UUID = "00000000-0000-4000-8000-000000000000";
|
|
13
|
+
var PLACEHOLDER_DATETIME = "2000-01-01T00:00:00.000Z";
|
|
14
|
+
var PLACEHOLDER_DATE = "2000-01-01";
|
|
15
|
+
var PLACEHOLDER_TIME = "00:00:00";
|
|
12
16
|
function readDef(leaf) {
|
|
13
17
|
return leaf._zod.def;
|
|
14
18
|
}
|
|
@@ -59,11 +63,23 @@ function synthesizePlainString(lengths) {
|
|
|
59
63
|
const bounded = target > lengths.max ? lengths.max : target;
|
|
60
64
|
return FILLER_CHARACTER.repeat(bounded);
|
|
61
65
|
}
|
|
66
|
+
function formatOf(def) {
|
|
67
|
+
const declared = checksOf(def).map((wrapper) => wrapper._zod.def).find((check) => check.check === "string_format");
|
|
68
|
+
return def.format ?? declared?.format;
|
|
69
|
+
}
|
|
70
|
+
var FIXED_FORMATS = /* @__PURE__ */ new Map([
|
|
71
|
+
["uuid", PLACEHOLDER_UUID],
|
|
72
|
+
["datetime", PLACEHOLDER_DATETIME],
|
|
73
|
+
["date", PLACEHOLDER_DATE],
|
|
74
|
+
["time", PLACEHOLDER_TIME]
|
|
75
|
+
]);
|
|
62
76
|
function synthesizeString(def) {
|
|
63
77
|
const lengths = readStringLengths(def);
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
return
|
|
78
|
+
const format = formatOf(def);
|
|
79
|
+
if (format === "url") return synthesizeUrl(lengths);
|
|
80
|
+
if (format === "email") return synthesizeEmail(lengths);
|
|
81
|
+
const fixed = format === void 0 ? void 0 : FIXED_FORMATS.get(format);
|
|
82
|
+
return fixed ?? synthesizePlainString(lengths);
|
|
67
83
|
}
|
|
68
84
|
function selectNumber(bounds) {
|
|
69
85
|
const { lower, upper, integer } = bounds;
|
|
@@ -99,20 +115,35 @@ function synthesizeEnum(def) {
|
|
|
99
115
|
const [first] = Object.values(def.entries);
|
|
100
116
|
return String(first);
|
|
101
117
|
}
|
|
118
|
+
function withoutOptional(leaf) {
|
|
119
|
+
const def = readDef(leaf);
|
|
120
|
+
return def.type === "optional" ? withoutOptional(def.innerType) : leaf;
|
|
121
|
+
}
|
|
102
122
|
function synthesizeLeaf(leaf) {
|
|
103
123
|
const def = readDef(leaf);
|
|
104
124
|
switch (def.type) {
|
|
105
125
|
case "default":
|
|
126
|
+
case "prefault":
|
|
127
|
+
case "catch":
|
|
106
128
|
case "optional":
|
|
107
129
|
return void 0;
|
|
108
130
|
case "nullable":
|
|
131
|
+
case "readonly":
|
|
109
132
|
return synthesizeLeaf(def.innerType);
|
|
133
|
+
case "nonoptional":
|
|
134
|
+
return synthesizeLeaf(withoutOptional(def.innerType));
|
|
135
|
+
case "pipe":
|
|
136
|
+
return readDef(def.out).type === "boolean" && readDef(def.in).type === "string" ? PLACEHOLDER_BOOLEAN : synthesizeLeaf(def.in);
|
|
110
137
|
case "string":
|
|
111
138
|
return synthesizeString(def);
|
|
112
139
|
case "number":
|
|
113
140
|
return synthesizeNumber(def);
|
|
114
141
|
case "boolean":
|
|
115
142
|
return PLACEHOLDER_BOOLEAN;
|
|
143
|
+
case "date":
|
|
144
|
+
return PLACEHOLDER_DATETIME;
|
|
145
|
+
case "literal":
|
|
146
|
+
return String(def.values[0]);
|
|
116
147
|
case "enum":
|
|
117
148
|
return synthesizeEnum(def);
|
|
118
149
|
default:
|
|
@@ -145,25 +176,43 @@ function flattenOverrides(overrides) {
|
|
|
145
176
|
}
|
|
146
177
|
return flat;
|
|
147
178
|
}
|
|
148
|
-
function applyOverrides(schema, base, overrides) {
|
|
179
|
+
function applyOverrides(schema, base, overrides, overridden) {
|
|
149
180
|
const flat = flattenOverrides(overrides ?? {});
|
|
150
181
|
const entries = new Map(Object.entries(base));
|
|
151
182
|
for (const binding of internal.resolveSourceNames(schema)) {
|
|
152
183
|
const value = flat.get(binding.path);
|
|
153
|
-
if (value
|
|
184
|
+
if (value === void 0) continue;
|
|
185
|
+
entries.set(binding.variable, String(value));
|
|
186
|
+
overridden.add(binding.variable);
|
|
154
187
|
}
|
|
155
188
|
const source = /* @__PURE__ */ Object.create(null);
|
|
156
189
|
for (const [variable, value] of entries) source[variable] = value;
|
|
157
190
|
return source;
|
|
158
191
|
}
|
|
159
|
-
function
|
|
160
|
-
return
|
|
192
|
+
function explainUnsatisfiedLeaves(error, overridden) {
|
|
193
|
+
return new internal.BymaxConfigValidationError(
|
|
194
|
+
error.issues.map(
|
|
195
|
+
(issue) => !issue.path.includes(".") || overridden.has(issue.variable) ? issue : {
|
|
196
|
+
...issue,
|
|
197
|
+
message: `no placeholder satisfies this leaf (${issue.message}); pass an override for ${issue.path}`
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
function prepareTestConfig(schema, overrides) {
|
|
203
|
+
const overridden = /* @__PURE__ */ new Set();
|
|
204
|
+
const source = applyOverrides(schema, synthesizePlaceholderSource(schema), overrides, overridden);
|
|
205
|
+
try {
|
|
206
|
+
return { source, config: internal.createValidatedConfig({ schema, source }) };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw error instanceof internal.BymaxConfigValidationError ? explainUnsatisfiedLeaves(error, overridden) : error;
|
|
209
|
+
}
|
|
161
210
|
}
|
|
162
211
|
function createTestConfig(schema, overrides) {
|
|
163
|
-
return
|
|
212
|
+
return prepareTestConfig(schema, overrides).config;
|
|
164
213
|
}
|
|
165
214
|
function configTestingModule(schema, overrides) {
|
|
166
|
-
const source =
|
|
215
|
+
const { source } = prepareTestConfig(schema, overrides);
|
|
167
216
|
return internal.BymaxConfigModule.forRoot({ schema, source });
|
|
168
217
|
}
|
|
169
218
|
|
package/dist/testing/index.d.cts
CHANGED
|
@@ -40,7 +40,8 @@ type ConfigOverrides<TShape extends EnvShape> = {
|
|
|
40
40
|
* @param schema - A schema produced by `defineEnv`.
|
|
41
41
|
* @param overrides - Optional selective nested partial overrides.
|
|
42
42
|
* @returns The validated, deep-frozen configuration typed by the schema.
|
|
43
|
-
* @throws {BymaxConfigValidationError} When an override violates a constraint
|
|
43
|
+
* @throws {BymaxConfigValidationError} When an override violates a constraint, or when no
|
|
44
|
+
* placeholder satisfies a leaf: that issue says so and names the leaf to override.
|
|
44
45
|
* @example
|
|
45
46
|
* ```typescript
|
|
46
47
|
* const config = createTestConfig(envSchema, {
|
|
@@ -75,6 +76,8 @@ declare function createTestConfig<TShape extends EnvShape>(schema: EnvSchema<TSh
|
|
|
75
76
|
* @param schema - A schema produced by `defineEnv`.
|
|
76
77
|
* @param overrides - Optional selective nested partial overrides.
|
|
77
78
|
* @returns A dynamic module registered through the production `forRoot` path.
|
|
79
|
+
* @throws {BymaxConfigValidationError} When an override violates a constraint, or when no
|
|
80
|
+
* placeholder satisfies a leaf; the module is not built, and the issue names the leaf to override.
|
|
78
81
|
* @example
|
|
79
82
|
* ```typescript
|
|
80
83
|
* const moduleRef = await Test.createTestingModule({
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -40,7 +40,8 @@ type ConfigOverrides<TShape extends EnvShape> = {
|
|
|
40
40
|
* @param schema - A schema produced by `defineEnv`.
|
|
41
41
|
* @param overrides - Optional selective nested partial overrides.
|
|
42
42
|
* @returns The validated, deep-frozen configuration typed by the schema.
|
|
43
|
-
* @throws {BymaxConfigValidationError} When an override violates a constraint
|
|
43
|
+
* @throws {BymaxConfigValidationError} When an override violates a constraint, or when no
|
|
44
|
+
* placeholder satisfies a leaf: that issue says so and names the leaf to override.
|
|
44
45
|
* @example
|
|
45
46
|
* ```typescript
|
|
46
47
|
* const config = createTestConfig(envSchema, {
|
|
@@ -75,6 +76,8 @@ declare function createTestConfig<TShape extends EnvShape>(schema: EnvSchema<TSh
|
|
|
75
76
|
* @param schema - A schema produced by `defineEnv`.
|
|
76
77
|
* @param overrides - Optional selective nested partial overrides.
|
|
77
78
|
* @returns A dynamic module registered through the production `forRoot` path.
|
|
79
|
+
* @throws {BymaxConfigValidationError} When an override violates a constraint, or when no
|
|
80
|
+
* placeholder satisfies a leaf; the module is not built, and the issue names the leaf to override.
|
|
78
81
|
* @example
|
|
79
82
|
* ```typescript
|
|
80
83
|
* const moduleRef = await Test.createTestingModule({
|
package/dist/testing/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createValidatedConfig,
|
|
1
|
+
import { BymaxConfigModule, createValidatedConfig, BymaxConfigValidationError, resolveSourceNames } from '@bymax-one/nest-config/internal';
|
|
2
2
|
|
|
3
3
|
// src/testing/create-test-config.ts
|
|
4
4
|
var FILLER_CHARACTER = "a";
|
|
@@ -7,6 +7,10 @@ var PLACEHOLDER_EMAIL_DOMAIN = "@placeholder.local";
|
|
|
7
7
|
var DEFAULT_NUMBER = 1;
|
|
8
8
|
var DEFAULT_STRING_LENGTH = 1;
|
|
9
9
|
var PLACEHOLDER_BOOLEAN = "true";
|
|
10
|
+
var PLACEHOLDER_UUID = "00000000-0000-4000-8000-000000000000";
|
|
11
|
+
var PLACEHOLDER_DATETIME = "2000-01-01T00:00:00.000Z";
|
|
12
|
+
var PLACEHOLDER_DATE = "2000-01-01";
|
|
13
|
+
var PLACEHOLDER_TIME = "00:00:00";
|
|
10
14
|
function readDef(leaf) {
|
|
11
15
|
return leaf._zod.def;
|
|
12
16
|
}
|
|
@@ -57,11 +61,23 @@ function synthesizePlainString(lengths) {
|
|
|
57
61
|
const bounded = target > lengths.max ? lengths.max : target;
|
|
58
62
|
return FILLER_CHARACTER.repeat(bounded);
|
|
59
63
|
}
|
|
64
|
+
function formatOf(def) {
|
|
65
|
+
const declared = checksOf(def).map((wrapper) => wrapper._zod.def).find((check) => check.check === "string_format");
|
|
66
|
+
return def.format ?? declared?.format;
|
|
67
|
+
}
|
|
68
|
+
var FIXED_FORMATS = /* @__PURE__ */ new Map([
|
|
69
|
+
["uuid", PLACEHOLDER_UUID],
|
|
70
|
+
["datetime", PLACEHOLDER_DATETIME],
|
|
71
|
+
["date", PLACEHOLDER_DATE],
|
|
72
|
+
["time", PLACEHOLDER_TIME]
|
|
73
|
+
]);
|
|
60
74
|
function synthesizeString(def) {
|
|
61
75
|
const lengths = readStringLengths(def);
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
return
|
|
76
|
+
const format = formatOf(def);
|
|
77
|
+
if (format === "url") return synthesizeUrl(lengths);
|
|
78
|
+
if (format === "email") return synthesizeEmail(lengths);
|
|
79
|
+
const fixed = format === void 0 ? void 0 : FIXED_FORMATS.get(format);
|
|
80
|
+
return fixed ?? synthesizePlainString(lengths);
|
|
65
81
|
}
|
|
66
82
|
function selectNumber(bounds) {
|
|
67
83
|
const { lower, upper, integer } = bounds;
|
|
@@ -97,20 +113,35 @@ function synthesizeEnum(def) {
|
|
|
97
113
|
const [first] = Object.values(def.entries);
|
|
98
114
|
return String(first);
|
|
99
115
|
}
|
|
116
|
+
function withoutOptional(leaf) {
|
|
117
|
+
const def = readDef(leaf);
|
|
118
|
+
return def.type === "optional" ? withoutOptional(def.innerType) : leaf;
|
|
119
|
+
}
|
|
100
120
|
function synthesizeLeaf(leaf) {
|
|
101
121
|
const def = readDef(leaf);
|
|
102
122
|
switch (def.type) {
|
|
103
123
|
case "default":
|
|
124
|
+
case "prefault":
|
|
125
|
+
case "catch":
|
|
104
126
|
case "optional":
|
|
105
127
|
return void 0;
|
|
106
128
|
case "nullable":
|
|
129
|
+
case "readonly":
|
|
107
130
|
return synthesizeLeaf(def.innerType);
|
|
131
|
+
case "nonoptional":
|
|
132
|
+
return synthesizeLeaf(withoutOptional(def.innerType));
|
|
133
|
+
case "pipe":
|
|
134
|
+
return readDef(def.out).type === "boolean" && readDef(def.in).type === "string" ? PLACEHOLDER_BOOLEAN : synthesizeLeaf(def.in);
|
|
108
135
|
case "string":
|
|
109
136
|
return synthesizeString(def);
|
|
110
137
|
case "number":
|
|
111
138
|
return synthesizeNumber(def);
|
|
112
139
|
case "boolean":
|
|
113
140
|
return PLACEHOLDER_BOOLEAN;
|
|
141
|
+
case "date":
|
|
142
|
+
return PLACEHOLDER_DATETIME;
|
|
143
|
+
case "literal":
|
|
144
|
+
return String(def.values[0]);
|
|
114
145
|
case "enum":
|
|
115
146
|
return synthesizeEnum(def);
|
|
116
147
|
default:
|
|
@@ -143,25 +174,43 @@ function flattenOverrides(overrides) {
|
|
|
143
174
|
}
|
|
144
175
|
return flat;
|
|
145
176
|
}
|
|
146
|
-
function applyOverrides(schema, base, overrides) {
|
|
177
|
+
function applyOverrides(schema, base, overrides, overridden) {
|
|
147
178
|
const flat = flattenOverrides(overrides ?? {});
|
|
148
179
|
const entries = new Map(Object.entries(base));
|
|
149
180
|
for (const binding of resolveSourceNames(schema)) {
|
|
150
181
|
const value = flat.get(binding.path);
|
|
151
|
-
if (value
|
|
182
|
+
if (value === void 0) continue;
|
|
183
|
+
entries.set(binding.variable, String(value));
|
|
184
|
+
overridden.add(binding.variable);
|
|
152
185
|
}
|
|
153
186
|
const source = /* @__PURE__ */ Object.create(null);
|
|
154
187
|
for (const [variable, value] of entries) source[variable] = value;
|
|
155
188
|
return source;
|
|
156
189
|
}
|
|
157
|
-
function
|
|
158
|
-
return
|
|
190
|
+
function explainUnsatisfiedLeaves(error, overridden) {
|
|
191
|
+
return new BymaxConfigValidationError(
|
|
192
|
+
error.issues.map(
|
|
193
|
+
(issue) => !issue.path.includes(".") || overridden.has(issue.variable) ? issue : {
|
|
194
|
+
...issue,
|
|
195
|
+
message: `no placeholder satisfies this leaf (${issue.message}); pass an override for ${issue.path}`
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
function prepareTestConfig(schema, overrides) {
|
|
201
|
+
const overridden = /* @__PURE__ */ new Set();
|
|
202
|
+
const source = applyOverrides(schema, synthesizePlaceholderSource(schema), overrides, overridden);
|
|
203
|
+
try {
|
|
204
|
+
return { source, config: createValidatedConfig({ schema, source }) };
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw error instanceof BymaxConfigValidationError ? explainUnsatisfiedLeaves(error, overridden) : error;
|
|
207
|
+
}
|
|
159
208
|
}
|
|
160
209
|
function createTestConfig(schema, overrides) {
|
|
161
|
-
return
|
|
210
|
+
return prepareTestConfig(schema, overrides).config;
|
|
162
211
|
}
|
|
163
212
|
function configTestingModule(schema, overrides) {
|
|
164
|
-
const source =
|
|
213
|
+
const { source } = prepareTestConfig(schema, overrides);
|
|
165
214
|
return BymaxConfigModule.forRoot({ schema, source });
|
|
166
215
|
}
|
|
167
216
|
|
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.2",
|
|
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",
|