Source: crdt/ObjectCRDT.ts

/**
 * @author Maximilian Kallert <max@refinio.net>
 * @author Eduard Reimer <eduard@refinio.net>
 * @copyright REFINIO GmbH 2020
 * @license CC-BY-NC-SA-2.5; portions MIT License
 * @version 1.0.0
 */

/**
 * This module implements the object CRDT, which in itself is no CRDT algorithm but
 * the toplevel object which resolves nested objects.
 * @module
 */

import {generateCrdtMetadataForRule, mergeCRDTDataForRule} from '../crdt';
import {generateMetaRecipeForRule} from '../crdt-recipes';
import {resolveRuleInheritance} from '../object-recipes';
import {ruleHasItemType} from '../util/type-checks';
import {isObject} from '../util/type-checks-basic';
import type {CRDTImplementation} from './CRDTImplementation';
import type {Recipe, RecipeRule} from '../recipes';

/**
 * Implementation of Object CRDT. This is a parent for the other CRDT implementations.
 *
 * See paper:
 * A comprehensive study of Convergent and Commutative Replicated Data Types
 * Marc Shapiro, Nuno PreguiƧa, Carlos Baquero, Marek Zawirski
 * p. 26, Section 3.3.5
 * https://hal.inria.fr/inria-00555588/document
 */
export class ObjectCRDT implements CRDTImplementation {
    generateRecipeRules(
        rule: RecipeRule,
        path: string,
        crdtConfig?: Recipe['crdtConfig']
    ): RecipeRule[] {
        const crdtRules: RecipeRule[] = [];

        if (!(ruleHasItemType(rule) && rule.itemtype.type === 'object')) {
            throw new Error(
                `Object parameter in type.object CRDT implementation is not an object ${JSON.stringify(
                    rule
                )}`
            );
        }

        for (const actualRule of rule.itemtype.rules) {
            const resolvedRule = resolveRuleInheritance(actualRule);

            const subrule: RecipeRule = {
                itemprop: actualRule.itemprop,
                itemtype: {
                    type: 'object',
                    rules: generateMetaRecipeForRule(resolvedRule, path, crdtConfig)
                }
            };
            crdtRules.push(subrule);
        }

        return crdtRules;
    }

    async generateMetaData(
        dataOld: unknown,
        dataNew: unknown,
        metadataOld: unknown,
        rule: RecipeRule,
        crdtConfig?: Recipe['crdtConfig']
    ): Promise<Record<string, any>> {
        const metaData: Record<string, any> = {};

        if (dataOld !== undefined && !isObject(dataOld)) {
            throw new Error(`Old data is neither undefined or an object: ${dataOld}`);
        }

        if (!isObject(dataNew)) {
            throw new Error(`New data is not an object: ${dataNew}`);
        }

        if (metadataOld !== undefined && !isObject(metadataOld)) {
            throw new Error(`Old metadata is neither undefined or an object: ${metadataOld}`);
        }

        if (!(ruleHasItemType(rule) && rule.itemtype.type === 'object')) {
            throw new Error(
                `Object parameter in type.object CRDT implementation is not an object ${JSON.stringify(
                    rule
                )}`
            );
        }

        const metaDataParts = await Promise.all(
            rule.itemtype.rules.map(async actualRule => {
                return await generateCrdtMetadataForRule(
                    actualRule,
                    dataNew,
                    dataOld,
                    metadataOld,
                    crdtConfig
                );
            })
        );

        // Build to object after all the promises are settled to ensure the same order of the props
        for (const metaDataPart of metaDataParts) {
            if (metaDataPart === undefined) {
                continue;
            }
            metaData[metaDataPart.itemprop] = metaDataPart.metaDataPart;
        }

        return metaData;
    }

    async mergeMetaData(
        objLatestVersion: unknown,
        metadataLatestVersion: unknown,
        objToMerge: unknown,
        metadataToMerge: unknown,
        rule: Readonly<RecipeRule>,
        crdtConfig?: Recipe['crdtConfig']
    ): Promise<{metadata: Record<string, any>; data: Record<string, any>}> {
        const objMerged: Record<string, any> = {};
        const metadataMerged: Record<string, any> = {};

        if (objLatestVersion !== undefined && !isObject(objLatestVersion)) {
            throw new Error(`Old data is neither undefined or an object: ${objLatestVersion}`);
        }

        if (metadataLatestVersion !== undefined && !isObject(metadataLatestVersion)) {
            throw new Error(
                `Old metadata is neither undefined or an object: ${metadataLatestVersion}`
            );
        }

        if (!isObject(objToMerge)) {
            throw new Error(`The object to merge is not an object: ${objToMerge}`);
        }

        if (!isObject(metadataToMerge)) {
            throw new Error(`The metadata to merge is not an object: ${metadataToMerge}`);
        }

        if (!(ruleHasItemType(rule) && rule.itemtype.type === 'object')) {
            throw new Error(
                `Object parameter in type.object CRDT implementation is not an object ${JSON.stringify(
                    rule
                )}`
            );
        }

        const mergeObjects = await Promise.all(
            rule.itemtype.rules.map(async actualRule => {
                return await mergeCRDTDataForRule(
                    objLatestVersion,
                    metadataLatestVersion,
                    objToMerge,
                    metadataToMerge,
                    actualRule,
                    crdtConfig
                );
            })
        );

        // Build to object after all the promises are settled to ensure the same order of the props
        for (const mergeObject of mergeObjects) {
            if (mergeObject === undefined) {
                continue;
            }
            objMerged[mergeObject.itemprop] = mergeObject.data;
            metadataMerged[mergeObject.itemprop] = mergeObject.metadata;
        }

        return {
            data: objMerged,
            metadata: metadataMerged
        };
    }
}