Source: jscore/DataBase.js

(function() {
    /**
     * retrieve configuration; when to update data;
     * see also {@link Help4.jscore.DataBase.RETRIEVE_KEYS}
     * @typedef {Object} Help4.jscore.DataBase.Retrieve
     * @property {boolean} onInitialize - retrieve data on initialize
     * @property {boolean} onUpdate - retrieve data on update
     * @property {boolean} onActivate - retrieve data on activate
     */

    /**
     * the actually managed data
     * @typedef {Object} Help4.jscore.DataBase.Data
     * @property {Help4.jscore.ControlBase.Types} type - data type
     * @property {boolean} [cleanup = false] - whether to clean the data on class destroy
     * @property {*} init - the init value
     * @property {Help4.jscore.DataBase.Retrieve} retrieve - when to update the data
     */

    /**
     * additional class configuration
     * @typedef {Object} Help4.jscore.DataBase.Config
     * @property {Function} [onPropertyChange] - sync listener for property change events
     */

    /**
     * @typedef {Help4.jscore.ControlBase.Params} Help4.jscore.DataBase.Params
     * @property {Object} [data] - data parameters; each is of type {@link Help4.jscore.DataBase.Data}
     * @property {Object} [retrieve] - how to update the data: {<key>: async function getValueForKey}
     * @property {Help4.jscore.DataBase.Config} [config] - class configuration
     */

    /**
     * to be used as a base class for data controls
     * @augments Help4.jscore.ControlBase
     * @abstract
     */
    Help4.jscore.DataBase = class extends Help4.jscore.ControlBase {
        /**
         * @override
         * @param {Object} [params]
         * @param {Help4.jscore.DataBase.Params} [classConfig]
         */
        constructor(params, classConfig) {
            super(params, classConfig, true);

            const {RETRIEVE_KEY} = this.constructor;
            classConfig = this.____getClassConfig();
            classConfig[RETRIEVE_KEY] = {};

            this.____resolveDerivedParams(classConfig);
            this.____defineProperties(params || {});
            this.____defineStaticProperties();
            this.____defineDataFunctions();
        }

        static DATA_KEYS = '_data_keys';
        static RETRIEVE_KEY = '_retrieve';

        /**
         * @memberof Help4.jscore.DataBase
         * @enum {string}
         * @property {'initialize'} initialize
         * @property {'update'} update
         * @property {'activate'} activate
         */
        static RETRIEVE_KEYS = {
            initialize: 'initialize',
            update: 'update',
            activate: 'activate'
        }

        /** destroys this instance */
        destroy() {
            const classConfig = this.____getClassConfig();
            for (const [key, info] of Object.entries(classConfig?.data || {})) {
                const item = this[key];  // prevent multiple getter calls
                if (item instanceof Help4.jscore.DataContainer || info.cleanup) item.destroy?.();
                delete this[key];
            }

            super.destroy();
        }

        /** cleans all stored data */
        clean() {
            const {RETRIEVE_KEY} = this.constructor;
            const rk = this.____getClassConfig()[RETRIEVE_KEY];
            const ts = new Date().getTime();

            // discard any upcoming result that has been started before clean()
            // see _retrieve() for more information
            for (const [id, timestamp] of Object.entries(rk)) {
                // ignore all entries made by _doRetrieve()
                if (typeof timestamp !== 'boolean') {
                    if (timestamp <= ts) delete rk[id];
                }
            }
        }

        /**
         * update data on initialize
         * returns {Promise<void>}
         */
        initialize() {
            return _doRetrieve.call(this, 'onInitialize');
        }

        /**
         * update data on update
         * @returns {Promise<void>}
         */
        update() {
            return _doRetrieve.call(this, 'onUpdate');
        }

        /**
         * update data on activate
         * @returns {Promise<void>}
         */
        activate() {
            return _doRetrieve.call(this, 'onActivate');
        }

        /**
         * called after a retrieve operation completed; retrieve will await this function
         * @param {string} key - the value that has changed
         * @returns {Promise<void>}
         * @protected
         */
        async _onRetrieveReady(key) {}

        /**
         * called after a retrieve operation completed
         * @param {string} key - the value that has changed
         * @protected
         */
        _onAfterRetrieve(key) {}

        /**
         * allows to iterate the existing data
         * @generator
         * @yields [number, *]}
         */
        *entries() {
            const classConfig = this.____getClassConfig();
            const data = this.____getData();
            const keys = Object.keys(classConfig?.data || {});
            for (const key of keys) {
                yield [key, data[key]];
            }
        }

        /**
         * @override
         * @param {Help4.jscore.DataBase.Params} level0
         * @param {string} type
         * @param {*} value
         */
        ____mergeDerivedParam(level0, type, value) {
            switch (type) {
                case 'data':  // for Help4.jscore.DataBase
                    level0[type] = this.____extendParams(level0[type] || {}, value);
                    break;
                case 'config':
                case 'retrieve':  // for Help4.jscore.DataBase
                    level0[type] = Help4.extendObject(level0[type] || {}, value);
                    break;
                default:
                    super.____mergeDerivedParam(level0, type, value);
                    break;
            }
        }

        /**
         * @override
         * @param {Object} params
         * @throws {Error}
         * @protected
         */
        ____defineProperties(params) {
            const {DATA_KEYS, CUSTOM_TYPE, GET, SET} =  this.constructor;
            super.____defineProperties(params, {GET, SET});

            const classConfig = this.____getClassConfig();
            const dataKeys = classConfig[DATA_KEYS] = Object.keys(classConfig.data || {});

            for (const key of dataKeys) {
                if (this.hasOwnProperty(key)) throw new Error(`Property "${key}" already defined!`);

                const info = classConfig.data[key];
                if (info.hasOwnProperty('private')) throw new Error(`"private" not allowed for data property "${key}"!`);
                if (info.hasOwnProperty('readonly')) throw new Error(`"readonly" not allowed for data property "${key}"!`);
                if (info.hasOwnProperty('mandatory')) throw new Error(`"mandatory" not allowed for data property "${key}"!`);

                const {type, get, set} = info;
                if (!type) throw new Error(`Property "${key}" need to define a type!`);

                const funGet = type === CUSTOM_TYPE ? get : GET[type];
                const funSet = type === CUSTOM_TYPE ? set : SET[type];

                if (funGet && funSet) {
                    this.____defineProperty({type: 'data', key, info, funGet, funSet});
                } else {
                    throw new Error(`Invalid type "${type}" for property or missing getter or setter!`);
                }

                this.____setInitValue({key, info, params, funSet});
            }
        }

        /**
         * @override
         * @param {Object} args
         * @param {string} args.key
         * @param {Help4.jscore.ControlBase.Param} args.info
         * @param {Object} args.params
         * @param {Help4.jscore.ControlBase.FunSet} args.funSet
         * @protected
         */
        ____setInitValue({key, info, params, funSet}) {
            const data = this.____getData();
            const {TYPES} = this.constructor;

            if (!data.hasOwnProperty(key) && info.type === TYPES.dataContainer) {
                data[key] = _createDataContainer.call(this, key, info);
            } else {
                super.____setInitValue({key, info, params, funSet});
            }
        }
    }

    /**
     * @memberof Help4.jscore.DataBase#
     * @param {string} key
     * @param {Help4.jscore.ControlBase.Param} info
     * @returns {Help4.jscore.DataContainer}
     * @private
     */
    function _createDataContainer(key, info) {
        const container = new Help4.jscore.DataContainer(info.init);

        // observe item data changes
        container.onChange.addListener(event => {
            /** @type {Help4.jscore.ControlBase.PropertyChangeEvent} */
            event = {
                target: this,
                name: key,
                value: event.value,
                oldValue: event.oldValue
            };

            this.____getClassConfig().config?.onPropertyChange?.(event);  // sync!
            this.dataFunctions.onChange.onEvent(event);  // async!
        });

        return container;
    }

    /**
     * @memberof Help4.jscore.DataBase#
     * @param {string} mode
     * @returns {Promise<void>}
     * @private
     */
    async function _doRetrieve(mode) {
        const {RETRIEVE_KEY} = this.constructor;

        const key = `_${mode}`;
        const rk = this.____getClassConfig()[RETRIEVE_KEY];
        if (rk[key]) this.clean();  // an old job is still running; end it
        rk[key] = true;

        await _retrieve.call(this, mode);
        delete rk[key];
    }

    /**
     * @memberof Help4.jscore.DataBase#
     * @param {string} mode
     * @returns {Promise<void>}
     * @private
     */
    async function _retrieve(mode) {
        const {RETRIEVE_KEY} = this.constructor;
        const classConfig = this.____getClassConfig();

        const rk = classConfig[RETRIEVE_KEY];
        const id = Help4.createId();
        rk[id] = new Date().getTime();  // allow clean() to discard all not-yet collected data

        function *generateFromRetrieve() {
            const {data, retrieve} = classConfig;
            for (const [key, {retrieve: r}] of Object.entries(data)) {
                if (r?.[mode] && retrieve[key]) {
                    yield {promise: retrieve[key](), key};

                    // clean() has been called; stop execution
                    if (!rk[id]) return;
                }
            }
        }

        const {map} = await Help4.awaitPromises(generateFromRetrieve);

        // awaitPromises is async; clean() could have been called in between
        // only use this value in case our timestamp still exists
        if (rk[id] && !this.isDestroyed()) {
            delete rk[id];

            const dataConfig = this.____getClassConfig()['data'];
            const {TYPES: {dataContainer}} = this.constructor;
            const promises = /** @type {Array<Promise<void>>} */ [];

            Object.keys(map).forEach(key => {
                /** @type {Help4.jscore.DataBase.Data} */
                const config = dataConfig[key];
                const value = map[key];

                if (config.type === dataContainer) {
                    // do not use setter for dataContainer
                    const container = this[key];
                    value
                        ? container.set(...value)
                        : container.clean();
                } else {
                    this[key] = value;
                }

                promises.push(this._onRetrieveReady(key));
                this._onAfterRetrieve(key);
            });

            await Help4.Promise.all(promises);
        }
    }
})();