RocketChat/Rocket.Chat

View on GitHub
apps/meteor/app/settings/server/CachedSettings.ts

Summary

Maintainability
F
3 days
Test Coverage
import type { ISetting, SettingValue } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import _ from 'underscore';

import { SystemLogger } from '../../../server/lib/logger/system';

const warn = process.env.NODE_ENV === 'development' || process.env.TEST_MODE;

type SettingsConfig = {
    debounce: number;
};

type OverCustomSettingsConfig = Partial<SettingsConfig>;

export interface ICachedSettings {
    /*
     * @description: The settings object as ready
     */
    initialized(): void;

    has(_id: ISetting['_id']): boolean;

    getSetting(_id: ISetting['_id']): ISetting | undefined;

    get<T extends SettingValue = SettingValue>(_id: ISetting['_id']): T;

    getByRegexp<T extends SettingValue = SettingValue>(_id: RegExp): [string, T][];

    watchMultiple<T extends SettingValue = SettingValue>(_id: ISetting['_id'][], callback: (settings: T[]) => void): () => void;

    watch<T extends SettingValue = SettingValue>(_id: ISetting['_id'], cb: (args: T) => void, config?: OverCustomSettingsConfig): () => void;

    watchOnce<T extends SettingValue = SettingValue>(
        _id: ISetting['_id'],
        cb: (args: T) => void,
        config?: OverCustomSettingsConfig,
    ): () => void;

    change<T extends SettingValue = SettingValue>(
        _id: ISetting['_id'],
        callback: (args: T) => void,
        config?: OverCustomSettingsConfig,
    ): () => void;

    changeMultiple<T extends SettingValue = SettingValue>(
        _ids: ISetting['_id'][],
        callback: (settings: T[]) => void,
        config?: OverCustomSettingsConfig,
    ): () => void;

    changeOnce<T extends SettingValue = SettingValue>(
        _id: ISetting['_id'],
        callback: (args: T) => void,
        config?: OverCustomSettingsConfig,
    ): () => void;

    set(record: ISetting): void;

    getConfig(config?: OverCustomSettingsConfig): SettingsConfig;

    watchByRegex(regex: RegExp, cb: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void;

    changeByRegex(regex: RegExp, callback: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void;

    onReady(cb: () => void): void;
}

/**
 * Class responsible for setting up the settings, cache and propagation changes
 * Should be agnostic to the actual settings implementation, running on meteor or standalone
 *
 * You should not instantiate this class directly, only for testing purposes
 *
 * @extends Emitter
 * @alpha
 */
export class CachedSettings
    extends Emitter<
        {
            '*': [string, SettingValue];
        } & {
            ready: undefined;
            [k: string]: SettingValue;
        }
    >
    implements ICachedSettings
{
    ready = false;

    store = new Map<string, ISetting>();

    /**
     * The settings object as ready
     */
    initialized(): void {
        if (this.ready) {
            return;
        }
        this.ready = true;
        this.emit('ready');
        SystemLogger.debug('Settings initialized');
    }

    /**
     * returns if the setting is defined
     * @param _id - The setting id
     * @returns {boolean}
     */
    public has(_id: ISetting['_id']): boolean {
        if (!this.ready && warn) {
            SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`);
        }
        return this.store.has(_id);
    }

    /**
     * Gets the current Object of the setting
     * @param _id - The setting id
     * @returns {ISetting} - The current Object of the setting
     */
    public getSetting(_id: ISetting['_id']): ISetting | undefined {
        if (!this.ready && warn) {
            SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`);
        }
        return this.store.get(_id);
    }

    /**
     * Gets the current value of the setting
     * - In development mode if you are trying to get the value of a setting that is not defined, it will give an warning, in theory it makes sense, there no reason to do that
     * - The setting's value will be cached in memory so it won't call the DB every time you fetch a particular setting
     * @param _id - The setting id
     * @returns {SettingValue} - The current value of the setting
     */
    public get<T extends SettingValue = SettingValue>(_id: ISetting['_id']): T {
        if (!this.ready && warn) {
            SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`);
        }
        return this.store.get(_id)?.value as T;
    }

    /**
     * Gets the current value of the setting
     * - In development mode if you are trying to get the value of a setting that is not defined, it will give an warning, in theory it makes sense, there no reason to do that
     * @deprecated
     * @param _id - The setting id
     * @returns {SettingValue} - The current value of the setting
     */
    public getByRegexp<T extends SettingValue = SettingValue>(_id: RegExp): [string, T][] {
        if (!this.ready && warn) {
            SystemLogger.warn(`Settings not initialized yet. getting: ${_id}`);
        }

        return [...this.store.entries()].filter(([key]) => _id.test(key)).map(([key, setting]) => [key, setting.value]) as [string, T][];
    }

    /**
     * Get the current value of the settings, and keep track of changes
     * - This callback is debounced
     * - The callback is not fire until the settings got initialized
     * @param _ids - Array of setting id
     * @param callback - The callback to run
     * @returns {() => void} - A function that can be used to cancel the observe
     */
    public watchMultiple<T extends SettingValue = SettingValue>(_id: ISetting['_id'][], callback: (settings: T[]) => void): () => void {
        if (!this.ready) {
            const cancel = new Set<() => void>();

            cancel.add(
                this.once('ready', (): void => {
                    cancel.clear();
                    cancel.add(this.watchMultiple(_id, callback));
                }),
            );
            return (): void => {
                cancel.forEach((fn) => fn());
            };
        }

        if (_id.every((id) => this.store.has(id))) {
            const settings = _id.map((id) => this.store.get(id)?.value);
            callback(settings as T[]);
        }
        const mergeFunction = _.debounce((): void => {
            callback(_id.map((id) => this.store.get(id)?.value) as T[]);
        }, 100);

        const fns = _id.map((id) => this.on(id, mergeFunction));
        return (): void => {
            fns.forEach((fn) => fn());
        };
    }

    /**
     * Get the current value of the setting, and keep track of changes
     * - This callback is debounced
     * - The callback is not fire until the settings got initialized
     * @param _id - The setting id
     * @param callback - The callback to run
     * @returns {() => void} - A function that can be used to cancel the observe
     */
    public watch<T extends SettingValue = SettingValue>(
        _id: ISetting['_id'],
        cb: (args: T) => void,
        config?: OverCustomSettingsConfig,
    ): () => void {
        if (!this.ready) {
            const cancel = new Set<() => void>();
            cancel.add(
                this.once('ready', (): void => {
                    cancel.clear();
                    cancel.add(this.watch(_id, cb, config));
                }),
            );
            return (): void => {
                cancel.forEach((fn) => fn());
            };
        }

        this.store.has(_id) && cb(this.store.get(_id)?.value as T);
        return this.change(_id, cb, config);
    }

    /**
     * Get the current value of the setting, or wait until the initialized
     * - This is a one time run
     * - This callback is debounced
     * - The callback is not fire until the settings got initialized
     * @param _id - The setting id
     * @param callback - The callback to run
     * @returns {() => void} - A function that can be used to cancel the observe
     */
    public watchOnce<T extends SettingValue = SettingValue>(
        _id: ISetting['_id'],
        cb: (args: T) => void,
        config?: OverCustomSettingsConfig,
    ): () => void {
        if (this.store.has(_id)) {
            cb(this.store.get(_id)?.value as T);
            return (): void => undefined;
        }
        return this.changeOnce(_id, cb, config);
    }

    /**
     * Observes the given setting by id and keep track of changes
     * - This callback is debounced
     * - The callback is not fire until the setting is changed
     * - The callback is not fire until all the settings get initialized
     * @param _id - The setting id
     * @param callback - The callback to run
     * @returns {() => void} - A function that can be used to cancel the observe
     */
    public change<T extends SettingValue = SettingValue>(
        _id: ISetting['_id'],
        callback: (args: T) => void,
        config?: OverCustomSettingsConfig,
    ): () => void {
        const { debounce } = this.getConfig(config);
        return this.on(_id, _.debounce(callback, debounce) as any);
    }

    /**
     * Observes multiple settings and keep track of changes
     * - This callback is debounced
     * - The callback is not fire until the setting is changed
     * - The callback is not fire until all the settings get initialized
     * @param _ids - Array of setting id
     * @param callback - The callback to run
     * @returns {() => void} - A function that can be used to cancel the observe
     */
    public changeMultiple<T extends SettingValue = SettingValue>(
        _ids: ISetting['_id'][],
        callback: (settings: T[]) => void,
        config?: OverCustomSettingsConfig,
    ): () => void {
        const fns = _ids.map((id) =>
            this.change(
                id,
                (): void => {
                    callback(_ids.map((id) => this.store.get(id)?.value) as T[]);
                },
                config,
            ),
        );
        return (): void => {
            fns.forEach((fn) => fn());
        };
    }

    /**
     * Observes the setting and fires only if there is a change. Runs only once
     * - This is a one time run
     * - This callback is debounced
     * - The callback is not fire until the setting is changed
     * - The callback is not fire until all the settings get initialized
     * @param _id - The setting id
     * @param callback - The callback to run
     * @returns {() => void} - A function that can be used to cancel the observe
     */
    public changeOnce<T extends SettingValue = SettingValue>(
        _id: ISetting['_id'],
        callback: (args: T) => void,
        config?: OverCustomSettingsConfig,
    ): () => void {
        const { debounce } = this.getConfig(config);
        return this.once(_id, _.debounce(callback, debounce) as any);
    }

    /**
     * Sets the value of the setting
     * - if the value set is the same as the current value, the change will not be fired
     * - if the value is set before the initialization, the emit will be queued and will be fired after initialization
     * @param _id - The setting id
     * @param value - The value to set
     * @returns {void}
     */
    public set(record: ISetting): void {
        if (this.store.has(record._id) && this.store.get(record._id)?.value === record.value) {
            return;
        }

        this.store.set(record._id, record);
        if (!this.ready) {
            this.once('ready', () => {
                this.emit(record._id, this.store.get(record._id)?.value);
                this.emit('*', [record._id, this.store.get(record._id)?.value]);
            });
            return;
        }
        this.emit(record._id, this.store.get(record._id)?.value);
        this.emit('*', [record._id, this.store.get(record._id)?.value]);
    }

    public getConfig = (config?: OverCustomSettingsConfig): SettingsConfig => ({
        debounce: 500,
        ...config,
    });

    /** @deprecated */
    public watchByRegex(regex: RegExp, cb: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void {
        if (!this.ready) {
            const cancel = new Set<() => void>();
            cancel.add(
                this.once('ready', (): void => {
                    cancel.clear();
                    cancel.add(this.watchByRegex(regex, cb, config));
                }),
            );
            return (): void => {
                cancel.forEach((fn) => fn());
            };
        }
        [...this.store.entries()].forEach(([key, setting]) => {
            if (regex.test(key)) {
                cb(key, setting.value);
            }
        });

        return this.changeByRegex(regex, cb, config);
    }

    /** @deprecated */
    public changeByRegex(regex: RegExp, callback: (...args: [string, SettingValue]) => void, config?: OverCustomSettingsConfig): () => void {
        const store: Map<string, (...args: [string, SettingValue]) => void> = new Map();
        return this.on('*', ([_id, value]) => {
            if (regex.test(_id)) {
                const { debounce } = this.getConfig(config);
                const cb = store.get(_id) || _.debounce(callback, debounce);
                cb(_id, value);
                store.set(_id, cb);
            }
            regex.lastIndex = 0;
        });
    }

    /**
     * Wait until the settings get ready then run the callback
     */
    public onReady(cb: () => void): void {
        if (this.ready) {
            return cb();
        }
        this.once('ready', cb);
    }
}