chore(server): customize config merge logic (#11400)

This commit is contained in:
forehalo
2025-04-02 09:48:45 +00:00
parent b21a0b4520
commit 85d176ce6f
9 changed files with 165 additions and 42 deletions
@@ -64,16 +64,16 @@ test.serial.before(async t => {
copilot: { copilot: {
providers: { providers: {
openai: { openai: {
apiKey: process.env.COPILOT_OPENAI_API_KEY, apiKey: process.env.COPILOT_OPENAI_API_KEY || '',
}, },
fal: { fal: {
apiKey: process.env.COPILOT_FAL_API_KEY, apiKey: process.env.COPILOT_FAL_API_KEY || '',
}, },
perplexity: { perplexity: {
apiKey: process.env.COPILOT_PERPLEXITY_API_KEY, apiKey: process.env.COPILOT_PERPLEXITY_API_KEY || '',
}, },
gemini: { gemini: {
apiKey: process.env.COPILOT_GOOGLE_API_KEY, apiKey: process.env.COPILOT_GOOGLE_API_KEY || '',
}, },
}, },
}, },
@@ -3,6 +3,7 @@ import test from 'ava';
import { createModule } from '../../../__tests__/create-module'; import { createModule } from '../../../__tests__/create-module';
import { ConfigFactory, ConfigModule } from '..'; import { ConfigFactory, ConfigModule } from '..';
import { Config } from '../config'; import { Config } from '../config';
import { override } from '../register';
const module = await createModule(); const module = await createModule();
test.after.always(async () => { test.after.always(async () => {
@@ -49,13 +50,14 @@ test('should override config', async t => {
auth: { auth: {
passwordRequirements: { passwordRequirements: {
max: 10, max: 10,
min: 1,
}, },
}, },
}); });
t.deepEqual(config.auth.passwordRequirements, { t.deepEqual(config.auth.passwordRequirements, {
max: 10, max: 10,
min: 6, min: 1,
}); });
}); });
@@ -88,3 +90,87 @@ Error: Minimum length of password must be less than maximum length`,
} }
); );
}); });
test('should override correctly', t => {
const config = {
auth: {
// object config
passwordRequirements: {
max: 10,
min: 6,
},
allowSignup: false,
// keyed config
// 'session.ttl', 'session.ttr'
session: {
ttl: 2000,
ttr: 1000,
},
},
storages: {
avatar: {
// keyed config
// "avatar.publicPath: String"
publicPath: '/',
// object config
// "avatar.storage => Object { }"
storage: {
provider: 'fs',
config: {
path: '/path/to/avatar',
},
},
},
},
} as AppConfig;
override(config, {
auth: {
passwordRequirements: {
max: 20,
},
allowSignup: true,
session: {
ttl: 3000,
},
},
storages: {
avatar: {
storage: {
provider: 'aws-s3',
config: {
credentials: {
accessKeyId: '1',
accessKeySecret: '1',
},
},
},
},
},
});
// simple value override
t.deepEqual(config.auth.allowSignup, true);
// right covered left
t.deepEqual(config.auth.passwordRequirements, {
max: 20,
});
// right merged to left
t.deepEqual(config.auth.session, {
ttl: 3000,
ttr: 1000,
});
// right covered left
t.deepEqual(config.storages.avatar.storage, {
provider: 'aws-s3',
config: {
credentials: {
accessKeyId: '1',
accessKeySecret: '1',
},
},
});
});
@@ -1,14 +1,13 @@
import { Inject, Injectable, Optional } from '@nestjs/common'; import { Inject, Injectable, Optional } from '@nestjs/common';
import { merge } from 'lodash-es';
import { InvalidAppConfig } from '../error'; import { InvalidAppConfig } from '../error';
import { APP_CONFIG_DESCRIPTORS, getDefaultConfig } from './register'; import { APP_CONFIG_DESCRIPTORS, getDefaultConfig, override } from './register';
export const OVERRIDE_CONFIG_TOKEN = Symbol('OVERRIDE_CONFIG_TOKEN'); export const OVERRIDE_CONFIG_TOKEN = Symbol('OVERRIDE_CONFIG_TOKEN');
@Injectable() @Injectable()
export class ConfigFactory { export class ConfigFactory {
readonly #config: DeepReadonly<AppConfig>; readonly #config: AppConfig;
constructor( constructor(
@Inject(OVERRIDE_CONFIG_TOKEN) @Inject(OVERRIDE_CONFIG_TOKEN)
@@ -22,8 +21,12 @@ export class ConfigFactory {
return this.#config; return this.#config;
} }
clone() {
return structuredClone(this.#config);
}
override(updates: DeepPartial<AppConfig>) { override(updates: DeepPartial<AppConfig>) {
merge(this.#config, updates); override(this.#config, updates);
} }
validate(updates: Array<{ module: string; key: string; value: any }>) { validate(updates: Array<{ module: string; key: string; value: any }>) {
@@ -53,8 +56,9 @@ Error: ${issue.message}`);
} }
} }
private loadDefault(): DeepReadonly<AppConfig> { private loadDefault() {
const config = getDefaultConfig(); const config = getDefaultConfig();
return merge(config, this.overrides); override(config, this.overrides);
return config;
} }
} }
@@ -1,7 +1,8 @@
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { merge, once, set } from 'lodash-es'; import { mergeWith, once, set } from 'lodash-es';
import { z } from 'zod'; import { z } from 'zod';
import { type EnvConfigType, parseEnvValue } from './env'; import { type EnvConfigType, parseEnvValue } from './env';
@@ -208,14 +209,15 @@ export function defineModuleConfig<T extends keyof AppConfigSchema>(
}; };
} }
function readConfigJSONOverrides() { const CONFIG_JSON_PATHS = [
if (existsSync(join(env.projectRoot, 'config.json'))) { join(env.projectRoot, 'config.json'),
`${homedir()}/.affine/config/config.json`,
];
function readConfigJSONOverrides(path: string) {
const overrides: DeepPartial<AppConfig> = {};
if (existsSync(path)) {
try { try {
const config = JSON.parse( const config = JSON.parse(readFileSync(path, 'utf-8')) as AppConfig;
readFileSync(join(env.projectRoot, 'config.json'), 'utf-8')
) as AppConfig;
const overrides = {};
Object.entries(config).forEach(([key, value]) => { Object.entries(config).forEach(([key, value]) => {
if (key === '$schema') { if (key === '$schema') {
@@ -226,18 +228,41 @@ function readConfigJSONOverrides() {
set(overrides, `${key}.${k}`, v); set(overrides, `${key}.${k}`, v);
}); });
}); });
return overrides;
} catch (e) { } catch (e) {
console.error('Invalid json config file', e); console.error('Invalid json config file', e);
} }
} }
return {}; return overrides;
} }
export function getDefaultConfig(): AppConfigSchema { export function override(config: AppConfig, update: DeepPartial<AppConfig>) {
const config: Record<string, any> = {}; Object.keys(update).forEach(module => {
const moduleDescriptors = APP_CONFIG_DESCRIPTORS[module];
const configKeys = new Set(Object.keys(moduleDescriptors));
const moduleConfig = config[module as keyof AppConfig];
const moduleOverrides = update[module as keyof AppConfig];
const merge = (left: any, right: any, path: string = '') => {
// if we found the key in the config keys
// we should use the override object instead of merge it with left
if (configKeys.has(path)) {
return right;
}
// go deeper
return mergeWith(left, right, (left, right, key) => {
return merge(left, right, path === '' ? key : `${path}.${key}`);
});
};
config[module as keyof AppConfig] = merge(moduleConfig, moduleOverrides);
});
}
export function getDefaultConfig(): AppConfig {
const config = {} as AppConfig;
const envs = process.env; const envs = process.env;
for (const [module, defs] of Object.entries(APP_CONFIG_DESCRIPTORS)) { for (const [module, defs] of Object.entries(APP_CONFIG_DESCRIPTORS)) {
@@ -271,12 +296,14 @@ Error: ${issue.message}`;
set(modulizedConfig, key, defaultValue); set(modulizedConfig, key, defaultValue);
} }
// @ts-expect-error all keys are known
config[module] = modulizedConfig; config[module] = modulizedConfig;
} }
const fileOverrides = readConfigJSONOverrides(); CONFIG_JSON_PATHS.forEach(path => {
const overrides = readConfigJSONOverrides(path);
merge(config, fileOverrides); override(config, overrides);
});
return config as AppConfigSchema; return config as AppConfigSchema;
} }
@@ -155,7 +155,7 @@ export class EventBus
add.call(this.emitter, event, handler as any, opts); add.call(this.emitter, event, handler as any, opts);
this.logger.verbose(`Event handler registered ${signature}`); this.logger.log(`Event handler registered ${signature}`);
return () => { return () => {
this.emitter.off(event, handler as any); this.emitter.off(event, handler as any);
@@ -1,4 +1,4 @@
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ModuleScanner } from '../../nestjs'; import { ModuleScanner } from '../../nestjs';
import { getJobHandlerMetadata, JOB_SIGNAL } from './def'; import { getJobHandlerMetadata, JOB_SIGNAL } from './def';
@@ -11,6 +11,7 @@ interface JobHandler {
@Injectable() @Injectable()
export class JobHandlerScanner implements OnModuleInit { export class JobHandlerScanner implements OnModuleInit {
private readonly handlers: Record<string, JobHandler> = {}; private readonly handlers: Record<string, JobHandler> = {};
private readonly logger = new Logger(JobHandlerScanner.name);
constructor(private readonly scanner: ModuleScanner) {} constructor(private readonly scanner: ModuleScanner) {}
@@ -70,6 +71,8 @@ export class JobHandlerScanner implements OnModuleInit {
return instance[method].bind(instance)(payload); return instance[method].bind(instance)(payload);
}, },
}; };
this.logger.log(`Job handler registered [${jobName}] (${signature})`);
}); });
}); });
}); });
@@ -23,7 +23,7 @@ test.after.always(async () => {
}); });
test('should update config', async t => { test('should update config', async t => {
const oldValue = service.config.server.externalUrl; const oldValue = service.getConfig().server.externalUrl;
const newValue = faker.internet.url(); const newValue = faker.internet.url();
await service.updateConfig(user.id, [ await service.updateConfig(user.id, [
{ {
@@ -33,8 +33,8 @@ test('should update config', async t => {
}, },
]); ]);
t.not(service.config.server.externalUrl, oldValue); t.not(service.getConfig().server.externalUrl, oldValue);
t.is(service.config.server.externalUrl, newValue); t.is(service.getConfig().server.externalUrl, newValue);
}); });
test('should validate config before update', async t => { test('should validate config before update', async t => {
@@ -53,7 +53,7 @@ Error: Invalid url`,
} }
); );
t.not(service.config.server.externalUrl, 'invalid-url'); t.not(service.getConfig().server.externalUrl, 'invalid-url');
await t.throwsAsync( await t.throwsAsync(
service.updateConfig(user.id, [ service.updateConfig(user.id, [
@@ -68,8 +68,11 @@ Error: Invalid url`,
} }
); );
// @ts-expect-error allow t.is(
t.is(service.config.auth['unknown-key'], undefined); // @ts-expect-error testing
service.getConfig().auth['unknown-key'],
undefined
);
}); });
test('should emit config.init event', async t => { test('should emit config.init event', async t => {
@@ -77,12 +80,12 @@ test('should emit config.init event', async t => {
const event = module.event.last('config.init'); const event = module.event.last('config.init');
t.is(event.name, 'config.init'); t.is(event.name, 'config.init');
t.deepEqual(event.payload, { t.deepEqual(event.payload, {
config: service.config, config: service.getConfig(),
}); });
}); });
test('should revalidate config', async t => { test('should revalidate config', async t => {
const outdatedValue = service.config.server.externalUrl; const outdatedValue = service.getConfig().server.externalUrl;
const newValue = faker.internet.url(); const newValue = faker.internet.url();
await models.appConfig.save(user.id, [ await models.appConfig.save(user.id, [
@@ -94,8 +97,8 @@ test('should revalidate config', async t => {
await service.revalidateConfig(); await service.revalidateConfig();
t.not(service.config.server.externalUrl, outdatedValue); t.not(service.getConfig().server.externalUrl, outdatedValue);
t.is(service.config.server.externalUrl, newValue); t.is(service.getConfig().server.externalUrl, newValue);
}); });
test('should emit config changed event', async t => { test('should emit config changed event', async t => {
@@ -191,7 +191,7 @@ export class AppConfigResolver {
description: 'get the whole app configuration', description: 'get the whole app configuration',
}) })
appConfig() { appConfig() {
return this.service.config; return this.service.getConfig();
} }
@Mutation(() => GraphQLJSONObject, { @Mutation(() => GraphQLJSONObject, {
@@ -56,8 +56,8 @@ export class ServerService implements OnApplicationBootstrap {
this.#features.delete(feature); this.#features.delete(feature);
} }
get config() { getConfig() {
return this.configFactory.config; return this.configFactory.clone();
} }
async updateConfig( async updateConfig(