@pawells/config 2.3.1 → 3.0.0

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/dist/manager.js CHANGED
@@ -1,7 +1,7 @@
1
- import { writeFileSync } from 'node:fs';
2
1
  import { z } from 'zod/v4';
3
- import { ConfigurationAlreadyRegisteredError, ConfigurationNotRegisteredError, ConfigurationError, ConfigurationNotSetError } from './errors.js';
4
- import { IsMarkedSecret } from './secret.js';
2
+ import { GetErrorMessage } from '@pawells/typescript-common';
3
+ import { ConfigRegistrationError, ConfigNotRegisteredError, ConfigNotSetError, ConfigValidationError } from './errors.js';
4
+ import { IsMarkedSecret, traverseSchemaToBase } from './secret.js';
5
5
  /**
6
6
  * Zod schema for all supported configuration value types.
7
7
  * Accepts string, number, boolean, Date, string[], number[], boolean[], and undefined — nullable and optional.
@@ -37,8 +37,7 @@ export function AssertConfigValueType(value) {
37
37
  * @internal
38
38
  */
39
39
  function GetFieldDescription(schema) {
40
- let current = schema;
41
- while (current != null) {
40
+ for (const current of traverseSchemaToBase(schema)) {
42
41
  try {
43
42
  const meta = z.globalRegistry.get(current);
44
43
  if (typeof meta?.description === 'string') {
@@ -48,226 +47,757 @@ function GetFieldDescription(schema) {
48
47
  catch {
49
48
  break;
50
49
  }
51
- current = current._def?.innerType;
52
50
  }
53
51
  return undefined;
54
52
  }
55
53
  /**
56
- * Serializes a configuration value to its .env string representation.
57
- * Arrays are JSON-stringified; Dates use ISO 8601; all others use String().
58
- * Returns '' for null or undefined.
54
+ * Non-exported internal state class holding all configuration data and logic.
59
55
  *
60
- * @param value - The configuration value to serialize
61
- * @returns - Serialized string representation
62
- * @internal
63
- */
64
- function SerializeConfigValue(value) {
65
- if (value === null || value === undefined)
66
- return '';
67
- if (Array.isArray(value))
68
- return JSON.stringify(value);
69
- if (value instanceof Date)
70
- return value.toISOString();
71
- return String(value);
72
- }
73
- /**
74
- * Runtime configuration manager with Zod schema validation.
75
- * Provides a singleton instance to register and retrieve typed configuration values.
56
+ * All instance methods mirror the public ConfigManager and ScopedConfigManager APIs.
57
+ * This class exists to avoid code duplication between the static and instance facades.
76
58
  *
77
- * @example
78
- * ConfigManager.Register('DATABASE_URL', z.string().url(), 'postgresql://localhost/mydb');
79
- * ConfigManager.Set('DEFAULT', 'DATABASE_URL', 'postgresql://localhost/mydb');
80
- * const url = ConfigManager.Get('DATABASE_URL');
59
+ * @internal
81
60
  */
82
- export class ConfigManager {
83
- static _Schemas = new Map();
84
- // Populated at Registration
85
- static _DataDefaults = new Map();
86
- // Overriden at Runtime from various sources
87
- static _DataOverrides = new Map();
88
- // Resolved Data
89
- static get _Data() {
61
+ class CoreConfigState {
62
+ _Schemas = new Map();
63
+ _DataDefaults = new Map();
64
+ _DataOverrides = new Map();
65
+ /**
66
+ * Raw (unvalidated) values collected from all registered providers.
67
+ * Stored so that schemas registered after providers can still receive provider values.
68
+ * Later-registered providers overwrite earlier ones for the same key.
69
+ */
70
+ _providerRawData = new Map();
71
+ /**
72
+ * Validated provider values, ready for merging into the resolved config.
73
+ * Populated from _providerRawData whenever a schema is registered or a provider is added.
74
+ */
75
+ _providerValues = new Map();
76
+ /**
77
+ * Maps env-var prefix strings to their section names for save-entry construction.
78
+ * Example: 'KEYCLOAK_' → 'KEYCLOAK'
79
+ */
80
+ _namespaces = new Map();
81
+ /**
82
+ * Cache for the resolved _Data map to avoid rebuilding on every access.
83
+ * Invalidated whenever data is mutated (Set, Register, Reset, RegisterProvider).
84
+ */
85
+ _dataCache = null;
86
+ /**
87
+ * Cache for parsed configuration values, keyed by configuration key.
88
+ * Avoids re-parsing the same value on repeated Get() calls.
89
+ */
90
+ _parsedCache = new Map();
91
+ /**
92
+ * Cache for schema metadata (isSecret and description).
93
+ * Populated at Register() time to avoid traversing the schema chain on every Save() call.
94
+ */
95
+ _schemaMetaCache = new Map();
96
+ /**
97
+ * Optional handler for provider validation warnings.
98
+ * If set, called when a provider value fails schema validation.
99
+ * If not set, validation failures are silent (no-op).
100
+ */
101
+ _validationWarningHandler;
102
+ /**
103
+ * Lookup table: key → { section, field }
104
+ * Built at Register() time to optimize Save() performance.
105
+ */
106
+ _keyLookup = new Map();
107
+ /**
108
+ * Resolved configuration data, computed from defaults, provider values, and overrides.
109
+ */
110
+ get _Data() {
111
+ if (this._dataCache !== null) {
112
+ return this._dataCache;
113
+ }
90
114
  const resolved = new Map(this._DataDefaults);
115
+ for (const [key, value] of this._providerValues) {
116
+ resolved.set(key, value);
117
+ }
91
118
  for (const [key, value] of this._DataOverrides) {
92
119
  resolved.set(key, value);
93
120
  }
121
+ this._dataCache = resolved;
94
122
  return resolved;
95
123
  }
96
124
  /**
97
- * Reset the singleton instance (for testing).
98
- * @internal
125
+ * Reset this state (for testing).
99
126
  */
100
- static Reset() {
127
+ Reset() {
101
128
  this._Schemas.clear();
102
129
  this._DataDefaults.clear();
130
+ this._providerRawData.clear();
131
+ this._providerValues.clear();
103
132
  this._DataOverrides.clear();
133
+ this._namespaces.clear();
134
+ this._dataCache = null;
135
+ this._parsedCache.clear();
136
+ this._schemaMetaCache.clear();
137
+ this._keyLookup.clear();
138
+ this._validationWarningHandler = undefined;
139
+ }
140
+ /**
141
+ * Set an optional handler for provider validation warnings.
142
+ * When a provider value fails schema validation, if a handler is set,
143
+ * it will be called with the key and provider name.
144
+ * If no handler is set, validation failures are silent.
145
+ *
146
+ * @param handler - Function to call on validation failure, or undefined to clear
147
+ */
148
+ SetValidationWarningHandler(handler) {
149
+ this._validationWarningHandler = handler;
150
+ }
151
+ /**
152
+ * Register a configuration namespace for use when building save entries.
153
+ *
154
+ * Records the mapping from `prefix` to `sectionName` so that
155
+ * {@link Save} can split fully-qualified keys into section and field
156
+ * components (e.g. `KEYCLOAK_HOST` → section `KEYCLOAK`, field `HOST`).
157
+ *
158
+ * @param name - Human-readable namespace name (e.g. `'Keycloak'`)
159
+ * @param prefix - Derived environment variable prefix (e.g. `'KEYCLOAK_'`)
160
+ */
161
+ RegisterNamespace(name, prefix) {
162
+ this._namespaces.set(prefix, name.toUpperCase());
163
+ }
164
+ /**
165
+ * Save all registered configuration values via a provider.
166
+ *
167
+ * Builds a {@link ConfigSaveEntry} for every registered schema key, then
168
+ * delegates formatting and file I/O to `provider.Save()`.
169
+ *
170
+ * In template mode (`useCurrentValues: false`, the default), each entry
171
+ * carries the registered default value. In current-values mode
172
+ * (`useCurrentValues: true`), each entry carries the fully resolved live
173
+ * value (DEFAULT → provider values → OVERRIDE). The `isSecret` flag is
174
+ * set for fields marked with {@link Secret}; providers are expected to
175
+ * redact those appropriately in template mode.
176
+ *
177
+ * @param provider - A {@link IConfigProvider} that handles the write
178
+ * @param options - Output path and save mode
179
+ * @returns - A promise that resolves when the save operation completes
180
+ * @remarks In `useCurrentValues` mode, keys that cannot be resolved (not set, not registered, or failing validation) are written as blank/undefined without throwing.
181
+ */
182
+ async Save(provider, options) {
183
+ const useCurrentValues = options.useCurrentValues ?? false;
184
+ const entries = [];
185
+ for (const [key] of this._Schemas) {
186
+ const meta = this._schemaMetaCache.get(key);
187
+ const isSecret = meta?.isSecret ?? false;
188
+ const description = meta?.description;
189
+ const lookup = this._keyLookup.get(key);
190
+ const section = lookup?.section ?? '';
191
+ const field = lookup?.field ?? key;
192
+ let value;
193
+ if (useCurrentValues) {
194
+ try {
195
+ value = this.Get(key);
196
+ }
197
+ catch (e) {
198
+ if (e instanceof ConfigNotSetError
199
+ || e instanceof ConfigNotRegisteredError) {
200
+ value = undefined;
201
+ }
202
+ else {
203
+ throw e;
204
+ }
205
+ }
206
+ }
207
+ else {
208
+ value = this._DataDefaults.get(key);
209
+ }
210
+ entries.push({ key, section, field, value, isSecret, description });
211
+ }
212
+ await provider.Save(entries, options);
213
+ }
214
+ /**
215
+ * Register a configuration value provider.
216
+ *
217
+ * Immediately calls `provider.Load()` to obtain all key/value pairs from the
218
+ * provider. All raw values are stored in the internal raw-data cache so that
219
+ * schemas registered after this call can still receive provider values.
220
+ * For any key that already has a registered schema, the raw value is validated
221
+ * and the validated result is stored in the provider values tier.
222
+ *
223
+ * Provider values occupy the middle precedence tier: they override registered
224
+ * defaults but are themselves overridden by explicit {@link Set} calls.
225
+ * When multiple providers supply the same key, the last-registered provider wins.
226
+ *
227
+ * @param provider - The {@link IConfigProvider} implementation to register
228
+ * @returns - A promise that resolves when registration completes
229
+ */
230
+ async RegisterProvider(provider) {
231
+ const values = await provider.Load();
232
+ for (const [key, rawValue] of Object.entries(values)) {
233
+ // Always cache raw value — needed for schemas registered after this provider
234
+ this._providerRawData.set(key, rawValue);
235
+ // If schema already registered, validate and cache the typed value now
236
+ const schema = this._Schemas.get(key);
237
+ if (schema === undefined)
238
+ continue;
239
+ const result = schema.safeParse(rawValue);
240
+ if (!result.success) {
241
+ if (this._validationWarningHandler) {
242
+ this._validationWarningHandler(key, provider.Name);
243
+ }
244
+ continue;
245
+ }
246
+ // Safe: safeParse succeeds only if result.data matches schema's inferred type,
247
+ // which was validated at Register to be TConfigValueTypes
248
+ this._providerValues.set(key, result.data);
249
+ }
250
+ this._dataCache = null;
251
+ }
252
+ /**
253
+ * Register a synchronous configuration provider.
254
+ * Use this only in contexts that cannot `await`. Most code should prefer
255
+ * `RegisterProvider()` with an async provider.
256
+ *
257
+ * Immediately calls `provider.LoadSync()` to obtain all key/value pairs from the
258
+ * provider. All raw values are stored in the internal raw-data cache so that
259
+ * schemas registered after this call can still receive provider values.
260
+ * For any key that already has a registered schema, the raw value is validated
261
+ * and the validated result is stored in the provider values tier.
262
+ *
263
+ * @param provider - The {@link ISyncConfigProvider} implementation to register
264
+ */
265
+ RegisterSyncProvider(provider) {
266
+ const values = provider.LoadSync();
267
+ for (const [key, rawValue] of Object.entries(values)) {
268
+ // Always cache raw value — needed for schemas registered after this provider
269
+ this._providerRawData.set(key, rawValue);
270
+ // If schema already registered, validate and cache the typed value now
271
+ const schema = this._Schemas.get(key);
272
+ if (schema === undefined)
273
+ continue;
274
+ const result = schema.safeParse(rawValue);
275
+ if (!result.success) {
276
+ if (this._validationWarningHandler) {
277
+ this._validationWarningHandler(key, provider.Name);
278
+ }
279
+ continue;
280
+ }
281
+ this._providerValues.set(key, result.data);
282
+ }
283
+ this._dataCache = null;
104
284
  }
105
- constructor() { }
106
285
  /**
107
286
  * Register a configuration schema.
287
+ *
108
288
  * @param key - Unique configuration key
109
289
  * @param schema - Zod schema for runtime validation
110
290
  * @param defaultValue - Initial value for the configuration key, must satisfy the schema
111
- * @throws {ConfigurationAlreadyRegisteredError} If key is already registered
112
- * @example
113
- * ConfigManager.Register('PORT', z.coerce.number().positive(), 3000);
114
- * ConfigManager.Register('JWT_SECRET', z.string().min(32), 'default-secret');
291
+ * @throws {ConfigRegistrationError} If key is already registered with a different schema
292
+ * @throws {ConfigValidationError} If defaultValue does not match the schema
115
293
  */
116
- static Register(key, schema, defaultValue) {
294
+ Register(key, schema, defaultValue) {
117
295
  // Ensure key is unique, but it's fine when the schemas match.
118
296
  if (this._Schemas.has(key) && this._Schemas.get(key) !== schema)
119
- throw new ConfigurationAlreadyRegisteredError(key);
297
+ throw new ConfigRegistrationError(key);
120
298
  const result = schema.safeParse(defaultValue);
121
- if (!result.success)
122
- throw new ConfigurationError('Registration', `Default value for configuration "${key}" does not match the provided schema.`, { cause: result.error });
299
+ if (!result.success) {
300
+ const isSecret = IsMarkedSecret(schema);
301
+ const message = isSecret ? 'Default value does not match the provided schema (value redacted for security).' : 'Default value does not match the provided schema.';
302
+ const options = isSecret ? undefined : { cause: result.error };
303
+ throw new ConfigValidationError(key, message, options);
304
+ }
123
305
  const parsed = result.data;
306
+ // Deep-clone mutable types to prevent caller mutation
307
+ const clonedDefault = structuredClone(parsed);
124
308
  this._Schemas.set(key, schema);
125
- this.Set(key, parsed, 'DEFAULT');
309
+ // Safe: clonedDefault is the parsed result of schema.safeParse(), which succeeded
310
+ this._DataDefaults.set(key, clonedDefault);
311
+ this._dataCache = null;
312
+ this._parsedCache.delete(key);
313
+ // Cache schema metadata for use in Save()
314
+ this._schemaMetaCache.set(key, {
315
+ isSecret: IsMarkedSecret(schema),
316
+ description: GetFieldDescription(schema)
317
+ });
318
+ // Precompute section/field lookup for Save() optimization
319
+ let section = '';
320
+ let field = key;
321
+ for (const [prefix, sectionName] of this._namespaces) {
322
+ if (key.startsWith(prefix)) {
323
+ section = sectionName;
324
+ field = key.slice(prefix.length);
325
+ break;
326
+ }
327
+ }
328
+ this._keyLookup.set(key, { section, field });
329
+ // Apply any provider value already loaded for this key
330
+ if (this._providerRawData.has(key)) {
331
+ const rawProviderValue = this._providerRawData.get(key);
332
+ const providerResult = schema.safeParse(rawProviderValue);
333
+ if (providerResult.success) {
334
+ // Safe: safeParse succeeds only if providerResult.data matches schema's type
335
+ this._providerValues.set(key, providerResult.data);
336
+ this._dataCache = null;
337
+ }
338
+ }
126
339
  }
127
340
  /**
128
341
  * Set a configuration value and validate against its schema.
342
+ *
129
343
  * @param key - Configuration key
130
344
  * @param value - Value to set and validate
131
345
  * @param target - Whether to set the default store or the override store; defaults to `'OVERRIDE'`
132
- * @throws {ConfigurationNotRegisteredError} If schema is not registered for key
133
- * @throws {ConfigurationError} If validation fails
134
- * @example
135
- * manager.set('PORT', 3000);
136
- * manager.set('JWT_SECRET', process.env.SECRET);
346
+ * @throws {ConfigNotRegisteredError} If schema is not registered for key
347
+ * @throws {ConfigValidationError} If validation fails
348
+ * @remarks
349
+ * When validation fails for a field marked with `Secret()`, the error message and error cause are sanitized to prevent secret values from appearing in error logs or stack traces.
137
350
  */
138
- static Set(key, value, target = 'OVERRIDE') {
351
+ Set(key, value, target = 'OVERRIDE') {
139
352
  const schema = this._Schemas.get(key);
140
353
  if (!schema)
141
- throw new ConfigurationNotRegisteredError(key);
354
+ throw new ConfigNotRegisteredError(key);
142
355
  try {
143
356
  const parsed = schema.parse(value);
144
- AssertConfigValueType(parsed);
357
+ // Safe: Register validates that this schema produces TConfigValueTypes at runtime
145
358
  if (target === 'DEFAULT') {
146
359
  this._DataDefaults.set(key, parsed);
147
360
  }
148
361
  else {
149
362
  this._DataOverrides.set(key, parsed);
150
363
  }
364
+ this._dataCache = null;
365
+ this._parsedCache.delete(key);
151
366
  }
152
367
  catch (cause) {
153
- throw new ConfigurationError(key, cause instanceof Error ? cause.message : String(cause), { cause: cause instanceof Error ? cause : undefined });
368
+ const isSecret = this._schemaMetaCache.get(key)?.isSecret ?? false;
369
+ const message = isSecret ? 'value failed validation (value redacted for security)' : GetErrorMessage(cause);
370
+ const options = isSecret ? undefined : (cause instanceof Error ? cause : undefined);
371
+ throw new ConfigValidationError(key, message, options ? { cause: options } : undefined);
154
372
  }
155
373
  }
156
374
  /**
157
375
  * Retrieve a configuration value by key.
376
+ *
158
377
  * Returns the value parsed by its registered schema.
378
+ *
159
379
  * @param key - Configuration key
160
380
  * @param source - Optional — filter to a specific store (`'DEFAULT'` or `'OVERRIDE'`); omit to return the resolved value (overrides take precedence over defaults)
161
381
  * @returns The typed configuration value
162
- * @throws {ConfigurationNotSetError} If value was not set
163
- * @throws {ConfigurationNotRegisteredError} If schema is not registered
164
- * @throws {ConfigurationError} If validation fails on retrieval
165
- * @example
166
- * const port = manager.get('PORT'); // Returns number, guaranteed by schema
167
- * const secret = manager.get('JWT_SECRET'); // Returns string
382
+ * @throws {ConfigNotSetError} If value was not set
383
+ * @throws {ConfigNotRegisteredError} If schema is not registered
384
+ * @throws {ConfigValidationError} If validation fails on retrieval
168
385
  */
169
- static Get(key, source) {
170
- const value = source === 'DEFAULT' ? this._DataDefaults.get(key) : source === 'OVERRIDE' ? this._DataOverrides.get(key) : this._Data.get(key);
171
- if (value === undefined)
172
- throw new ConfigurationNotSetError(key);
386
+ Get(key, source) {
387
+ let dataSource;
388
+ switch (source) {
389
+ case 'DEFAULT':
390
+ dataSource = this._DataDefaults;
391
+ break;
392
+ case 'OVERRIDE':
393
+ dataSource = this._DataOverrides;
394
+ break;
395
+ default:
396
+ dataSource = this._Data;
397
+ break;
398
+ }
399
+ if (!dataSource.has(key))
400
+ throw new ConfigNotSetError(key);
401
+ // Safe: has() guard above guarantees presence; stored values match schema types
402
+ const value = dataSource.get(key);
403
+ // Only use parsed cache for resolved (non-source-filtered) values
404
+ if (source === undefined && this._parsedCache.has(key)) {
405
+ // has() guard above guarantees presence — use type assertion instead of ! (ESLint: no-non-null-assertion)
406
+ return this._parsedCache.get(key);
407
+ }
173
408
  const schema = this.GetSchema(key);
174
409
  try {
175
- // TODO: This will re-parse the value on every retrieval, which may have performance implications. Consider caching parsed values if this becomes an issue.
176
- // TODO: Also consider how to handle complex types that may not be easily represented as strings in ENV or CLI (e.g. arrays, objects). We may need to support custom parsing logic or conventions for these cases.
177
410
  const rvalue = schema.parse(value);
178
- AssertConfigValueType(rvalue);
411
+ // Safe: Register validates that this schema produces TConfigValueTypes at runtime
412
+ // Cache the parsed result (only for resolved values, not source-filtered)
413
+ if (source === undefined) {
414
+ this._parsedCache.set(key, rvalue);
415
+ }
179
416
  return rvalue;
180
417
  }
181
418
  catch (cause) {
182
- throw new ConfigurationError(key, cause instanceof Error ? cause.message : String(cause), { cause: cause instanceof Error ? cause : undefined });
419
+ const isSecret = this._schemaMetaCache.get(key)?.isSecret ?? false;
420
+ if (isSecret) {
421
+ throw new ConfigValidationError(key, 'value failed validation (value redacted for security)');
422
+ }
423
+ throw new ConfigValidationError(key, GetErrorMessage(cause), cause instanceof Error ? { cause } : undefined);
183
424
  }
184
425
  }
185
426
  /**
186
427
  * Retrieve the schema for a configuration key.
428
+ *
187
429
  * @param key - Configuration key
188
430
  * @returns The Zod schema for this configuration
189
- * @throws {ConfigurationNotRegisteredError} If schema is not registered for key
190
- * @example
191
- * const schema = manager.getSchema('PORT');
192
- * const parsed = schema.safeParse(value);
431
+ * @throws {ConfigNotRegisteredError} If schema is not registered for key
193
432
  */
194
- static GetSchema(key) {
433
+ GetSchema(key) {
195
434
  const schema = this._Schemas.get(key);
196
435
  if (!schema)
197
- throw new ConfigurationNotRegisteredError(key);
436
+ throw new ConfigNotRegisteredError(key);
198
437
  return schema;
199
438
  }
439
+ }
440
+ /**
441
+ * Runtime configuration manager with Zod schema validation.
442
+ * Provides a singleton instance to register and retrieve typed configuration values.
443
+ *
444
+ * @example
445
+ * ```typescript
446
+ * ConfigManager.Register('DATABASE_URL', z.string().url(), 'postgresql://localhost/mydb');
447
+ * ConfigManager.Set('DATABASE_URL', 'postgresql://localhost/mydb');
448
+ * const url = ConfigManager.Get('DATABASE_URL');
449
+ * ```
450
+ */
451
+ export class ConfigManager {
452
+ static _state = new CoreConfigState();
453
+ /**
454
+ * Reset the singleton instance (for testing).
455
+ *
456
+ * @internal
457
+ */
458
+ static Reset() {
459
+ this._state.Reset();
460
+ }
200
461
  /**
201
- * Generates a `.env` file string from all currently registered configuration keys.
462
+ * Set an optional handler for provider validation warnings.
202
463
  *
203
- * In template mode (default), each key is emitted with its registered default value.
204
- * Secret fields (marked with `Secret()`) are always emitted with a blank value in template
205
- * mode, regardless of their registered default.
206
- * In current-values mode, the live resolved value from `Get(key)` is used; keys that are
207
- * unset or produce a configuration error are emitted as commented-out blank lines (`# KEY=`).
464
+ * When a provider value fails schema validation, if a handler is set,
465
+ * it will be called with the key and provider name. If no handler is set,
466
+ * validation failures are silent (no-op).
208
467
  *
209
- * If a field has a Zod `.describe()` annotation, it is emitted as a `# comment` line
210
- * immediately before the key–value pair.
468
+ * @param handler - Function to call on validation failure, or undefined to clear
469
+ * @example
470
+ * ```typescript
471
+ * ConfigManager.SetValidationWarningHandler((key, providerName) => {
472
+ * console.warn(`Provider ${providerName} failed for key ${key}`);
473
+ * });
474
+ * ```
475
+ */
476
+ static SetValidationWarningHandler(handler) {
477
+ this._state.SetValidationWarningHandler(handler);
478
+ }
479
+ /**
480
+ * Register a configuration namespace for use when building save entries.
211
481
  *
212
- * @param options - Optional configuration for generation behavior
213
- * @param options.useCurrentValues - When `true`, emit current live values from `Get(key)`.
214
- * Defaults to `false` (template mode using registered defaults).
215
- * @param options.path - When provided, write the generated string to this file path in UTF-8.
216
- * @returns - The generated `.env` content as a string.
482
+ * Records the mapping from `prefix` to `sectionName` so that
483
+ * {@link Save} can split fully-qualified keys into section and field
484
+ * components (e.g. `KEYCLOAK_HOST` section `KEYCLOAK`, field `HOST`).
217
485
  *
486
+ * Called automatically by `RegisterConfigSchema` — applications do not
487
+ * normally need to call this directly.
488
+ *
489
+ * @param name - Human-readable namespace name (e.g. `'Keycloak'`)
490
+ * @param prefix - Derived environment variable prefix (e.g. `'KEYCLOAK_'`)
218
491
  * @example
219
492
  * ```typescript
220
- * // Template mode — defaults shown, secrets blank
221
- * const template = ConfigManager.GenerateEnv();
222
- * // "APP_HOST=localhost\nAPP_PORT=3000\nAPP_SECRET_KEY="
493
+ * ConfigManager.RegisterNamespace('Keycloak', 'KEYCLOAK_');
494
+ * // KEYCLOAK_HOST → section='KEYCLOAK', field='HOST'
223
495
  * ```
496
+ */
497
+ static RegisterNamespace(name, prefix) {
498
+ this._state.RegisterNamespace(name, prefix);
499
+ }
500
+ /**
501
+ * Save all registered configuration values via a provider.
502
+ *
503
+ * Builds a {@link ConfigSaveEntry} for every registered schema key, then
504
+ * delegates formatting and file I/O to `provider.Save()`.
224
505
  *
506
+ * In template mode (`useCurrentValues: false`, the default), each entry
507
+ * carries the registered default value. In current-values mode
508
+ * (`useCurrentValues: true`), each entry carries the fully resolved live
509
+ * value (DEFAULT → provider values → OVERRIDE). The `isSecret` flag is
510
+ * set for fields marked with {@link Secret}; providers are expected to
511
+ * redact those appropriately in template mode.
512
+ *
513
+ * @param provider - An {@link IConfigProvider} that handles the write
514
+ * @param options - Output path and save mode
515
+ * @returns - A promise that resolves when the save operation completes
516
+ * @remarks In `useCurrentValues` mode, keys that cannot be resolved (not set, not registered, or failing validation) are written as blank/undefined without throwing.
225
517
  * @example
226
518
  * ```typescript
227
- * // Save current runtime settings to a file
228
- * ConfigManager.GenerateEnv({ useCurrentValues: true, path: '.env.snapshot' });
519
+ * // Write .env.example (template, secrets blank)
520
+ * await ConfigManager.Save(envProvider, { path: '.env.example' });
521
+ *
522
+ * // Snapshot current runtime values
523
+ * await ConfigManager.Save(envProvider, { path: '.env', useCurrentValues: true });
229
524
  * ```
230
525
  */
231
- static GenerateEnv(options) {
232
- const useCurrentValues = options?.useCurrentValues ?? false;
233
- const lines = [];
234
- for (const [key, schema] of this._Schemas) {
235
- const description = GetFieldDescription(schema);
236
- const isSecret = IsMarkedSecret(schema);
237
- if (description != null) {
238
- lines.push(`# ${description}`);
239
- }
240
- if (useCurrentValues) {
241
- try {
242
- const value = this.Get(key);
243
- lines.push(`${key}=${SerializeConfigValue(value)}`);
244
- }
245
- catch (e) {
246
- if (e instanceof ConfigurationNotSetError ||
247
- e instanceof ConfigurationNotRegisteredError ||
248
- e instanceof ConfigurationError) {
249
- lines.push(`# ${key}=`);
250
- }
251
- else {
252
- throw e;
253
- }
254
- }
255
- }
256
- else {
257
- if (isSecret) {
258
- lines.push(`${key}=`);
259
- }
260
- else {
261
- const defaultValue = this._DataDefaults.get(key);
262
- lines.push(`${key}=${SerializeConfigValue(defaultValue)}`);
263
- }
264
- }
265
- }
266
- const result = lines.join('\n');
267
- if (options?.path != null) {
268
- writeFileSync(options.path, result, 'utf8');
269
- }
270
- return result;
526
+ static async Save(provider, options) {
527
+ await this._state.Save(provider, options);
528
+ }
529
+ /**
530
+ * Register a configuration value provider with the manager.
531
+ *
532
+ * Immediately calls `provider.Load()` to obtain all key/value pairs from the
533
+ * provider. All raw values are stored in the internal raw-data cache so that
534
+ * schemas registered after this call can still receive provider values.
535
+ * For any key that already has a registered schema, the raw value is validated
536
+ * and the validated result is stored in the provider values tier.
537
+ *
538
+ * Provider values occupy the middle precedence tier: they override registered
539
+ * defaults but are themselves overridden by explicit {@link Set} calls.
540
+ * When multiple providers supply the same key, the last-registered provider wins.
541
+ *
542
+ * @param provider - An {@link IConfigProvider} implementation to register
543
+ * @returns - A promise that resolves when registration completes
544
+ * @example
545
+ * ```typescript
546
+ * import { ConfigEnvironmentProvider } from '@pawells/config-provider-env';
547
+ * import { ConfigJSONProvider } from '@pawells/config-provider-json';
548
+ *
549
+ * // Register before importing any schema modules
550
+ * await ConfigManager.RegisterProvider(await ConfigEnvironmentProvider.Register({ path: '.env' }));
551
+ * await ConfigManager.RegisterProvider(await ConfigJSONProvider.Register({ path: './config.json' }));
552
+ * ```
553
+ */
554
+ static async RegisterProvider(provider) {
555
+ await this._state.RegisterProvider(provider);
556
+ }
557
+ /**
558
+ * Register a synchronous configuration provider with the manager.
559
+ * Use this only in contexts that cannot `await`. Most code should prefer
560
+ * `RegisterProvider()` with an async provider.
561
+ *
562
+ * Immediately calls `provider.LoadSync()` to obtain all key/value pairs from the
563
+ * provider. All raw values are stored in the internal raw-data cache so that
564
+ * schemas registered after this call can still receive provider values.
565
+ * For any key that already has a registered schema, the raw value is validated
566
+ * and the validated result is stored in the provider values tier.
567
+ *
568
+ * @param provider - An {@link ISyncConfigProvider} implementation to register
569
+ * @example
570
+ * ```typescript
571
+ * class MemoryProvider implements ISyncConfigProvider {
572
+ * readonly name = 'memory';
573
+ * LoadSync() {
574
+ * return { MY_KEY: 'value' };
575
+ * }
576
+ * }
577
+ * ConfigManager.RegisterSyncProvider(new MemoryProvider());
578
+ * ```
579
+ */
580
+ static RegisterSyncProvider(provider) {
581
+ this._state.RegisterSyncProvider(provider);
582
+ }
583
+ constructor() { }
584
+ /**
585
+ * Register a configuration schema.
586
+ *
587
+ * @param key - Unique configuration key
588
+ * @param schema - Zod schema for runtime validation
589
+ * @param defaultValue - Initial value for the configuration key, must satisfy the schema
590
+ * @throws {ConfigRegistrationError} If key is already registered with a different schema
591
+ * @throws {ConfigValidationError} If defaultValue does not match the schema
592
+ * @example
593
+ * ```typescript
594
+ * ConfigManager.Register('PORT', z.coerce.number().positive(), 3000);
595
+ * ConfigManager.Register('JWT_SECRET', z.string().min(32), 'default-secret');
596
+ * ```
597
+ */
598
+ static Register(key, schema, defaultValue) {
599
+ this._state.Register(key, schema, defaultValue);
600
+ }
601
+ /**
602
+ * Set a configuration value and validate against its schema.
603
+ *
604
+ * @param key - Configuration key
605
+ * @param value - Value to set and validate
606
+ * @param target - Whether to set the default store or the override store; defaults to `'OVERRIDE'`
607
+ * @throws {ConfigNotRegisteredError} If schema is not registered for key
608
+ * @throws {ConfigValidationError} If validation fails
609
+ * @remarks
610
+ * When validation fails for a field marked with `Secret()`, the error message and error cause are sanitized to prevent secret values from appearing in error logs or stack traces.
611
+ * @example
612
+ * ```typescript
613
+ * ConfigManager.Set('PORT', 3000);
614
+ * ConfigManager.Set('JWT_SECRET', process.env.SECRET);
615
+ * ```
616
+ */
617
+ static Set(key, value, target = 'OVERRIDE') {
618
+ this._state.Set(key, value, target);
619
+ }
620
+ /**
621
+ * Retrieve a configuration value by key.
622
+ *
623
+ * Returns the value parsed by its registered schema.
624
+ *
625
+ * @param key - Configuration key
626
+ * @param source - Optional — filter to a specific store (`'DEFAULT'` or `'OVERRIDE'`); omit to return the resolved value (overrides take precedence over defaults)
627
+ * @returns The typed configuration value
628
+ * @throws {ConfigNotSetError} If value was not set
629
+ * @throws {ConfigNotRegisteredError} If schema is not registered
630
+ * @throws {ConfigValidationError} If validation fails on retrieval
631
+ * @example
632
+ * ```typescript
633
+ * const port = ConfigManager.Get('PORT'); // Returns number, guaranteed by schema
634
+ * const secret = ConfigManager.Get('JWT_SECRET'); // Returns string
635
+ * ```
636
+ */
637
+ static Get(key, source) {
638
+ return this._state.Get(key, source);
639
+ }
640
+ /**
641
+ * Retrieve the schema for a configuration key.
642
+ *
643
+ * @param key - Configuration key
644
+ * @returns The Zod schema for this configuration
645
+ * @throws {ConfigNotRegisteredError} If schema is not registered for key
646
+ * @example
647
+ * ```typescript
648
+ * const schema = ConfigManager.GetSchema('PORT');
649
+ * const parsed = schema.safeParse(value);
650
+ * ```
651
+ */
652
+ static GetSchema(key) {
653
+ return this._state.GetSchema(key);
654
+ }
655
+ }
656
+ /**
657
+ * Instance-based configuration manager for test isolation and multi-tenant scenarios.
658
+ *
659
+ * Unlike the static singleton {@link ConfigManager}, `ScopedConfigManager` maintains
660
+ * independent state in instance fields. This enables isolated configuration contexts
661
+ * without affecting the global singleton or other instances.
662
+ *
663
+ * The public API mirrors `ConfigManager` exactly, but as instance methods instead of
664
+ * static methods. Use this when you need:
665
+ * - Test isolation: each test gets its own config instance
666
+ * - Multi-tenant scenarios: separate configs per tenant
667
+ * - Feature-gating: isolated experimental configs
668
+ *
669
+ * @example
670
+ * ```typescript
671
+ * // Two independent configurations
672
+ * const config1 = new ScopedConfigManager();
673
+ * const config2 = new ScopedConfigManager();
674
+ *
675
+ * config1.Register('PORT', z.coerce.number(), 3000);
676
+ * config2.Register('PORT', z.coerce.number(), 4000);
677
+ *
678
+ * config1.Get('PORT'); // 3000
679
+ * config2.Get('PORT'); // 4000 — independent state
680
+ * ```
681
+ */
682
+ export class ScopedConfigManager {
683
+ _state;
684
+ constructor() {
685
+ this._state = new CoreConfigState();
686
+ }
687
+ /**
688
+ * Reset this instance (for testing).
689
+ */
690
+ Reset() {
691
+ this._state.Reset();
692
+ }
693
+ /**
694
+ * Set an optional handler for provider validation warnings.
695
+ *
696
+ * When a provider value fails schema validation, if a handler is set,
697
+ * it will be called with the key and provider name. If no handler is set,
698
+ * validation failures are silent (no-op).
699
+ *
700
+ * @param handler - Function to call on validation failure, or undefined to clear
701
+ */
702
+ SetValidationWarningHandler(handler) {
703
+ this._state.SetValidationWarningHandler(handler);
704
+ }
705
+ /**
706
+ * Register a configuration namespace for use when building save entries.
707
+ *
708
+ * Records the mapping from `prefix` to `sectionName` so that
709
+ * {@link Save} can split fully-qualified keys into section and field
710
+ * components (e.g. `KEYCLOAK_HOST` → section `KEYCLOAK`, field `HOST`).
711
+ *
712
+ * @param name - Human-readable namespace name (e.g. `'Keycloak'`)
713
+ * @param prefix - Derived environment variable prefix (e.g. `'KEYCLOAK_'`)
714
+ */
715
+ RegisterNamespace(name, prefix) {
716
+ this._state.RegisterNamespace(name, prefix);
717
+ }
718
+ /**
719
+ * Save all registered configuration values via a provider.
720
+ *
721
+ * Builds a {@link ConfigSaveEntry} for every registered schema key, then
722
+ * delegates formatting and file I/O to `provider.Save()`.
723
+ *
724
+ * @param provider - An {@link IConfigProvider} that handles the write
725
+ * @param options - Output path and save mode
726
+ * @returns - A promise that resolves when the save operation completes
727
+ */
728
+ async Save(provider, options) {
729
+ await this._state.Save(provider, options);
730
+ }
731
+ /**
732
+ * Register a configuration value provider with this instance.
733
+ *
734
+ * @param provider - An {@link IConfigProvider} implementation to register
735
+ * @returns - A promise that resolves when registration completes
736
+ */
737
+ async RegisterProvider(provider) {
738
+ await this._state.RegisterProvider(provider);
739
+ }
740
+ /**
741
+ * Register a synchronous configuration provider with this instance.
742
+ *
743
+ * Use this only in contexts that cannot `await`. Most code should prefer
744
+ * `RegisterProvider()` with an async provider.
745
+ *
746
+ * @param provider - An {@link ISyncConfigProvider} implementation to register
747
+ */
748
+ RegisterSyncProvider(provider) {
749
+ this._state.RegisterSyncProvider(provider);
750
+ }
751
+ /**
752
+ * Register a configuration schema.
753
+ *
754
+ * @param key - Unique configuration key
755
+ * @param schema - Zod schema for runtime validation
756
+ * @param defaultValue - Initial value for the configuration key, must satisfy the schema
757
+ * @throws {ConfigRegistrationError} If key is already registered with a different schema
758
+ * @throws {ConfigValidationError} If defaultValue does not match the schema
759
+ */
760
+ Register(key, schema, defaultValue) {
761
+ this._state.Register(key, schema, defaultValue);
762
+ }
763
+ /**
764
+ * Set a configuration value and validate against its schema.
765
+ *
766
+ * @param key - Configuration key
767
+ * @param value - Value to set and validate
768
+ * @param target - Whether to set the default store or the override store; defaults to `'OVERRIDE'`
769
+ * @throws {ConfigNotRegisteredError} If schema is not registered for key
770
+ * @throws {ConfigValidationError} If validation fails
771
+ * @remarks
772
+ * When validation fails for a field marked with `Secret()`, the error message and error cause are sanitized to prevent secret values from appearing in error logs or stack traces.
773
+ */
774
+ Set(key, value, target = 'OVERRIDE') {
775
+ this._state.Set(key, value, target);
776
+ }
777
+ /**
778
+ * Retrieve a configuration value by key.
779
+ *
780
+ * Returns the value parsed by its registered schema.
781
+ *
782
+ * @param key - Configuration key
783
+ * @param source - Optional — filter to a specific store (`'DEFAULT'` or `'OVERRIDE'`); omit to return the resolved value (overrides take precedence over defaults)
784
+ * @returns The typed configuration value
785
+ * @throws {ConfigNotSetError} If value was not set
786
+ * @throws {ConfigNotRegisteredError} If schema is not registered
787
+ * @throws {ConfigValidationError} If validation fails on retrieval
788
+ */
789
+ Get(key, source) {
790
+ return this._state.Get(key, source);
791
+ }
792
+ /**
793
+ * Retrieve the schema for a configuration key.
794
+ *
795
+ * @param key - Configuration key
796
+ * @returns The Zod schema for this configuration
797
+ * @throws {ConfigNotRegisteredError} If schema is not registered for key
798
+ */
799
+ GetSchema(key) {
800
+ return this._state.GetSchema(key);
271
801
  }
272
802
  }
273
803
  //# sourceMappingURL=manager.js.map