@ontrails/config 1.0.0-beta.30 → 1.0.0-beta.32

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
@@ -1,5 +1,25 @@
1
1
  # @ontrails/config
2
2
 
3
+ ## 1.0.0-beta.32
4
+
5
+ ### Patch Changes
6
+
7
+ - 2b819f4: Rename config check reports to `ConfigReport` and `ConfigFieldReport`, and return field reports from `config.check`.
8
+ - Updated dependencies [3e5c0fc]
9
+ - Updated dependencies [f3c4fef]
10
+ - Updated dependencies [cb0a9d8]
11
+ - Updated dependencies [21c6dda]
12
+ - Updated dependencies [fe72b84]
13
+ - @ontrails/core@1.0.0-beta.32
14
+
15
+ ## 1.0.0-beta.31
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [4cd5d4e]
20
+ - Updated dependencies [38907cc]
21
+ - @ontrails/core@1.0.0-beta.31
22
+
3
23
  ## 1.0.0-beta.30
4
24
 
5
25
  ### Patch Changes
package/README.md CHANGED
@@ -166,7 +166,7 @@ export const myTrail = trail('my.trail', {
166
166
 
167
167
  ### `config.check`
168
168
 
169
- Validate config values against the schema. Returns diagnostics with field-level status (valid, missing, invalid, deprecated, default).
169
+ Validate config values against the schema. Returns a config report with field-level status (valid, missing, invalid, deprecated, default) and the checked field values when available.
170
170
 
171
171
  ### `config.describe`
172
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/config",
3
- "version": "1.0.0-beta.30",
3
+ "version": "1.0.0-beta.32",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
@@ -22,7 +22,7 @@
22
22
  "clean": "rm -rf dist *.tsbuildinfo"
23
23
  },
24
24
  "peerDependencies": {
25
- "@ontrails/core": "^1.0.0-beta.30",
25
+ "@ontrails/core": "^1.0.0-beta.32",
26
26
  "zod": "^4.3.5"
27
27
  }
28
28
  }
package/src/app-config.ts CHANGED
@@ -7,7 +7,7 @@ import { dirname, join } from 'node:path';
7
7
  import { NotFoundError, Result, ValidationError } from '@ontrails/core';
8
8
  import type { z } from 'zod';
9
9
 
10
- import type { CheckResult } from './doctor.js';
10
+ import type { ConfigReport } from './doctor.js';
11
11
  import { checkConfig } from './doctor.js';
12
12
  import type { FieldDescription } from './derive-fields.js';
13
13
  import { deriveConfigFields } from './derive-fields.js';
@@ -58,11 +58,11 @@ export interface AppConfig<T extends z.ZodType> {
58
58
  /** Describe all fields in the schema without needing values. */
59
59
  describe(): readonly FieldDescription[];
60
60
 
61
- /** Check a config object against the schema and return diagnostics. */
61
+ /** Check a config object against the schema and return field reports. */
62
62
  check(
63
63
  values: Record<string, unknown>,
64
64
  options?: { readonly env?: Record<string, string | undefined> }
65
- ): CheckResult;
65
+ ): ConfigReport;
66
66
 
67
67
  /** Show which source won for each config field. */
68
68
  explain(
package/src/collect.ts CHANGED
@@ -79,6 +79,10 @@ const walkObjectShape = (
79
79
 
80
80
  for (const [key, fieldSchema] of Object.entries(shape)) {
81
81
  const path = prefix ? `${prefix}.${key}` : key;
82
+ const meta = extractConfigMeta(fieldSchema);
83
+ if (meta) {
84
+ result.set(path, meta);
85
+ }
82
86
 
83
87
  if (isZodObject(fieldSchema)) {
84
88
  queue.push({
@@ -87,11 +91,6 @@ const walkObjectShape = (
87
91
  Record<string, z.ZodType>
88
92
  >,
89
93
  });
90
- } else {
91
- const meta = extractConfigMeta(fieldSchema);
92
- if (meta) {
93
- result.set(path, meta);
94
- }
95
94
  }
96
95
  }
97
96
  };
package/src/derive/env.ts CHANGED
@@ -10,6 +10,7 @@ import { collectConfigMeta } from '../collect.js';
10
10
  import type { ConfigFieldMeta } from '../extensions.js';
11
11
 
12
12
  import { isLikelySecret } from '../secret-heuristics.js';
13
+ import { isZodContainer } from '../zod-utils.js';
13
14
 
14
15
  import {
15
16
  formatValue,
@@ -80,6 +81,9 @@ const collectEnvEntries = (
80
81
  if (!fieldSchema) {
81
82
  continue;
82
83
  }
84
+ if (isZodContainer(fieldSchema)) {
85
+ continue;
86
+ }
83
87
  entries.push(...envEntry(fieldMeta.env, fieldSchema, fieldMeta));
84
88
  entries.push('');
85
89
  }
@@ -8,7 +8,14 @@ import type { z } from 'zod';
8
8
 
9
9
  import { collectConfigMeta } from './collect.js';
10
10
  import { isLikelySecret } from './secret-heuristics.js';
11
- import { getAtPath, isZodObject, unwrapToBase, zodDef } from './zod-utils.js';
11
+ import {
12
+ getAtPath,
13
+ getSchemaAtPath,
14
+ isZodContainer,
15
+ isZodObject,
16
+ unwrapToBase,
17
+ zodDef,
18
+ } from './zod-utils.js';
12
19
 
13
20
  // ---------------------------------------------------------------------------
14
21
  // Types
@@ -64,21 +71,73 @@ const buildSecretSet = (
64
71
  return result;
65
72
  };
66
73
 
74
+ /** Build a set of env-backed container paths that env overlay skips. */
75
+ const buildSkippedEnvContainerSet = (
76
+ schema: z.ZodObject<Record<string, z.ZodType>>,
77
+ envMap: Map<string, string>
78
+ ): Set<string> => {
79
+ const result = new Set<string>();
80
+ for (const path of envMap.keys()) {
81
+ const fieldSchema = getSchemaAtPath(schema, path);
82
+ if (fieldSchema && isZodContainer(fieldSchema)) {
83
+ result.add(path);
84
+ }
85
+ }
86
+ return result;
87
+ };
88
+
67
89
  /** Source entries in reverse precedence order for winner detection. */
68
90
  type SourceEntry = readonly [
69
91
  name: ProvenanceEntry['source'],
70
92
  values: Record<string, unknown> | undefined,
71
93
  ];
72
94
 
95
+ /** Compare JSON-shaped config values for provenance winner detection. */
96
+ const areConfigValuesEqual = (left: unknown, right: unknown): boolean => {
97
+ if (Object.is(left, right)) {
98
+ return true;
99
+ }
100
+ if (Array.isArray(left) || Array.isArray(right)) {
101
+ if (!(Array.isArray(left) && Array.isArray(right))) {
102
+ return false;
103
+ }
104
+ return (
105
+ left.length === right.length &&
106
+ left.every((value, index) => areConfigValuesEqual(value, right[index]))
107
+ );
108
+ }
109
+ if (
110
+ typeof left !== 'object' ||
111
+ left === null ||
112
+ typeof right !== 'object' ||
113
+ right === null
114
+ ) {
115
+ return false;
116
+ }
117
+ const leftRecord = left as Record<string, unknown>;
118
+ const rightRecord = right as Record<string, unknown>;
119
+ const leftKeys = Object.keys(leftRecord);
120
+ const rightKeys = Object.keys(rightRecord);
121
+ return (
122
+ leftKeys.length === rightKeys.length &&
123
+ leftKeys.every(
124
+ (key) =>
125
+ Object.hasOwn(rightRecord, key) &&
126
+ areConfigValuesEqual(leftRecord[key], rightRecord[key])
127
+ )
128
+ );
129
+ };
130
+
73
131
  /** Determine which source provided the winning value for a given path. */
74
132
  const determineSource = (
75
133
  path: string,
76
134
  resolved: Record<string, unknown>,
77
135
  sources: readonly SourceEntry[],
78
136
  envMap: Map<string, string>,
137
+ skippedEnvContainers: Set<string>,
79
138
  envVars: Record<string, string | undefined> | undefined
80
139
  ): ProvenanceEntry['source'] => {
81
- if (envVars && envMap.has(path)) {
140
+ if (envVars && envMap.has(path) && !skippedEnvContainers.has(path)) {
82
141
  const envVar = envMap.get(path);
83
142
  if (envVar && envVars[envVar] !== undefined) {
84
143
  return 'env';
@@ -87,7 +146,10 @@ const determineSource = (
87
146
 
88
147
  const resolvedValue = getAtPath(resolved, path);
89
148
  for (const [name, values] of sources) {
90
- if (values && getAtPath(values, path) === resolvedValue) {
149
+ if (
150
+ values &&
151
+ areConfigValuesEqual(getAtPath(values, path), resolvedValue)
152
+ ) {
91
153
  return name;
92
154
  }
93
155
  }
@@ -143,6 +205,7 @@ export const deriveConfigProvenance = <T extends z.ZodType>(
143
205
  >;
144
206
  const envMap = buildEnvMap(objSchema);
145
207
  const secretSet = buildSecretSet(objSchema);
208
+ const skippedEnvContainers = buildSkippedEnvContainerSet(objSchema, envMap);
146
209
 
147
210
  const sources: readonly SourceEntry[] = [
148
211
  ['local', options.local],
@@ -158,6 +221,7 @@ export const deriveConfigProvenance = <T extends z.ZodType>(
158
221
  options.resolved,
159
222
  sources,
160
223
  envMap,
224
+ skippedEnvContainers,
161
225
  options.env
162
226
  );
163
227
  const envVarName = envMap.get(path);
package/src/doctor.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Config doctor — structured diagnostics for a config object against a schema.
2
+ * Config doctor — structured field reports for a config object against a schema.
3
3
  *
4
4
  * Reports which fields are valid, missing, using defaults, deprecated, or invalid.
5
5
  */
@@ -7,14 +7,22 @@
7
7
  import type { z } from 'zod';
8
8
 
9
9
  import { collectConfigMeta } from './collect.js';
10
- import { getAtPath, isZodObject, unwrapToBase, zodDef } from './zod-utils.js';
10
+ import {
11
+ coerceEnvValue,
12
+ getAtPath,
13
+ getSchemaAtPath,
14
+ isZodContainer,
15
+ isZodObject,
16
+ unwrapToBase,
17
+ zodDef,
18
+ } from './zod-utils.js';
11
19
 
12
20
  // ---------------------------------------------------------------------------
13
21
  // Types
14
22
  // ---------------------------------------------------------------------------
15
23
 
16
- /** Diagnostic status for a single config field. */
17
- export interface ConfigDiagnostic {
24
+ /** Validation status for a single config field. */
25
+ export interface ConfigFieldReport {
18
26
  readonly path: string;
19
27
  readonly status: 'valid' | 'missing' | 'invalid' | 'deprecated' | 'default';
20
28
  readonly message: string;
@@ -22,8 +30,8 @@ export interface ConfigDiagnostic {
22
30
  }
23
31
 
24
32
  /** Aggregated result from checking config against a schema. */
25
- export interface CheckResult {
26
- readonly diagnostics: readonly ConfigDiagnostic[];
33
+ export interface ConfigReport {
34
+ readonly fields: readonly ConfigFieldReport[];
27
35
  readonly valid: boolean;
28
36
  }
29
37
 
@@ -74,9 +82,20 @@ const applyEnvToValues = (
74
82
  const meta = collectConfigMeta(schema);
75
83
  const result = structuredClone(values) as Record<string, unknown>;
76
84
  for (const [path, fieldMeta] of meta) {
77
- if (fieldMeta.env && envVars[fieldMeta.env] !== undefined) {
78
- setAtPath(result, path, envVars[fieldMeta.env]);
85
+ const fieldSchema = getSchemaAtPath(schema, path);
86
+ const envName = fieldMeta.env;
87
+ const envValue = envName ? envVars[envName] : undefined;
88
+ if (!envName || envValue === undefined) {
89
+ continue;
79
90
  }
91
+ if (fieldSchema && isZodContainer(fieldSchema)) {
92
+ continue;
93
+ }
94
+ setAtPath(
95
+ result,
96
+ path,
97
+ fieldSchema ? coerceEnvValue(envValue, fieldSchema) : envValue
98
+ );
80
99
  }
81
100
  return result;
82
101
  };
@@ -96,7 +115,7 @@ const validateFieldValue = (
96
115
  path: string,
97
116
  fieldSchema: z.ZodType,
98
117
  value: unknown
99
- ): ConfigDiagnostic => {
118
+ ): ConfigFieldReport => {
100
119
  const result = fieldSchema.safeParse(value);
101
120
  if (result.success) {
102
121
  return { message: 'OK', path, status: 'valid', value };
@@ -112,7 +131,7 @@ const classifyField = (
112
131
  fieldSchema: z.ZodType,
113
132
  values: Record<string, unknown>,
114
133
  deprecatedMeta: Map<string, string>
115
- ): ConfigDiagnostic => {
134
+ ): ConfigFieldReport => {
116
135
  const value = getAtPath(values, path);
117
136
  const deprecationMsg = deprecatedMeta.get(path);
118
137
 
@@ -190,7 +209,7 @@ const collectLeaves = (schema: z.ZodType): WalkEntry[] => {
190
209
  // ---------------------------------------------------------------------------
191
210
 
192
211
  /**
193
- * Check a config object against a schema and return structured diagnostics.
212
+ * Check a config object against a schema and return structured field reports.
194
213
  *
195
214
  * Reports which fields are valid, missing, using defaults, deprecated, or invalid.
196
215
  */
@@ -198,7 +217,7 @@ export const checkConfig = <T extends z.ZodType>(
198
217
  schema: T,
199
218
  values: Record<string, unknown>,
200
219
  options?: { readonly env?: Record<string, string | undefined> }
201
- ): CheckResult => {
220
+ ): ConfigReport => {
202
221
  const objSchema = schema as unknown as z.ZodObject<Record<string, z.ZodType>>;
203
222
  const effectiveValues = options?.env
204
223
  ? applyEnvToValues(values, objSchema, options.env)
@@ -207,13 +226,13 @@ export const checkConfig = <T extends z.ZodType>(
207
226
  const deprecatedMeta = collectDeprecatedPaths(objSchema);
208
227
  const leaves = collectLeaves(objSchema);
209
228
 
210
- const diagnostics = leaves.map((leaf) =>
229
+ const fields = leaves.map((leaf) =>
211
230
  classifyField(leaf.path, leaf.schema, effectiveValues, deprecatedMeta)
212
231
  );
213
232
 
214
- const valid = diagnostics.every(
233
+ const valid = fields.every(
215
234
  (d) => d.status !== 'missing' && d.status !== 'invalid'
216
235
  );
217
236
 
218
- return { diagnostics, valid };
237
+ return { fields, valid };
219
238
  };
package/src/index.ts CHANGED
@@ -36,8 +36,8 @@ export {
36
36
  export { deriveConfigFields, type FieldDescription } from './derive-fields.js';
37
37
  export {
38
38
  checkConfig,
39
- type CheckResult,
40
- type ConfigDiagnostic,
39
+ type ConfigReport,
40
+ type ConfigFieldReport,
41
41
  } from './doctor.js';
42
42
  export { env, secret, deprecated, type ConfigFieldMeta } from './extensions.js';
43
43
  export {
package/src/resolve.ts CHANGED
@@ -9,7 +9,12 @@ import { Result, ValidationError } from '@ontrails/core';
9
9
 
10
10
  import { collectConfigMeta } from './collect.js';
11
11
  import { deepMerge } from './merge.js';
12
- import { zodDef } from './zod-utils.js';
12
+ import {
13
+ coerceEnvValue,
14
+ getSchemaAtPath,
15
+ isZodContainer,
16
+ zodDef,
17
+ } from './zod-utils.js';
13
18
 
14
19
  // ---------------------------------------------------------------------------
15
20
  // Types
@@ -25,72 +30,6 @@ export interface DeriveConfigOptions<T extends z.ZodType> {
25
30
  readonly env?: Record<string, string | undefined> | undefined;
26
31
  }
27
32
 
28
- // ---------------------------------------------------------------------------
29
- // Env coercion helpers (defined before consumers)
30
- // ---------------------------------------------------------------------------
31
-
32
- /** Boolean string values we accept from environment variables. */
33
- const BOOL_TRUE = new Set(['true', '1']);
34
- const BOOL_FALSE = new Set(['false', '0']);
35
-
36
- /** Primitive type names we can coerce env strings into. */
37
- const PRIMITIVE_TYPES = new Set(['number', 'boolean', 'string']);
38
-
39
- /** Try to advance one level through a Zod wrapper, returning the inner type or undefined. */
40
- const unwrapOne = (
41
- schema: z.ZodType
42
- ): { typeName: string | undefined; inner: z.ZodType | undefined } => {
43
- const def = zodDef(schema);
44
- return {
45
- inner: def['innerType'] as z.ZodType | undefined,
46
- typeName: def['type'] as string | undefined,
47
- };
48
- };
49
-
50
- /** Unwrap ZodDefault / ZodOptional / ZodNullable to find the base type name. */
51
- const resolveBaseTypeName = (schema: z.ZodType): string => {
52
- let current: z.ZodType = schema;
53
-
54
- for (let depth = 0; depth < 10; depth += 1) {
55
- const { typeName, inner } = unwrapOne(current);
56
- if (typeName && PRIMITIVE_TYPES.has(typeName)) {
57
- return typeName;
58
- }
59
- if (!inner) {
60
- break;
61
- }
62
- current = inner;
63
- }
64
-
65
- return 'string';
66
- };
67
-
68
- /** Coerce a boolean env string. Returns the original string if unrecognized. */
69
- const coerceBooleanEnv = (raw: string): unknown => {
70
- if (BOOL_TRUE.has(raw)) {
71
- return true;
72
- }
73
- if (BOOL_FALSE.has(raw)) {
74
- return false;
75
- }
76
- return raw;
77
- };
78
-
79
- /** Coerce env var lookup table keyed by base type name. */
80
- const ENV_COERCERS: Record<string, (raw: string) => unknown> = {
81
- boolean: coerceBooleanEnv,
82
- number: (raw: string) => {
83
- const n = Number(raw);
84
- return Number.isNaN(n) ? raw : n;
85
- },
86
- };
87
-
88
- /** Coerce a string env value to the type expected by the schema field. */
89
- const coerceEnvValue = (raw: string, schema: z.ZodType): unknown => {
90
- const coercer = ENV_COERCERS[resolveBaseTypeName(schema)];
91
- return coercer ? coercer(raw) : raw;
92
- };
93
-
94
33
  // ---------------------------------------------------------------------------
95
34
  // Path utilities
96
35
  // ---------------------------------------------------------------------------
@@ -132,33 +71,6 @@ const setAtPath = (
132
71
  parent[parts.at(-1) as string] = value;
133
72
  };
134
73
 
135
- /** Resolve one step of a Zod shape walk: find the field schema for a key. */
136
- const resolveShapeStep = (
137
- current: z.ZodType,
138
- key: string
139
- ): z.ZodType | undefined => {
140
- const shape = zodDef(current)['shape'] as
141
- | Record<string, z.ZodType>
142
- | undefined;
143
- return shape?.[key];
144
- };
145
-
146
- /** Walk a Zod schema shape to find the field at a dot-separated path. */
147
- const getFieldSchema = (
148
- schema: z.ZodType,
149
- path: string
150
- ): z.ZodType | undefined => {
151
- let current: z.ZodType = schema;
152
- for (const part of path.split('.')) {
153
- const next = resolveShapeStep(current, part);
154
- if (!next) {
155
- return undefined;
156
- }
157
- current = next;
158
- }
159
- return current;
160
- };
161
-
162
74
  // ---------------------------------------------------------------------------
163
75
  // Env overlay
164
76
  // ---------------------------------------------------------------------------
@@ -170,7 +82,10 @@ const applyOneEnvOverride = (
170
82
  path: string,
171
83
  envValue: string
172
84
  ): void => {
173
- const fieldSchema = getFieldSchema(schema, path);
85
+ const fieldSchema = getSchemaAtPath(schema, path);
86
+ if (fieldSchema && isZodContainer(fieldSchema)) {
87
+ return;
88
+ }
174
89
  const coerced = fieldSchema
175
90
  ? coerceEnvValue(envValue, fieldSchema)
176
91
  : envValue;
@@ -1,27 +1,36 @@
1
1
  /**
2
2
  * Infrastructure trail that validates config values against a schema.
3
3
  *
4
- * Returns structured diagnostics indicating which fields are valid,
4
+ * Returns structured field reports indicating which fields are valid,
5
5
  * missing, invalid, deprecated, or using defaults.
6
6
  */
7
7
  import { Result, trail } from '@ontrails/core';
8
8
  import { z } from 'zod';
9
9
 
10
10
  import { configResource } from '../config-resource.js';
11
+ import { collectConfigMeta } from '../collect.js';
11
12
  import { checkConfig } from '../doctor.js';
12
13
  import { deepMerge } from '../merge.js';
14
+ import { isLikelySecret } from '../secret-heuristics.js';
13
15
 
14
- const diagnosticSchema = z.object({
16
+ const fieldReportSchema = z.object({
15
17
  message: z.string(),
16
18
  path: z.string(),
19
+ redacted: z.boolean().optional(),
17
20
  status: z.enum(['valid', 'missing', 'invalid', 'deprecated', 'default']),
21
+ value: z.unknown().optional(),
18
22
  });
19
23
 
20
24
  const outputSchema = z.object({
21
- diagnostics: z.array(diagnosticSchema),
25
+ fields: z.array(fieldReportSchema),
22
26
  valid: z.boolean(),
23
27
  });
24
28
 
29
+ type ConfigCheckFieldReport = ReturnType<typeof checkConfig>['fields'][number] &
30
+ Readonly<{
31
+ redacted?: boolean;
32
+ }>;
33
+
25
34
  /** Merge input values on top of resolved config values. */
26
35
  const mergeValues = (
27
36
  resolved: Record<string, unknown>,
@@ -31,13 +40,39 @@ const mergeValues = (
31
40
  return hasOverrides ? deepMerge(resolved, overrides) : resolved;
32
41
  };
33
42
 
43
+ const redactSecretFields = (
44
+ schema: z.ZodObject<Record<string, z.ZodType>>,
45
+ fields: ReturnType<typeof checkConfig>['fields']
46
+ ): ConfigCheckFieldReport[] => {
47
+ const meta = collectConfigMeta(schema);
48
+ const redactedPaths = new Set(
49
+ [...meta.entries()]
50
+ .filter(
51
+ ([, fieldMeta]) =>
52
+ fieldMeta.secret === true ||
53
+ (fieldMeta.env !== undefined && isLikelySecret(fieldMeta.env))
54
+ )
55
+ .map(([path]) => path)
56
+ );
57
+ return fields.map((field) => {
58
+ const shouldRedact = [...redactedPaths].some(
59
+ (path) => field.path === path || field.path.startsWith(`${path}.`)
60
+ );
61
+ if (!shouldRedact || !('value' in field) || field.value === undefined) {
62
+ return field;
63
+ }
64
+
65
+ return { ...field, redacted: true, value: '[REDACTED]' };
66
+ });
67
+ };
68
+
34
69
  export const configCheck = trail('config.check', {
35
70
  blaze: (input, ctx) => {
36
71
  const state = configResource.from(ctx);
37
72
  const effective = mergeValues(state.resolved, input.values);
38
73
  const checked = checkConfig(state.schema, effective);
39
74
  return Result.ok({
40
- diagnostics: [...checked.diagnostics],
75
+ fields: redactSecretFields(state.schema, checked.fields),
41
76
  valid: checked.valid,
42
77
  });
43
78
  },
package/src/zod-utils.ts CHANGED
@@ -9,15 +9,27 @@ import type { z } from 'zod';
9
9
  export const zodDef = (schema: z.ZodType): Record<string, unknown> =>
10
10
  schema.def as unknown as Record<string, unknown>;
11
11
 
12
- /** Wrapper types that should be peeled before checking the base type. */
13
- const WRAPPER_TYPES = new Set(['optional', 'default', 'nullable']);
12
+ /** Wrapper types that preserve object traversal shape. */
13
+ const TRAVERSAL_WRAPPER_TYPES = new Set(['optional', 'default', 'nullable']);
14
14
 
15
- /** Unwrap through optional/default/nullable wrappers to find the base schema. */
16
- export const unwrapToBase = (schema: z.ZodType): z.ZodType => {
15
+ /** Wrapper types env overlay can inspect before coercing a string value. */
16
+ const ENV_WRAPPER_TYPES = new Set([
17
+ ...TRAVERSAL_WRAPPER_TYPES,
18
+ 'catch',
19
+ 'nonoptional',
20
+ 'prefault',
21
+ 'readonly',
22
+ ]);
23
+
24
+ /** Unwrap through selected wrappers to find the base schema. */
25
+ const unwrapWith = (
26
+ schema: z.ZodType,
27
+ wrapperTypes: ReadonlySet<string>
28
+ ): z.ZodType => {
17
29
  let current = schema;
18
30
  for (let depth = 0; depth < 10; depth += 1) {
19
31
  const def = zodDef(current);
20
- if (!WRAPPER_TYPES.has(def['type'] as string)) {
32
+ if (!wrapperTypes.has(def['type'] as string)) {
21
33
  return current;
22
34
  }
23
35
  const inner = def['innerType'] as z.ZodType | undefined;
@@ -29,6 +41,14 @@ export const unwrapToBase = (schema: z.ZodType): z.ZodType => {
29
41
  return current;
30
42
  };
31
43
 
44
+ /** Unwrap through shape-preserving wrappers to find the base schema. */
45
+ export const unwrapToBase = (schema: z.ZodType): z.ZodType =>
46
+ unwrapWith(schema, TRAVERSAL_WRAPPER_TYPES);
47
+
48
+ /** Unwrap through env-coercion wrappers to find the env target schema. */
49
+ const unwrapToEnvBase = (schema: z.ZodType): z.ZodType =>
50
+ unwrapWith(schema, ENV_WRAPPER_TYPES);
51
+
32
52
  /** Check if a schema is (or wraps) a ZodObject by inspecting its def. */
33
53
  export const isZodObject = (
34
54
  schema: z.ZodType
@@ -37,6 +57,85 @@ export const isZodObject = (
37
57
  return def['type'] === 'object' && 'shape' in def;
38
58
  };
39
59
 
60
+ /** Container types an env string should not replace wholesale. */
61
+ const CONTAINER_TYPES = new Set([
62
+ 'object',
63
+ 'array',
64
+ 'tuple',
65
+ 'record',
66
+ 'map',
67
+ 'set',
68
+ ]);
69
+
70
+ /** Check whether a schema is a container shape after unwrapping defaults. */
71
+ export const isZodContainer = (schema: z.ZodType): boolean => {
72
+ const def = zodDef(unwrapToEnvBase(schema));
73
+ return CONTAINER_TYPES.has(def['type'] as string);
74
+ };
75
+
76
+ /** Boolean string values we accept from environment variables. */
77
+ const BOOL_TRUE = new Set(['true', '1']);
78
+ const BOOL_FALSE = new Set(['false', '0']);
79
+
80
+ /** Primitive type names we can coerce env strings into. */
81
+ const PRIMITIVE_TYPES = new Set(['number', 'boolean', 'string']);
82
+
83
+ /** Resolve the primitive base type name after unwrapping defaults. */
84
+ const resolvePrimitiveBaseTypeName = (
85
+ schema: z.ZodType
86
+ ): string | undefined => {
87
+ const def = zodDef(unwrapToEnvBase(schema));
88
+ const typeName = def['type'] as string | undefined;
89
+ return typeName && PRIMITIVE_TYPES.has(typeName) ? typeName : undefined;
90
+ };
91
+
92
+ /** Coerce a boolean env string. Returns the original string if unrecognized. */
93
+ const coerceBooleanEnv = (raw: string): unknown => {
94
+ if (BOOL_TRUE.has(raw)) {
95
+ return true;
96
+ }
97
+ if (BOOL_FALSE.has(raw)) {
98
+ return false;
99
+ }
100
+ return raw;
101
+ };
102
+
103
+ /** Coerce env var lookup table keyed by base type name. */
104
+ const ENV_COERCERS: Record<string, (raw: string) => unknown> = {
105
+ boolean: coerceBooleanEnv,
106
+ number: (raw: string) => {
107
+ const n = Number(raw);
108
+ return Number.isNaN(n) ? raw : n;
109
+ },
110
+ };
111
+
112
+ /** Coerce a string env value to the type expected by the schema field. */
113
+ export const coerceEnvValue = (raw: string, schema: z.ZodType): unknown => {
114
+ const typeName = resolvePrimitiveBaseTypeName(schema);
115
+ const coercer = typeName ? ENV_COERCERS[typeName] : undefined;
116
+ return coercer ? coercer(raw) : raw;
117
+ };
118
+
119
+ /** Resolve a schema at a dot-separated path through nested object shapes. */
120
+ export const getSchemaAtPath = (
121
+ schema: z.ZodType,
122
+ path: string
123
+ ): z.ZodType | undefined => {
124
+ let current = schema;
125
+ for (const part of path.split('.')) {
126
+ const base = unwrapToBase(current);
127
+ const shape = zodDef(base)['shape'] as
128
+ | Record<string, z.ZodType>
129
+ | undefined;
130
+ const next = shape?.[part];
131
+ if (!next) {
132
+ return undefined;
133
+ }
134
+ current = next;
135
+ }
136
+ return current;
137
+ };
138
+
40
139
  /** Read a value at a dot-separated path from a plain object. */
41
140
  export const getAtPath = (
42
141
  obj: Record<string, unknown>,