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
+1 -1
View File
@@ -1,2 +1,2 @@
export { loadConfig } from './loader.js';
export { loadConfig, deepMerge } from './loader.js';
export { configSchema, type Config, type TelegramConfig, type ModelConfig, type CronJobConfig, type AgentsConfig, type CompactionConfig, type ToolProfile, type ToolOverrideConfig, type ToolsConfig, type SandboxConfig, type AgentConfigEntry, type RoutingConfig, type ServerConfig } from './schema.js';
+140 -2
View File
@@ -1,9 +1,43 @@
import { describe, it, expect } from 'vitest';
import { loadConfig } from './loader.js';
import { describe, it, expect, afterEach } from 'vitest';
import { loadConfig, deepMerge } from './loader.js';
import { writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
describe('deepMerge', () => {
it('merges nested objects', () => {
const result = deepMerge({ a: { b: 1, c: 2 } }, { a: { c: 3 } });
expect(result).toEqual({ a: { b: 1, c: 3 } });
});
it('overlay arrays replace base arrays', () => {
const result = deepMerge({ a: [1, 2] }, { a: [3] });
expect(result).toEqual({ a: [3] });
});
it('adds new keys from overlay', () => {
const result = deepMerge({ a: 1 }, { b: 2 });
expect(result).toEqual({ a: 1, b: 2 });
});
it('returns base when overlay is empty', () => {
const result = deepMerge({ a: 1 }, {});
expect(result).toEqual({ a: 1 });
});
it('handles deeply nested objects (3+ levels)', () => {
const base = { a: { b: { c: { d: 1, e: 2 }, f: 3 } } };
const overlay = { a: { b: { c: { e: 99 } } } };
const result = deepMerge(base, overlay);
expect(result).toEqual({ a: { b: { c: { d: 1, e: 99 }, f: 3 } } });
});
it('null in overlay sets key to null', () => {
const result = deepMerge({ a: { b: 1 } }, { a: null as unknown as Record<string, unknown> });
expect(result).toEqual({ a: null });
});
});
describe('loadConfig', () => {
const testDir = join(tmpdir(), 'flynn-test-config');
@@ -69,3 +103,107 @@ telegram:
rmSync(testDir, { recursive: true });
});
});
describe('loadConfig with overlay', () => {
const testDir = join(tmpdir(), 'flynn-test-overlay');
afterEach(() => {
try { rmSync(testDir, { recursive: true }); } catch {}
delete process.env.TEST_OVERLAY_TOKEN;
delete process.env.TEST_OVERLAY_MODEL;
});
it('merges overlay on top of base config', () => {
mkdirSync(testDir, { recursive: true });
const basePath = join(testDir, 'config.yaml');
const overlayPath = join(testDir, 'docker.yaml');
writeFileSync(basePath, `
telegram:
bot_token: "base-token"
allowed_chat_ids: [123]
server:
port: 18800
models:
default:
provider: anthropic
model: claude-sonnet
`);
writeFileSync(overlayPath, `
server:
port: 9999
`);
const config = loadConfig(basePath, overlayPath);
expect(config.server.port).toBe(9999);
expect(config.telegram.bot_token).toBe('base-token');
});
it('loads base-only when no overlay provided', () => {
mkdirSync(testDir, { recursive: true });
const basePath = join(testDir, 'config.yaml');
writeFileSync(basePath, `
telegram:
bot_token: "base-only-token"
allowed_chat_ids: [456]
server:
port: 18800
models:
default:
provider: anthropic
model: claude-sonnet
`);
const config = loadConfig(basePath);
expect(config.telegram.bot_token).toBe('base-only-token');
expect(config.server.port).toBe(18800);
});
it('expands env vars in both base and overlay', () => {
mkdirSync(testDir, { recursive: true });
const basePath = join(testDir, 'config.yaml');
const overlayPath = join(testDir, 'docker.yaml');
process.env.TEST_OVERLAY_TOKEN = 'expanded-token';
process.env.TEST_OVERLAY_MODEL = 'gpt-4o';
writeFileSync(basePath, `
telegram:
bot_token: \${TEST_OVERLAY_TOKEN}
allowed_chat_ids: [123]
models:
default:
provider: anthropic
model: claude-sonnet
`);
writeFileSync(overlayPath, `
models:
default:
model: \${TEST_OVERLAY_MODEL}
`);
const config = loadConfig(basePath, overlayPath);
expect(config.telegram.bot_token).toBe('expanded-token');
expect(config.models.default.model).toBe('gpt-4o');
});
it('throws when overlay path provided but file does not exist', () => {
mkdirSync(testDir, { recursive: true });
const basePath = join(testDir, 'config.yaml');
writeFileSync(basePath, `
telegram:
bot_token: "token"
allowed_chat_ids: [123]
models:
default:
provider: anthropic
model: claude-sonnet
`);
expect(() => loadConfig(basePath, '/nonexistent/overlay.yaml')).toThrow('/nonexistent/overlay.yaml');
});
});
+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);
}