feat(02-01): implement deepMerge and overlay-aware loadConfig with tests

- Add deepMerge utility for recursive object merging (arrays replace, not concat)
- Extend loadConfig with optional overlayPath parameter
- Merge happens before env var expansion and Zod validation
- Add 6 deepMerge unit tests and 4 overlay integration tests
- Re-export deepMerge from config/index.ts
- All 1087 existing tests still pass
This commit is contained in:
William Valentin
2026-02-09 20:56:29 -08:00
parent 00b1716418
commit c2cc052694
3 changed files with 191 additions and 5 deletions
+50 -2
View File
@@ -29,9 +29,57 @@ function expandEnvVarsInObject(obj: unknown): unknown {
return obj;
}
export function loadConfig(configPath: string): Config {
/**
* Deep merge two plain objects. Overlay values override base values.
* Arrays in overlay replace base arrays (not concatenated).
* null in overlay explicitly sets key to null.
* Only plain objects recurse; arrays, primitives, and null are overwritten.
*/
export function deepMerge(
base: Record<string, unknown>,
overlay: Record<string, unknown>,
): Record<string, unknown> {
const result = { ...base };
for (const [key, value] of Object.entries(overlay)) {
if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
typeof result[key] === 'object' &&
result[key] !== null &&
!Array.isArray(result[key])
) {
result[key] = deepMerge(
result[key] as Record<string, unknown>,
value as Record<string, unknown>,
);
} else {
result[key] = value;
}
}
return result;
}
/**
* Load and validate a Flynn config from YAML file(s).
* If overlayPath is provided, the overlay YAML is deep-merged on top of
* the base config BEFORE env var expansion and Zod validation.
*/
export function loadConfig(configPath: string, overlayPath?: string): Config {
const rawContent = readFileSync(configPath, 'utf-8');
const rawConfig = parse(rawContent);
let rawConfig = parse(rawContent);
if (overlayPath) {
const overlayContent = readFileSync(overlayPath, 'utf-8');
const overlayConfig = parse(overlayContent);
if (overlayConfig && typeof overlayConfig === 'object') {
rawConfig = deepMerge(
rawConfig as Record<string, unknown>,
overlayConfig as Record<string, unknown>,
);
}
}
const expandedConfig = expandEnvVarsInObject(rawConfig);
return configSchema.parse(expandedConfig);
}