diff --git a/README.md b/README.md index 2646351..30a21e9 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Use the `loadConfig` function once, to load and merge configurations from specif With parameters you can define: - `configDirs`: (Default: `['${current-working-directory}/config']`) An array of directories to load configurations from - `environment`: (Default: `process.env.NODE_ENV`) The environment to load specific configurations for +- `variant`: (Default: `undefined`) Allows to define 2nd dimension of configs above environment. Will load configs matching `*-{variant}` pattern - `fileExtensions`: (Default: `['ts', 'js']`) An array of file extensions to load configurations from - `throwOnUndefinedProp`: (Default: `true`) If true, throws an error when accessing undefined properties - `freezeConfig`: (Default: `true`) If true, freezes the configuration object to make it immutable @@ -153,14 +154,52 @@ Add this to `.gitignore` to prevent committing local configuration files: **/config/local* ``` +### Second layer of variant configurations + +Some applications may need another layer of overrides besides environments (for example: regions, brands, customers etc). +Pass the `variant` option to `loadConfig` and create files that follow the `*-{variant}` naming convention to scope those overrides. + +When `variant` is set, Configate looks for these optional files on top of the usual ones: +- `default-{variant}.ext` +- `{environment}-{variant}.ext` +- `local-{variant}.ext` +- `local-{environment}-{variant}.ext` + +Example setup: + +```text +config/ + default.ts + default-customerA.ts + default-customerB.ts + production.ts + production-customerA.ts +``` + +```ts +// src/config.ts +import { loadConfig } from 'configate'; + +export const { config } = await loadConfig({ + environment: 'production', + variant: 'customerA', +}); +``` + +This keeps the base config reusable while still letting you target per-variant overrides without duplicating entire files. + ### Order of loading configuration files ``` -default.ext -{environment}.ext -local.ext -local-{environment}.ext -custom-environment-variables.ext +1. default.ext +2. default-{variant}.ext +3. {environment}.ext +4. {environment}-{variant}.ext +5. local.ext +6. local-{variant}.ext +7. local-{environment}.ext +8. local-{environment}-{variant}.ext +9. custom-environment-variables.ext ``` ### Using unsecure config @@ -221,4 +260,3 @@ export const { config } = await loadConfig({ ], }); ``` - diff --git a/src/importConfigFiles.test.ts b/src/importConfigFiles.test.ts index 2a7877a..77bff0a 100644 --- a/src/importConfigFiles.test.ts +++ b/src/importConfigFiles.test.ts @@ -27,6 +27,49 @@ describe('importConfigFiles', () => { process.env.SHALLOW_PROPERTY = undefined; }); + + it('loads variant-specific overrides on top of defaults', async () => { + const config = await importConfigFiles({ + configDir: `${import.meta.dirname}/testConfigDirs/variant`, + variant: 'customerA', + fileExtensions: ['ts'], + }); + + assert.equal(config.shallowProperty, 'default'); + assert.equal( + config.nestedObject.nestedProperty1, + 'default-customerA-n1', + ); + assert.equal(config.nestedObject.nestedProperty2, 'local-customerA-n2'); + + const configB = await importConfigFiles({ + configDir: `${import.meta.dirname}/testConfigDirs/variant`, + variant: 'customerB', + fileExtensions: ['ts'], + }); + + assert.equal(configB.shallowProperty, 'default'); + assert.equal( + configB.nestedObject.nestedProperty1, + 'default-customerB-n1', + ); + }); + + it('prioritizes environment+variant specific files and locals', async () => { + const config = await importConfigFiles({ + configDir: `${import.meta.dirname}/testConfigDirs/variant`, + environment: 'production', + variant: 'customerA', + fileExtensions: ['ts'], + }); + + assert.equal(config.shallowProperty, 'local-production'); + assert.equal( + config.nestedObject.nestedProperty1, + 'default-customerA-n1', + ); + assert.equal(config.nestedObject.nestedProperty2, 'local-customerA-n2'); + }); }); export type TestConfig = { diff --git a/src/importConfigFiles.ts b/src/importConfigFiles.ts index 4c62285..1da804a 100644 --- a/src/importConfigFiles.ts +++ b/src/importConfigFiles.ts @@ -13,6 +13,7 @@ import { importConfigFile } from './importConfigFile.ts'; export async function importConfigFiles({ configDir, environment, + variant, fileExtensions, }: ImportConfigFilesOptions): Promise { /** default.ext **/ @@ -22,11 +23,21 @@ export async function importConfigFiles({ shouldThrowError: true, }); + /** default-{variant}.ext **/ + let defaultVariantConfig: DeepPartial | undefined; + if (variant) { + defaultVariantConfig = await importConfigFile>({ + filePath: `${configDir}/default-${variant}`, + fileExtensions, + }); + } + /** {environment}.ext **/ - let envConfig: DeepPartial | undefined; + let environmentConfig: DeepPartial | undefined; + let environmentVariantConfig: DeepPartial | undefined; if (environment) { try { - envConfig = await importConfigFile>({ + environmentConfig = await importConfigFile>({ filePath: `${configDir}/${environment}`, fileExtensions, shouldThrowError: true, @@ -36,6 +47,16 @@ export async function importConfigFiles({ `Environment ${environment} defined but no config file found`, ); } + + /** {environment}-{variant}.ext **/ + if (variant) { + environmentVariantConfig = await importConfigFile< + DeepPartial + >({ + filePath: `${configDir}/${environment}-${variant}`, + fileExtensions, + }); + } } /** local.ext **/ @@ -44,6 +65,15 @@ export async function importConfigFiles({ fileExtensions, }); + /** local-{variant}.ext **/ + let localVariantConfig: DeepPartial | undefined; + if (variant) { + localVariantConfig = await importConfigFile>({ + filePath: `${configDir}/local-${variant}`, + fileExtensions, + }); + } + /** local-{environment}.ext **/ const localEnvConfig = environment ? await importConfigFile({ @@ -60,8 +90,11 @@ export async function importConfigFiles({ return deepMerge( structuredClone(defaultConfig), - structuredClone(envConfig ?? {}), + structuredClone(defaultVariantConfig ?? {}), + structuredClone(environmentConfig ?? {}), + structuredClone(environmentVariantConfig ?? {}), structuredClone(localConfig), + structuredClone(localVariantConfig ?? {}), structuredClone(localEnvConfig), structuredClone(customEnvVarsConfig), ) as Config; @@ -70,5 +103,6 @@ export async function importConfigFiles({ type ImportConfigFilesOptions = { configDir: string; environment?: Environment; + variant?: string; fileExtensions: FileExtension[]; }; diff --git a/src/loadConfig.ts b/src/loadConfig.ts index cb5ca99..ca8f51b 100644 --- a/src/loadConfig.ts +++ b/src/loadConfig.ts @@ -10,10 +10,14 @@ import { makeSecureDeepProxy } from './makeSecureDeepProxy.ts'; * Features: * - Load config files in order: * 1. default.ext - * 2. {environment}.ext - * 4. local.ext - * 3. local-{environment}.ext - * 5. custom-environment-variables.ext (only for .ts and .js files) + * 2. default-{variant}.ext + * 3. {environment}.ext + * 4. {environment}-{variant}.ext + * 5. local.ext + * 6. local-{variant}.ext + * 7. local-{environment}.ext + * 8. local-{environment}-{variant}.ext + * 9. custom-environment-variables.ext (only for .ts and .js files) * - Throw an error if a property is accessed that is not defined in the config * - Freeze the config object to prevent modifications * - Supported config file formats: TypeScript (.ts, .mts), JavaScript (.js, .mjs), JSON (.json) @@ -22,6 +26,7 @@ import { makeSecureDeepProxy } from './makeSecureDeepProxy.ts'; * * @param configDirs - paths to directories where the config files are located. Relative or absolute. Directories are merged in the array order. Default: ['${current-working-directory}/config'] * @param environment - the environment to load the config for. Default: process.env.NODE_ENV + * @param variant - allows to define 2nd dimension of configs above environment. Default: undefined * @param fileExtensions - an array of file extensions to use when looking for config files. Configs are loaded in this order. Default: ['ts', 'js'] * @param throwOnUndefinedProp - throw an error if a property is accessed that is not defined in the config. Default: true * @param freezeConfig - freeze the config object to prevent modifications. Default: true @@ -33,6 +38,7 @@ import { makeSecureDeepProxy } from './makeSecureDeepProxy.ts'; export async function loadConfig({ configDirs = [`${process.cwd()}/config`], environment = process.env.NODE_ENV, + variant, fileExtensions = ['ts', 'js'], throwOnUndefinedProp = true, freezeConfig = true, @@ -46,6 +52,7 @@ export async function loadConfig({ const config = await importConfigFiles({ configDir, environment, + variant, fileExtensions, }); @@ -71,6 +78,7 @@ export async function loadConfig({ type LoadConfigOptions = { configDirs?: string[]; environment?: Environment; + variant?: string; fileExtensions?: FileExtension[]; throwOnUndefinedProp?: boolean; freezeConfig?: boolean; diff --git a/src/testConfigDirs/variant/default-customerA.ts b/src/testConfigDirs/variant/default-customerA.ts new file mode 100644 index 0000000..7f67c4a --- /dev/null +++ b/src/testConfigDirs/variant/default-customerA.ts @@ -0,0 +1,8 @@ +import type { DeepPartial } from '../../common.ts'; +import type { TestConfig } from '../../importConfigFiles.test.js'; + +export const config: DeepPartial = { + nestedObject: { + nestedProperty1: 'default-customerA-n1', + }, +}; diff --git a/src/testConfigDirs/variant/default-customerB.ts b/src/testConfigDirs/variant/default-customerB.ts new file mode 100644 index 0000000..0242994 --- /dev/null +++ b/src/testConfigDirs/variant/default-customerB.ts @@ -0,0 +1,8 @@ +import type { DeepPartial } from '../../common.ts'; +import type { TestConfig } from '../../importConfigFiles.test.js'; + +export const config: DeepPartial = { + nestedObject: { + nestedProperty1: 'default-customerB-n1', + }, +}; diff --git a/src/testConfigDirs/variant/default.ts b/src/testConfigDirs/variant/default.ts new file mode 100644 index 0000000..a356c1b --- /dev/null +++ b/src/testConfigDirs/variant/default.ts @@ -0,0 +1,9 @@ +import type { TestConfig } from '../../importConfigFiles.test.js'; + +export const config: TestConfig = { + shallowProperty: 'default', + nestedObject: { + nestedProperty1: 'default-n1', + nestedProperty2: 'default-n2', + }, +}; diff --git a/src/testConfigDirs/variant/local-customerA.ts b/src/testConfigDirs/variant/local-customerA.ts new file mode 100644 index 0000000..34402d7 --- /dev/null +++ b/src/testConfigDirs/variant/local-customerA.ts @@ -0,0 +1,8 @@ +import type { DeepPartial } from '../../common.ts'; +import type { TestConfig } from '../../importConfigFiles.test.js'; + +export const config: DeepPartial = { + nestedObject: { + nestedProperty2: 'local-customerA-n2', + }, +}; diff --git a/src/testConfigDirs/variant/local-production-customerA.ts b/src/testConfigDirs/variant/local-production-customerA.ts new file mode 100644 index 0000000..619028d --- /dev/null +++ b/src/testConfigDirs/variant/local-production-customerA.ts @@ -0,0 +1,8 @@ +import type { DeepPartial } from '../../common.ts'; +import type { TestConfig } from '../../importConfigFiles.test.js'; + +export const config: DeepPartial = { + nestedObject: { + nestedProperty1: 'local-production-customerA-n1', + }, +}; diff --git a/src/testConfigDirs/variant/local-production.ts b/src/testConfigDirs/variant/local-production.ts new file mode 100644 index 0000000..681a8be --- /dev/null +++ b/src/testConfigDirs/variant/local-production.ts @@ -0,0 +1,6 @@ +import type { DeepPartial } from '../../common.ts'; +import type { TestConfig } from '../../importConfigFiles.test.js'; + +export const config: DeepPartial = { + shallowProperty: 'local-production', +}; diff --git a/src/testConfigDirs/variant/local.ts b/src/testConfigDirs/variant/local.ts new file mode 100644 index 0000000..be52292 --- /dev/null +++ b/src/testConfigDirs/variant/local.ts @@ -0,0 +1,8 @@ +import type { DeepPartial } from '../../common.ts'; +import type { TestConfig } from '../../importConfigFiles.test.js'; + +export const config: DeepPartial = { + nestedObject: { + nestedProperty2: 'local-n2', + }, +}; diff --git a/src/testConfigDirs/variant/production-customerA.ts b/src/testConfigDirs/variant/production-customerA.ts new file mode 100644 index 0000000..c659941 --- /dev/null +++ b/src/testConfigDirs/variant/production-customerA.ts @@ -0,0 +1,6 @@ +import type { DeepPartial } from '../../common.ts'; +import type { TestConfig } from '../../importConfigFiles.test.js'; + +export const config: DeepPartial = { + shallowProperty: 'production-customerA', +}; diff --git a/src/testConfigDirs/variant/production.ts b/src/testConfigDirs/variant/production.ts new file mode 100644 index 0000000..fe2c0d3 --- /dev/null +++ b/src/testConfigDirs/variant/production.ts @@ -0,0 +1,9 @@ +import type { DeepPartial } from '../../common.ts'; +import type { TestConfig } from '../../importConfigFiles.test.js'; + +export const config: DeepPartial = { + shallowProperty: 'production', + nestedObject: { + nestedProperty2: 'production-n2', + }, +};