Yuki Takei 1 год назад
Родитель
Сommit
193bf0204c

Разница между файлами не показана из-за своего большого размера
+ 336 - 365
apps/app/src/server/service/config-manager/config-definition.ts


+ 40 - 29
apps/app/src/server/service/config-manager/config-loader.ts

@@ -1,56 +1,60 @@
-import loggerFactory from '~/utils/logger';
-
-import { Config } from '../../models/config';
+import type { IConfigLoader } from '@growi/core/dist/interfaces';
 
-import type {
-  ConfigKey,
-  RawConfigData,
-} from './config-definition';
-import {
-  CONFIG_DEFINITIONS,
-} from './config-definition';
+import loggerFactory from '~/utils/logger';
 
+import type { ConfigKey, ConfigValues } from './config-definition';
+import { CONFIG_DEFINITIONS } from './config-definition';
 
 const logger = loggerFactory('growi:service:ConfigLoader');
 
-export class ConfigLoader {
+export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
 
-  /**
-   * Load configuration from environment variables
-   */
-  async loadFromEnv(): Promise<RawConfigData['env']> {
-    const envConfig: RawConfigData['env'] = {};
+  async loadFromEnv(): Promise<Record<ConfigKey, ConfigValues[ConfigKey]>> {
+    const envConfig = {} as Record<ConfigKey, ConfigValues[ConfigKey]>;
 
     for (const [key, metadata] of Object.entries(CONFIG_DEFINITIONS)) {
-      const envValue = process.env[metadata.envVarName];
-      if (envValue === undefined) {
-        envConfig[key as ConfigKey] = metadata.defaultValue;
-      }
-      else {
-        envConfig[key as ConfigKey] = this.parseEnvValue(envValue, typeof metadata.defaultValue);
+      const configKey = key as ConfigKey;
+
+      if (metadata.envVarName != null) {
+        const envValue = process.env[metadata.envVarName];
+        if (envValue !== undefined) {
+          envConfig[configKey] = this.parseEnvValue(
+            envValue,
+            typeof metadata.defaultValue,
+          ) as ConfigValues[ConfigKey];
+          continue;
+        }
       }
+      envConfig[configKey] = metadata.defaultValue;
     }
 
     logger.debug('loadFromEnv', envConfig);
     return envConfig;
   }
 
-  /**
-   * Load configuration from the database
-   */
-  async loadFromDB(): Promise<RawConfigData['db']> {
-    const dbConfig: RawConfigData['db'] = {};
+  async loadFromDB(): Promise<Record<ConfigKey, ConfigValues[ConfigKey] | null>> {
+    const dbConfig = {} as Record<ConfigKey, ConfigValues[ConfigKey] | null>;
+
+    // Initialize with null values
+    for (const key of Object.keys(CONFIG_DEFINITIONS)) {
+      dbConfig[key as ConfigKey] = null;
+    }
+
+    // Dynamic import to avoid loading database modules too early
+    const { Config } = await import('../../models/config');
     const docs = await Config.find().exec();
 
     for (const doc of docs) {
-      dbConfig[doc.key as ConfigKey] = doc.value ? JSON.parse(doc.value) : null;
+      if (doc.key in CONFIG_DEFINITIONS) {
+        dbConfig[doc.key as ConfigKey] = doc.value ? JSON.parse(doc.value) : null;
+      }
     }
 
     logger.debug('loadFromDB', dbConfig);
     return dbConfig;
   }
 
-  private parseEnvValue(value: string, type: string): any {
+  private parseEnvValue(value: string, type: string): unknown {
     switch (type) {
       case 'number':
         return parseInt(value, 10);
@@ -58,6 +62,13 @@ export class ConfigLoader {
         return value.toLowerCase() === 'true';
       case 'string':
         return value;
+      case 'object':
+        try {
+          return JSON.parse(value);
+        }
+        catch {
+          return null;
+        }
       default:
         return value;
     }

+ 1 - 1
apps/app/src/server/service/config-manager/config-manager.integ.ts

@@ -1,6 +1,6 @@
 import { mock } from 'vitest-mock-extended';
 
-import { GrowiDeploymentType, GrowiServiceType } from '~/features/questionnaire/interfaces/growi-info';
+import { GrowiDeploymentType, GrowiServiceType } from '~/interfaces/system';
 
 import { Config } from '../../models/config';
 import type { S2sMessagingService } from '../s2s-messaging/base';

+ 61 - 156
apps/app/src/server/service/config-manager/config-manager.ts

@@ -1,158 +1,104 @@
+import type { IConfigManager, ConfigSource, UpdateConfigOptions } from '@growi/core/dist/interfaces';
 import { parseISO } from 'date-fns/parseISO';
 
 import loggerFactory from '~/utils/logger';
 
-import { Config } from '../../models/config';
-import S2sMessage from '../../models/vo/s2s-message';
+import type S2sMessage from '../../models/vo/s2s-message';
 import type { S2sMessagingService } from '../s2s-messaging/base';
 import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
 
-import {
-  ConfigKeys,
-  CONFIG_DEFINITIONS,
-  ENV_ONLY_GROUPS,
-} from './config-definition';
-import type {
-  ConfigKey,
-  ConfigValues,
-  MergedConfigData,
-  RawConfigData,
-} from './config-definition';
+import type { ConfigKey, ConfigValues } from './config-definition';
+import { ENV_ONLY_GROUPS } from './config-definition';
 import { ConfigLoader } from './config-loader';
 
-const logger = loggerFactory('growi:service:ConfigManager');
-
 
-type ConfigUpdates<K extends ConfigKey> = Partial<{ [P in K]: ConfigValues[P] }>;
-
-type UpdateConfigOptions = {
-  skipPubsub?: boolean;
-};
+const logger = loggerFactory('growi:service:ConfigManager');
 
-export class ConfigManager implements S2sMessageHandlable {
+export class ConfigManager implements IConfigManager<ConfigKey, ConfigValues>, S2sMessageHandlable {
 
-  private configLoader = new ConfigLoader();
+  private configLoader: ConfigLoader;
 
   private s2sMessagingService?: S2sMessagingService;
 
-  private rawConfig?: RawConfigData;
+  private envConfig?: Record<ConfigKey, ConfigValues[ConfigKey]>;
 
-  private mergedConfig?: MergedConfigData;
+  private dbConfig?: Record<ConfigKey, ConfigValues[ConfigKey] | null>;
 
   private lastLoadedAt?: Date;
 
-  private keyToGroupMap: Map<ConfigKey, ConfigKey> = new Map();
+  private keyToGroupMap: Map<ConfigKey, ConfigKey>;
 
   constructor() {
-    this.initKeyToGroupMap();
-    this.init();
+    this.configLoader = new ConfigLoader();
+    this.keyToGroupMap = this.initKeyToGroupMap();
   }
 
-  private initKeyToGroupMap() {
+  private initKeyToGroupMap(): Map<ConfigKey, ConfigKey> {
+    const map = new Map<ConfigKey, ConfigKey>();
     for (const group of ENV_ONLY_GROUPS) {
       for (const targetKey of group.targetKeys) {
-        this.keyToGroupMap.set(targetKey, group.controlKey);
+        map.set(targetKey, group.controlKey);
       }
     }
+    return map;
   }
 
-  private async init() {
-    await this.loadConfigs();
-  }
-
-  private shouldUseEnvOnly(key: ConfigKey): boolean {
-    const controlKey = this.keyToGroupMap.get(key);
-    if (!controlKey) {
-      return false;
+  async loadConfigs(options?: { source?: ConfigSource }): Promise<void> {
+    if (options?.source === 'env') {
+      this.envConfig = await this.configLoader.loadFromEnv();
+    }
+    else if (options?.source === 'db') {
+      this.dbConfig = await this.configLoader.loadFromDB();
+    }
+    else {
+      this.envConfig = await this.configLoader.loadFromEnv();
+      this.dbConfig = await this.configLoader.loadFromDB();
     }
-    return this.getConfigValue(controlKey) === true;
-  }
-
-  async loadConfigs(): Promise<void> {
-    this.rawConfig = {
-      env: await this.configLoader.loadFromEnv(),
-      db: await this.configLoader.loadFromDB(),
-    };
 
-    this.mergedConfig = this.mergeConfigs(this.rawConfig);
     this.lastLoadedAt = new Date();
   }
 
-  /**
-   * Method to get configuration values with type inference
-   */
   getConfig<K extends ConfigKey>(key: K): ConfigValues[K] {
-    if (!this.mergedConfig || !this.rawConfig) {
+    if (!this.envConfig || !this.dbConfig) {
       throw new Error('Config is not loaded');
     }
 
-    // Since key is already constrained by K extends ConfigKey,
-    // additional type checks are unnecessary
     if (this.shouldUseEnvOnly(key)) {
-      const metadata = CONFIG_DEFINITIONS[key];
-      return (this.rawConfig.env[key] ?? metadata.defaultValue) as ConfigValues[K];
+      return this.envConfig[key] as ConfigValues[K];
     }
 
-    return this.mergedConfig[key].value;
+    return (this.dbConfig[key] ?? this.envConfig[key]) as ConfigValues[K];
   }
 
-  // Instead, prepare a validation method for public methods that are not type-safe
-  validateConfigKey(key: unknown): asserts key is ConfigKey {
-    if (!ConfigKeys.includes(key)) {
-      throw new Error(`Invalid config key: ${String(key)}`);
+  private shouldUseEnvOnly(key: ConfigKey): boolean {
+    const controlKey = this.keyToGroupMap.get(key);
+    if (!controlKey) {
+      return false;
     }
+    return this.getConfig(controlKey) === true;
   }
 
-  /**
-   * Method for receiving any string as a key
-   */
-  getConfigByKey(key: string): unknown {
-    this.validateConfigKey(key);
-    return this.getConfig(key);
-  }
+  async updateConfig<K extends ConfigKey>(key: K, value: ConfigValues[K], options?: UpdateConfigOptions): Promise<void> {
+    // Dynamic import to avoid loading database modules too early
+    const { Config } = await import('../../models/config');
 
-  /**
-   * Get raw config
-   */
-  getRawConfig(): RawConfigData | undefined {
-    return this.rawConfig;
-  }
-
-  /**
-   * Get merged config
-   */
-  getMergedConfig(): MergedConfigData | undefined {
-    return this.mergedConfig;
-  }
-
-  /**
-   * Type-safe configuration update
-   */
-  async updateConfig<K extends ConfigKey>(
-      key: K,
-      value: ConfigValues[K],
-      opts?: UpdateConfigOptions,
-  ): Promise<void> {
     await Config.updateOne(
       { key },
       { value: JSON.stringify(value) },
       { upsert: true },
     );
 
-    await this.loadConfigs();
+    await this.loadConfigs({ source: 'db' });
 
-    if (!opts?.skipPubsub) {
+    if (!options?.skipPubsub) {
       await this.publishUpdateMessage();
     }
   }
 
-  /**
-   * Bulk update of multiple type-safe configurations
-   */
-  async updateConfigs<K extends ConfigKey>(
-      updates: ConfigUpdates<K>,
-      opts?: UpdateConfigOptions,
-  ): Promise<void> {
+  async updateConfigs(updates: Partial<{ [K in ConfigKey]: ConfigValues[K] }>, options?: UpdateConfigOptions): Promise<void> {
+    // Dynamic import to avoid loading database modules too early
+    const { Config } = await import('../../models/config');
+
     const operations = Object.entries(updates).map(([key, value]) => ({
       updateOne: {
         filter: { key },
@@ -162,45 +108,17 @@ export class ConfigManager implements S2sMessageHandlable {
     }));
 
     await Config.bulkWrite(operations);
-    await this.loadConfigs();
+    await this.loadConfigs({ source: 'db' });
 
-    if (!opts?.skipPubsub) {
+    if (!options?.skipPubsub) {
       await this.publishUpdateMessage();
     }
   }
 
-  /**
-   * Update configuration that is not type-safe (accepts string keys)
-   */
-  async updateConfigByKey(
-      key: string,
-      value: unknown,
-  ): Promise<void> {
-    this.validateConfigKey(key);
-    await this.updateConfig(key, value as any);
-  }
+  async removeConfigs(keys: ConfigKey[], options?: UpdateConfigOptions): Promise<void> {
+    // Dynamic import to avoid loading database modules too early
+    const { Config } = await import('../../models/config');
 
-  /**
-   * Bulk update of multiple configurations that are not type-safe (accepts string keys)
-   */
-  async updateConfigsByKey(
-      updates: Record<string, unknown>,
-  ): Promise<void> {
-    // Validate all keys
-    for (const key of Object.keys(updates)) {
-      this.validateConfigKey(key);
-    }
-
-    await this.updateConfigs(updates as any);
-  }
-
-  /**
-   * Bulk update of multiple type-safe configurations
-   */
-  async removeConfigs<K extends ConfigKey>(
-      keys: K[],
-      opts?: UpdateConfigOptions,
-  ): Promise<void> {
     const operations = keys.map(key => ({
       deleteOne: {
         filter: { key },
@@ -208,38 +126,25 @@ export class ConfigManager implements S2sMessageHandlable {
     }));
 
     await Config.bulkWrite(operations);
-    await this.loadConfigs();
+    await this.loadConfigs({ source: 'db' });
 
-    if (!opts?.skipPubsub) {
+    if (!options?.skipPubsub) {
       await this.publishUpdateMessage();
     }
   }
 
-  /**
-   * Get value from merged configuration (for condition checks)
-   */
-  private getConfigValue<K extends ConfigKey>(key: K): ConfigValues[K] {
-    if (!this.mergedConfig) {
+  getRawConfigData(): {
+    env: Record<ConfigKey, ConfigValues[ConfigKey]>;
+    db: Record<ConfigKey, ConfigValues[ConfigKey] | null>;
+    } {
+    if (!this.envConfig || !this.dbConfig) {
       throw new Error('Config is not loaded');
     }
-    return this.mergedConfig[key].value;
-  }
-
-  private mergeConfigs(raw: RawConfigData): MergedConfigData {
-    const merged = {} as MergedConfigData;
-
-    for (const key of ConfigKeys.all) {
-      const metadata = CONFIG_DEFINITIONS[key];
-      const dbValue = raw.db[key];
-      const envValue = raw.env[key];
-
-      merged[key] = {
-        value: dbValue ?? envValue ?? metadata.defaultValue,
-        source: dbValue != null ? 'db' : 'env',
-      };
-    }
 
-    return merged;
+    return {
+      env: this.envConfig,
+      db: this.dbConfig,
+    };
   }
 
   /**
@@ -251,8 +156,9 @@ export class ConfigManager implements S2sMessageHandlable {
   }
 
   async publishUpdateMessage(): Promise<void> {
-    const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
+    const { default: S2sMessage } = await import('../../models/vo/s2s-message');
 
+    const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
     try {
       await this.s2sMessagingService?.publish(s2sMessage);
     }
@@ -269,7 +175,6 @@ export class ConfigManager implements S2sMessageHandlable {
     if (eventName !== 'configUpdated') {
       return false;
     }
-
     return this.lastLoadedAt == null // loaded for the first time
       || !('updatedAt' in s2sMessage) // updatedAt is not included in the message
       || (typeof s2sMessage.updatedAt === 'string' && this.lastLoadedAt < parseISO(s2sMessage.updatedAt));

+ 77 - 0
packages/core/src/interfaces/config-manager.ts

@@ -0,0 +1,77 @@
+/**
+ * Available configuration sources
+ */
+export const CONFIG_SOURCES = ['env', 'db'] as const;
+export type ConfigSource = typeof CONFIG_SOURCES[number];
+
+/**
+ * Metadata for a configuration value
+ */
+export interface ConfigDefinition<T> {
+  defaultValue: T;
+  envVarName?: string;
+  isSecret?: boolean;
+}
+
+/**
+ * Helper function for defining configurations with type safety
+ */
+export const defineConfig = <T>(config: ConfigDefinition<T>): ConfigDefinition<T> => config;
+
+/**
+ * Interface for loading configuration values
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export interface IConfigLoader<K extends string, V extends Record<K, any>> {
+  /**
+   * Load configurations from environment variables
+   */
+  loadFromEnv(): Promise<Record<K, V[K]>>;
+
+  /**
+   * Load configurations from database
+   */
+  loadFromDB(): Promise<Record<K, V[K] | null>>;
+}
+
+export type UpdateConfigOptions = { skipPubsub?: boolean };
+
+/**
+ * Interface for managing configuration values
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export interface IConfigManager<K extends string, V extends Record<K, any>> {
+  /**
+   * Load configurations
+   * @param options.source - Specify which source to load from
+   */
+  loadConfigs(options?: { source?: ConfigSource }): Promise<void>;
+
+  /**
+   * Get a configuration value
+   */
+  getConfig<T extends K>(key: T): V[T];
+
+  /**
+   * Update a configuration value
+   */
+  updateConfig<T extends K>(key: T, value: V[T], options?: UpdateConfigOptions): Promise<void>;
+
+  /**
+   * Update multiple configuration values
+   */
+  updateConfigs(updates: Partial<{ [T in K]: V[T] }>, options?: UpdateConfigOptions): Promise<void>;
+
+  /**
+   * Remove multiple configuration values
+   */
+  removeConfigs(keys: K[], options?: UpdateConfigOptions): Promise<void>;
+
+  /**
+   * Get raw configuration data for UI display
+   */
+  getRawConfigData(): {
+    env: Record<K, V[K]>;
+    db: Record<K, V[K] | null>;
+  };
+}

+ 1 - 0
packages/core/src/interfaces/index.ts

@@ -1,6 +1,7 @@
 export * from './attachment';
 export * from './color-scheme';
 export * from './color-scheme';
+export * from './config-manager';
 export * from './common';
 export * from './external-account';
 export * from './growi-facade';

Некоторые файлы не были показаны из-за большого количества измененных файлов