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
@@ -3,6 +3,7 @@ import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { ConfigFactory, ConfigModule } from '..';
import { Config } from '../config';
import { override } from '../register';
const module = await createModule();
test.after.always(async () => {
@@ -49,13 +50,14 @@ test('should override config', async t => {
auth: {
passwordRequirements: {
max: 10,
min: 1,
},
},
});
t.deepEqual(config.auth.passwordRequirements, {
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 { merge } from 'lodash-es';
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');
@Injectable()
export class ConfigFactory {
readonly #config: DeepReadonly<AppConfig>;
readonly #config: AppConfig;
constructor(
@Inject(OVERRIDE_CONFIG_TOKEN)
@@ -22,8 +21,12 @@ export class ConfigFactory {
return this.#config;
}
clone() {
return structuredClone(this.#config);
}
override(updates: DeepPartial<AppConfig>) {
merge(this.#config, updates);
override(this.#config, updates);
}
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();
return merge(config, this.overrides);
override(config, this.overrides);
return config;
}
}
@@ -1,7 +1,8 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { merge, once, set } from 'lodash-es';
import { mergeWith, once, set } from 'lodash-es';
import { z } from 'zod';
import { type EnvConfigType, parseEnvValue } from './env';
@@ -208,14 +209,15 @@ export function defineModuleConfig<T extends keyof AppConfigSchema>(
};
}
function readConfigJSONOverrides() {
if (existsSync(join(env.projectRoot, 'config.json'))) {
const CONFIG_JSON_PATHS = [
join(env.projectRoot, 'config.json'),
`${homedir()}/.affine/config/config.json`,
];
function readConfigJSONOverrides(path: string) {
const overrides: DeepPartial<AppConfig> = {};
if (existsSync(path)) {
try {
const config = JSON.parse(
readFileSync(join(env.projectRoot, 'config.json'), 'utf-8')
) as AppConfig;
const overrides = {};
const config = JSON.parse(readFileSync(path, 'utf-8')) as AppConfig;
Object.entries(config).forEach(([key, value]) => {
if (key === '$schema') {
@@ -226,18 +228,41 @@ function readConfigJSONOverrides() {
set(overrides, `${key}.${k}`, v);
});
});
return overrides;
} catch (e) {
console.error('Invalid json config file', e);
}
}
return {};
return overrides;
}
export function getDefaultConfig(): AppConfigSchema {
const config: Record<string, any> = {};
export function override(config: AppConfig, update: DeepPartial<AppConfig>) {
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;
for (const [module, defs] of Object.entries(APP_CONFIG_DESCRIPTORS)) {
@@ -271,12 +296,14 @@ Error: ${issue.message}`;
set(modulizedConfig, key, defaultValue);
}
// @ts-expect-error all keys are known
config[module] = modulizedConfig;
}
const fileOverrides = readConfigJSONOverrides();
merge(config, fileOverrides);
CONFIG_JSON_PATHS.forEach(path => {
const overrides = readConfigJSONOverrides(path);
override(config, overrides);
});
return config as AppConfigSchema;
}
@@ -155,7 +155,7 @@ export class EventBus
add.call(this.emitter, event, handler as any, opts);
this.logger.verbose(`Event handler registered ${signature}`);
this.logger.log(`Event handler registered ${signature}`);
return () => {
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 { getJobHandlerMetadata, JOB_SIGNAL } from './def';
@@ -11,6 +11,7 @@ interface JobHandler {
@Injectable()
export class JobHandlerScanner implements OnModuleInit {
private readonly handlers: Record<string, JobHandler> = {};
private readonly logger = new Logger(JobHandlerScanner.name);
constructor(private readonly scanner: ModuleScanner) {}
@@ -70,6 +71,8 @@ export class JobHandlerScanner implements OnModuleInit {
return instance[method].bind(instance)(payload);
},
};
this.logger.log(`Job handler registered [${jobName}] (${signature})`);
});
});
});