Source: crdt.ts

/**
 * @author Michael Hasenstein <hasenstein@yahoo.com>
 * @author Sebastian Șandru <sebastian@refinio.net>
 * @author Maximilian Kallert <max@refinio.net>
 * @copyright REFINIO GmbH 2021
 * @license CC-BY-NC-SA-2.5; portions MIT License
 * @version 0.0.1
 */

/**
 * The main module for CRDTs:
 * * Read and write CRDT objects
 * * Generate CRDT metadata
 * * merge CRDTs
 * * how to resolve references
 * @module
 */

import type {OneCrdtToMetaObjectInterfaces} from '@OneObjectInterfaces';

import {
    adjustCrdtConfig,
    getCrdtMetaRecipeName,
    isCrdtMetaObjectForCrdtObjectTypeName,
    isCrdtMetaObjectTypeNameForCrdtObjectTypeName,
    isCrdtTypeName
} from './crdt-recipes';
import {getCrdtImplementation} from './crdt/CRDTRegistry';
import {createError} from './errors';
import {parseHeader} from './microdata-to-object';
import {getRecipe, isVersionedObjectType, resolveRuleInheritance} from './object-recipes';
import type {
    CRDTMetaData,
    MetaObjectList,
    OneCrdtMetaObjectTypes,
    OneCrdtObjectTypeNames,
    OneCrdtObjectTypes,
    OneObjectTypeNames,
    OneObjectTypes,
    OneUnversionedObjectTypes,
    Recipe,
    RecipeRule
} from './recipes';
import {CREATION_STATUS} from './storage-base-common';
import {getObject, getObjectWithType, storeUnversionedObject} from './storage-unversioned-objects';
import type {VersionedObjectResult} from './storage-versioned-objects';
import {
    getObjectByIdHash,
    getObjectByIdObj,
    storeVersionedObject,
    storeVersionedObjectOmitVersionMap,
    versionedObjEvent
} from './storage-versioned-objects';
import {readUTF8TextFile} from './system/storage-base';
import {iterateArrayFromEnd} from './util/function';
import {calculateIdHashOfObj, MICRODATA_START} from './util/object';
import {serializeWithType} from './util/promise';
import type {SHA256Hash, SHA256IdHash} from './util/type-checks';
import {ensureHash, isListItemType, ruleHasItemType} from './util/type-checks';
import {isObject} from './util/type-checks-basic';
import {createVersionMapOrAppendEntry, VERSION_UPDATES} from './version-map-updater';

const DUMMY_RULE_ITEMPROP = 'DUMMY';

/**
 * This stores a new version for a crdt based data type.
 *
 * Functionality:
 * 1) A diff is calculated between ob and objBase.
 * 2) The metadata is updated based on the changes. The details depend on the CRDT
 * implementation. For a set a list of add / remove objects might be generated, or for a
 * LWWRegister the current time might be recorded for the new value ...
 * 3) This metadata is then merged into the latest version generating a new version for the
 * versioned object.
 *
 * The interface differs from the normal storeVersionedObject only by one additional
 * parameter: objBase. For most CRDTs we need to calculate the changes that
 * have happened since the last version. So you have to specify on which version
 * you based your new object on.
 * We can't just use the latest version that is stored, because other changes might have arrived
 * via chum in the meantime.
 *
 * @param {T} obj
 * @param {SHA256Hash<T>} objBase
 * @returns {Promise<VersionedObjectResult<T>>}
 */
export async function storeVersionedObjectCRDT<T extends OneCrdtObjectTypes>(
    obj: T,
    objBase?: SHA256Hash<T>
): Promise<VersionedObjectResult<T>> {
    // Calculate the new metadata by
    // 1) grabbing the old metadata (from objBase)
    // 2) update the metadata by using the diff between obj and objBase
    // TODO: Perhaps it is enough not return the whole metadata, but just the
    //       operations that are required to do the diff.
    //       The next merge step then will integrate it.
    const metaData = await generateCrdtMetaData(obj, objBase);

    const {hash} = await storeVersionedObjectOmitVersionMap(obj);

    return mergeCRDTData(
        obj,
        {
            $type$: getCrdtMetaRecipeName<T['$type$']>(obj.$type$),
            data: hash,
            crdt: metaData
        } as CRDTMetaData<T>,
        'local'
    );
}

/**
 * Generate a new version of the versioned object by merging the passed information.
 * The data is referenced by the metadata object, so effectively you pass in metadata and data.
 * @param {CRDTMetaData<T.$type$>} metadata
 * @returns {Promise<VersionedObjectResult<T>>}
 */
export async function storeVersionedObjectCRDTMergeOnly<T extends OneCrdtObjectTypes>(
    metadata: CRDTMetaData<T>
): Promise<VersionedObjectResult<T>> {
    const obj = await getObject(metadata.data);
    return mergeCRDTData(obj, metadata, 'remote');
}

// ######## CRDT METADATA STEP ########

/**
 * This generates / updates metadata for all crdt variables.
 *
 * This function diffs the obj against the base object in order to see what data was added /
 * removed from the object. Then it computes new metadata that then can be merged into the
 * versioned object with the mergeCRDTData call.
 *
 * Note: This call does not write anything at the moment. It just gets the base version,
 * compares it to the new version and returns the metadata that reflects this diff.
 *
 * @param {T} obj - The new version of the object that shall be written
 * @param {SHA256Hash<T>} objBaseHash - The base object. It is used to see what changes were
 * made in the original object.
 * @returns {Promise<unknown>} - This
 */
async function generateCrdtMetaData<T extends OneCrdtObjectTypes>(
    obj: T,
    objBaseHash?: SHA256Hash<T>
): Promise<Record<string, any>> {
    // Step 1: Get the crdt, metadata object and the recipe
    const objBaseData = objBaseHash
        ? await getCrdtAndMetaObject(objBaseHash)
        : {
              data: undefined,
              metadata: {
                  crdt: undefined
              }
          };
    const recipe = getRecipe(obj.$type$);

    // Step 2: Iterate the object / recipe and then call the crdt implementations
    const objectCRDT = getCrdtImplementation('object');

    const metadata = await objectCRDT.generateMetaData(
        objBaseData.data,
        obj,
        objBaseData.metadata.crdt,
        {
            itemprop: DUMMY_RULE_ITEMPROP,
            itemtype: {type: 'object', rules: recipe.rule}
        },
        recipe.crdtConfig
    );

    if (!isObject(metadata)) {
        throw new Error(`Object CRDT generated metadata is not an object: ${metadata}`);
    }

    return metadata;
}

/**
 * Generate the metadata for the given rule. If case, it is recursive for unversioned reference
 * objects.
 * @param {RecipeRule} rule
 * @param {Record<string, any>} obj
 * @param {Record<string, any>} [objBase]
 * @param {Record<string, any>} [metadataBase]
 * @param {Recipe.crdtConfig} [crdtConfig]
 * @returns {Promise<undefined|{itemprop:string, metaDataPart:*}>}
 */
export async function generateCrdtMetadataForRule(
    rule: RecipeRule,
    obj: Record<string, any>,
    objBase?: Record<string, any>,
    metadataBase?: Record<string, any>,
    crdtConfig?: Recipe['crdtConfig']
): Promise<undefined | ReturnType<typeof generateMetadataForListRule>> {
    const actualRule = resolveRuleInheritance(rule);
    const adjustedCrdtConfig = adjustCrdtConfig(rule, crdtConfig);

    const objChild = obj && obj[actualRule.itemprop];
    const objBaseChild = objBase && objBase[actualRule.itemprop];
    const metadataBaseChild = metadataBase && metadataBase[actualRule.itemprop];

    // If the objects don't have data, then we need to skip this rule.
    // If we don't stop we might have an endless loop, because rules can be recursive.
    if (objChild === undefined && objBaseChild === undefined) {
        return;
    }

    if (ruleHasItemType(actualRule)) {
        // Handle lists by choosing the default list crdt
        if (isListItemType(actualRule.itemtype)) {
            return await generateMetadataForListRule(
                actualRule,
                objBaseChild,
                objChild,
                metadataBaseChild,
                adjustedCrdtConfig
            );
        } else if (actualRule.itemtype.type === 'object') {
            // Handle objects
            const objectCRDT = getCrdtImplementation('object');

            const metaDataPart = await objectCRDT.generateMetaData(
                objBaseChild,
                objChild,
                metadataBaseChild,
                actualRule,
                adjustedCrdtConfig
            );

            return {itemprop: actualRule.itemprop, metaDataPart};
        } else if (
            actualRule.itemtype.type === 'referenceToObj' &&
            'allowedTypes' in actualRule.itemtype &&
            actualRule.itemtype.allowedTypes !== undefined
        ) {
            // Handle links to objects
            return await generateMetadataForRuleWithReferenceToObj(
                actualRule,
                objBaseChild,
                objChild,
                metadataBaseChild,
                adjustedCrdtConfig
            );
        }
    }

    // Handle normal types, id links CLOB and BLOB types as register
    const registerCrdt = getCrdtImplementation(
        'register',
        crdtConfig ? crdtConfig.get(rule.itemprop) : undefined
    );

    const metaDataPart = await registerCrdt.generateMetaData(
        objBaseChild,
        objChild,
        metadataBaseChild,
        actualRule
    );

    return {itemprop: actualRule.itemprop, metaDataPart};
}

// ######## CRDT MERGE STEP ########

/**
 * Merges an object with CRDT Annotations into a versioned object
 *
 * This will generate a new version of the versioned object (if necessary).
 *
 * @param {T} obj
 * @param {CRDTMetaData<T.$type$>} metadata
 * @param {"local" | "remote"} source
 * @returns {Promise<VersionedObjectResult<T>>}
 */
async function mergeCRDTData<T extends OneCrdtObjectTypes>(
    obj: T,
    metadata: CRDTMetaData<T>,
    source?: 'local' | 'remote'
): Promise<VersionedObjectResult<T>> {
    const versionIdHash = await calculateIdHashOfObj(obj);

    return serializeWithType(`crdtmerge_${versionIdHash}`, async () => {
        let latestVersion;

        try {
            latestVersion = await getCrdtAndMetaObjectByIdHash(versionIdHash);
        } catch (ignore) {
            latestVersion = {
                data: undefined,
                metadata: {
                    $type$: metadata.$type$,
                    crdt: undefined,
                    data: undefined
                }
            };
        }

        const recipe = getRecipe(obj.$type$);

        const objectCRDT = getCrdtImplementation('object');

        if (objectCRDT === undefined) {
            throw new Error('Object CRDT implementation not found.');
        }

        const mergedData = await objectCRDT.mergeMetaData(
            latestVersion.data,
            latestVersion.metadata.crdt,
            obj,
            metadata.crdt,
            {
                itemprop: DUMMY_RULE_ITEMPROP,
                itemtype: {type: 'object', rules: recipe.rule}
            },
            recipe.crdtConfig
        );

        if (!isObject(mergedData) || !isObject(mergedData.data) || !isObject(mergedData.metadata)) {
            throw new Error(`Object CRDT merged metadata result is invalid: ${mergedData}`);
        }

        (mergedData.data as T).$type$ = obj.$type$;

        const objResult = await storeVersionedObjectOmitVersionMap(mergedData.data as T);

        return storeCrdtAndMetaObject({
            $type$: getCrdtMetaRecipeName<T['$type$']>(obj.$type$),
            data: objResult.hash,
            crdt: mergedData.metadata,
            source: source
        });
    });
}

/**
 * Merge CRDT data for given rule. If case, it is recursive for unversioned referenced objects.
 * @param {Record<string, any> | undefined} objLatestVersion
 * @param {Record<string, any> | undefined} metadataLatestVersion
 * @param {Record<string, any> | undefined} objToMerge
 * @param {Record<string, any> | undefined} metadataToMerge
 * @param {RecipeRule} rule
 * @param {Recipe.crdtConfig} crdtConfig
 * @returns {Promise<undefined|{itemprop:string, data:*, metadata:*}>}
 */
export async function mergeCRDTDataForRule(
    objLatestVersion: Record<string, any> | undefined,
    metadataLatestVersion: Record<string, any> | undefined,
    objToMerge: Record<string, any> | undefined,
    metadataToMerge: Record<string, any> | undefined,
    rule: RecipeRule,
    crdtConfig?: Recipe['crdtConfig']
): Promise<undefined | ReturnType<typeof mergeCRDTDataForListRule>> {
    const actualRule = resolveRuleInheritance(rule);
    const adjustedCrdtConfig = adjustCrdtConfig(rule, crdtConfig);

    const objLatestVersionChild = objLatestVersion && objLatestVersion[actualRule.itemprop];
    const metadataLatestVersionChild =
        metadataLatestVersion && metadataLatestVersion[actualRule.itemprop];
    const objToMergeChild = objToMerge && objToMerge[actualRule.itemprop];
    const metadataToMergeChild = metadataToMerge && metadataToMerge[actualRule.itemprop];

    // If the objects don't have data, then we need to skip this rule.
    // If we don't stop we might have an endless loop, because rules can be recursive.
    if (objLatestVersionChild === undefined && objToMergeChild === undefined) {
        return;
    }

    // Handle lists by choosing the default list crdt
    if (ruleHasItemType(rule) && isListItemType(rule.itemtype)) {
        return await mergeCRDTDataForListRule(
            actualRule,
            objLatestVersionChild,
            metadataLatestVersionChild,
            objToMergeChild,
            metadataToMergeChild,
            adjustedCrdtConfig
        );
    } else if (ruleHasItemType(rule) && rule.itemtype.type === 'object') {
        // Handle child rules by handling members individually
        const objectCRDT = getCrdtImplementation('object');

        const mergeData = await objectCRDT.mergeMetaData(
            objLatestVersionChild,
            metadataLatestVersionChild,
            objToMergeChild,
            metadataToMergeChild,
            actualRule,
            adjustedCrdtConfig
        );

        return {
            itemprop: actualRule.itemprop,
            data: mergeData.data,
            metadata: mergeData.metadata
        };

        // eslint-disable-next-line no-negated-condition
    } else if (
        ruleHasItemType(actualRule) &&
        'allowedTypes' in actualRule.itemtype &&
        actualRule.itemtype.allowedTypes !== undefined &&
        actualRule.itemtype.type === 'referenceToObj'
    ) {
        // Handle links to objects
        return await mergeCRDTDataForRuleWithReferenceToObj(
            actualRule,
            objLatestVersionChild,
            metadataLatestVersionChild,
            metadataToMergeChild,
            objToMergeChild,
            adjustedCrdtConfig
        );
    }

    // Handle normal types, id links CLOB and BLOB types as register
    const registerCrdt = getCrdtImplementation(
        'register',
        adjustedCrdtConfig ? adjustedCrdtConfig.get(rule.itemprop) : undefined
    );

    const mergeData = await registerCrdt.mergeMetaData(
        objLatestVersionChild,
        metadataLatestVersionChild,
        objToMergeChild,
        metadataToMergeChild,
        actualRule
    );

    return {
        itemprop: actualRule.itemprop,
        data: mergeData.data,
        metadata: mergeData.metadata
    };
}

// ######## Low level get / store of object and metadata object ########

/**
 * Loads the latest data and metadata object for the versioned object.
 *
 * @param {SHA256IdHash<T>} idHash
 * @returns {{data: T, metadata: CRDTMetaData<T>}}
 */
async function getCrdtAndMetaObjectByIdHash<T extends OneCrdtObjectTypes>(
    idHash: SHA256IdHash<T>
): Promise<{data: T; metadata: OneCrdtToMetaObjectInterfaces[T['$type$']]}> {
    const latestObject = await getObjectByIdHash(idHash);
    return {
        data: latestObject.obj,
        metadata: await getCrdtMetaObjectWithType(latestObject.hash, latestObject.obj.$type$)
    };
}

/**
 * Loads the latest data and metadata object for the versioned object.
 * @template T
 * @param {SHA256Hash<T>} hash - The hash of a specific object version for which to load data
 * and metadata.
 * @returns {{data: T, metadata: unknown}}
 */
async function getCrdtAndMetaObject<T extends OneCrdtObjectTypes>(
    hash: SHA256Hash<T>
): Promise<{data: T; metadata: OneCrdtToMetaObjectInterfaces[T['$type$']]}> {
    const requestedObject = await getObject(hash);
    return {
        data: requestedObject,
        metadata: await getCrdtMetaObjectWithType(hash, requestedObject.$type$)
    };
}

/**
 * Obtain the meta-object of a certain type for the passed object hash.
 * @param {SHA256Hash<T>} hash
 * @param {OneCrdtObjectTypeNames} type
 * @returns {Promise<OneUnversionedObjectTypes>}
 */
export async function getCrdtMetaObjectWithType<T extends OneCrdtObjectTypes>(
    hash: SHA256Hash<T>,
    type: T['$type$']
): Promise<OneCrdtToMetaObjectInterfaces[T['$type$']]> {
    const metaObjectList = await getObjectByIdObj({
        $type$: 'MetaObjectList',
        object: hash
    });

    for (const metaObject of iterateArrayFromEnd(metaObjectList.obj.metaobject)) {
        // We don't have to load all objects. In 99% of the time the last object is the correct one.
        // For the 1% we need to loop.
        // eslint-disable-next-line no-await-in-loop
        const metaObj = await getObject(metaObject);

        // I am not sure wy the explicit generic argument is required. I guess this has
        // something to do with getObjectByIdObj not resolving T of latestObject properly.
        if (isCrdtMetaObjectForCrdtObjectTypeName(metaObj, type)) {
            return metaObj;
        }
    }

    throw new Error(`No metadata exists for the specified version. The data structure is
         corrupt!. Hash: ${hash} Type: ${type}`);
}

/**
 * Obtain the meta-object for the passed object hash.
 * @param {SHA256Hash<T>} hash
 * @param {OneCrdtObjectTypeNames} type
 * @returns {Promise<OneCrdtMetaObjectTypes>}
 */
export async function getCrdtMetaObject(
    hash: SHA256Hash<OneCrdtObjectTypes>,
    type: OneCrdtObjectTypeNames
): Promise<OneCrdtMetaObjectTypes> {
    const metaObjectList = await getObjectByIdObj({
        $type$: 'MetaObjectList',
        object: hash
    });

    for (const metaObject of iterateArrayFromEnd(metaObjectList.obj.metaobject)) {
        // We don't have to load all objects. In 99% of the time the last object is the correct one.
        // For the 1% we need to loop.
        // eslint-disable-next-line no-await-in-loop
        const metaObj = await getObject(metaObject);

        // I am not sure wy the explicit generic argument is required. I guess this has
        // something to do with getObjectByIdObj not resolving T of latestObject properly.
        if (isCrdtMetaObjectForCrdtObjectTypeName(metaObj, type)) {
            return metaObj;
        }
    }

    throw new Error(
        `No metadata exists for the specified version. The data structure is corrupt!
        Hash: ${hash} Type: ${type}`
    );
}

/**
 * Obtain the meta-object for the passed object hash.
 * @param {SHA256Hash<T>} hash
 * @param {OneCrdtObjectTypeNames} type
 * @returns {Promise<{microdata:string, hash:SHA256Hash<OneCrdtMetaObjectTypes>}>}
 */
export async function getCrdtMetaObjectAsMicrodata(
    hash: SHA256Hash<OneCrdtObjectTypes>,
    type: OneCrdtObjectTypeNames
): Promise<{microdata: string; hash: SHA256Hash<OneCrdtMetaObjectTypes>}> {
    const metaObjectList = await getObjectByIdObj({
        $type$: 'MetaObjectList',
        object: hash
    });

    for (const metaobject of iterateArrayFromEnd(metaObjectList.obj.metaobject)) {
        // Read the file to get the type
        // It is okay to do this in a loop, because in 99% of time the first entry
        // will be the desired file.
        // eslint-disable-next-line no-await-in-loop
        const microdata = await readUTF8TextFile(metaobject);

        if (!microdata.startsWith(MICRODATA_START)) {
            throw createError('CRDT-GCMOAM001', {hash});
        }

        // Get the tye from file
        const metaType = parseHeader(undefined, {
            html: microdata,
            isIdObj: false,
            position: 0
        });

        if (!isCrdtTypeName(type)) {
            throw createError('CRDT-GCMOAM002', {hash});
        }

        // I am not sure wy the explicit generic argument is required. I guess this has
        // something to do with getObjectByIdObj not resolving T of latestObject properly.
        if (isCrdtMetaObjectTypeNameForCrdtObjectTypeName(metaType, type)) {
            return {
                microdata: microdata,
                hash: metaobject as SHA256Hash<OneCrdtMetaObjectTypes>
            };
        }
    }

    throw new Error(
        `No metadata exists for the specified version. The data structure is corrupt!
        Hash: ${hash} Type: ${type}`
    );
}

/**
 * Stores a new version of the data and metadata.
 *
 * The data is not part of the interface because we expect the data object already be written to
 * disk - but without being recorded in the version map. This can be achieved with the call
 * storeVersionedObjectOmitVersionMap.
 *
 * @param {CRDTMetaData<T.$type$>} metadata
 * @returns {Promise<VersionedObjectResult<T>>}
 */
async function storeCrdtAndMetaObject<T extends OneCrdtObjectTypes>(
    metadata: CRDTMetaData<T>
): Promise<VersionedObjectResult<T>> {
    // TODO: check this 'as' conversion again.
    const metadataResult = await storeUnversionedObject(
        metadata as unknown as OneCrdtToMetaObjectInterfaces[T['$type$']]
    );

    // Generate the metadata object list that links the metadata
    let newMetaObjectList: MetaObjectList;

    try {
        newMetaObjectList = (
            await getObjectByIdObj({
                $type$: 'MetaObjectList',
                object: metadata.data
            })
        ).obj;

        newMetaObjectList.metaobject.push(metadataResult.hash);
    } catch (ignore) {
        newMetaObjectList = {
            $type$: 'MetaObjectList',
            object: metadata.data,
            metaobject: [metadataResult.hash]
        };
    }

    // Create a new version for the metadata and after that for the data.
    // The order is important, because if the code is interrupted, and you
    // wrote the metadata after the data itself, then we might have a version
    // without metadata which will corrupt the CRDTs.
    await storeVersionedObject(newMetaObjectList);

    // Create a new version for the data.
    // The object already exists, though it is not part of the version map!
    // TODO: There are two ways to make it more efficient
    //       1) Pass the object as argument to this function (bad: caller might make an error)
    //       2) Implement a storeVersionedObject function that can accept a hash instead of an
    //          object.
    const obj = await getObject(metadata.data);
    const objId = await calculateIdHashOfObj(obj);

    const vmapResult = await createVersionMapOrAppendEntry(
        objId,
        metadata.data,
        CREATION_STATUS.NEW,
        VERSION_UPDATES.NONE_IF_LATEST
    );

    const objCreationResult = {
        obj,
        status: vmapResult.status,
        timestamp: vmapResult.timestamp,
        hash: metadata.data,
        idHash: objId as any // TODO THIS TS ERROR IS SO USELESS!
    };

    versionedObjEvent.dispatch(objCreationResult);

    return objCreationResult;
}

/**
 * Generate the metadata object for the given list rule.
 * @param {RecipeRule} rule
 * @param {unknown} objBaseChild
 * @param {unknown} objChild
 * @param {unknown} metadataBaseChild
 * @param {Recipe.crdtConfig} crdtConfig
 * @returns {{itemprop: string, metaDataPart: unknown}}
 */
async function generateMetadataForListRule(
    rule: RecipeRule,
    objBaseChild: unknown,
    objChild: unknown,
    metadataBaseChild: unknown,
    crdtConfig?: Recipe['crdtConfig']
): Promise<{itemprop: string; metaDataPart: unknown}> {
    const listCrdt = getCrdtImplementation(
        'list',
        crdtConfig ? crdtConfig.get(rule.itemprop) : undefined
    );

    const metaDataPart = await listCrdt.generateMetaData(
        objBaseChild,
        objChild,
        metadataBaseChild,
        rule
    );

    return {itemprop: rule.itemprop, metaDataPart};
}

/**
 * Generate the metadata object for the given rule with a 'referenceToObject' property. The
 * metadata generation will go recursively if:
 * - the referenced object is unversioned
 * - the referenced object has only one single type
 * - the referenced object has a concrete type ('*' - not accepted; it is handled with CRDT-Register
 * algorithm)
 * @param {RecipeRule} rule
 * @param {unknown} objBaseChild
 * @param {unknown} objChild
 * @param {unknown} metadataBaseChild
 * @param {Recipe.crdtConfig} crdtConfig
 * @returns {Promise<{itemprop: string, metaDataPart: unknown}>}
 */
async function generateMetadataForRuleWithReferenceToObj(
    rule: RecipeRule,
    objBaseChild: unknown,
    objChild: unknown,
    metadataBaseChild: unknown,
    crdtConfig: Recipe['crdtConfig']
): Promise<{itemprop: string; metaDataPart: unknown}> {
    // by default, rule.itemtype is string
    if (rule.itemtype === undefined) {
        rule.itemtype = {type: 'string'};
    }

    if (rule.itemtype.type !== 'referenceToObj') {
        throw new Error(`referenceToObj is undefined for rule: ${rule}`);
    }

    const objChildHash = ensureHash<OneObjectTypes>(objChild);

    const objBaseChildHash =
        objBaseChild === undefined ? undefined : ensureHash<OneObjectTypes>(objBaseChild);

    if (metadataBaseChild !== undefined && !isObject(metadataBaseChild)) {
        throw new Error(
            `generateMetadataForRuleWithReferenceToObj metadataBaseChild not object: ${metadataBaseChild}`
        );
    }

    const referenceToObjType = rule.itemtype.allowedTypes.values().next().value;

    if (
        !isVersionedObjectType(referenceToObjType) &&
        referenceToObjType !== '*' &&
        rule.itemtype.allowedTypes.size === 1 &&
        (rule.optional === false || rule.optional === undefined)
    ) {
        const recipeOfUnversionedObj = getRecipe(referenceToObjType as OneObjectTypeNames);

        const objectChild = await getObjectWithType(objChildHash);

        let objectBaseChild = undefined;

        if (objBaseChildHash !== undefined) {
            objectBaseChild = await getObjectWithType(objBaseChildHash);
        }

        const objectCRDT = getCrdtImplementation('object');

        const metaDataPart = await objectCRDT.generateMetaData(
            objectBaseChild,
            objectChild,
            metadataBaseChild,
            {
                itemprop: DUMMY_RULE_ITEMPROP,
                itemtype: {type: 'object', rules: recipeOfUnversionedObj.rule}
            },
            crdtConfig
        );

        return {itemprop: rule.itemprop, metaDataPart};
    }

    const registerCrdt = getCrdtImplementation(
        'register',
        crdtConfig ? crdtConfig.get(rule.itemprop) : undefined
    );

    const metaDataPart = await registerCrdt.generateMetaData(
        objBaseChildHash,
        objChildHash,
        metadataBaseChild,
        rule
    );

    return {itemprop: rule.itemprop, metaDataPart};
}

/**
 * Merge CRDT data for list rule.
 * @param {RecipeRule} rule
 * @param {unknown} objLatestVersionChildHash
 * @param {unknown} metadataLatestVersionChild
 * @param {unknown} objToMergeChild
 * @param {unknown} metadataToMergeChild
 * @param {Recipe.crdtConfig} crdtConfig
 * @returns {Promise<{itemprop:string, data:*, metadata:*}>}
 */
async function mergeCRDTDataForListRule(
    rule: RecipeRule,
    objLatestVersionChildHash: unknown,
    metadataLatestVersionChild: unknown,
    objToMergeChild: unknown,
    metadataToMergeChild: unknown,
    crdtConfig?: Recipe['crdtConfig']
): Promise<{itemprop: string; data: unknown; metadata: unknown}> {
    const listCrdt = getCrdtImplementation(
        'list',
        crdtConfig ? crdtConfig.get(rule.itemprop) : undefined
    );

    const mergeData = await listCrdt.mergeMetaData(
        objLatestVersionChildHash,
        metadataLatestVersionChild,
        objToMergeChild,
        metadataToMergeChild,
        rule
    );

    return {
        itemprop: rule.itemprop,
        data: mergeData.data,
        metadata: mergeData.metadata
    };
}

/**
 * Merge CRDT data for the given rule with a 'referenceToObject' property. The data is merging
 * is done recursively if:
 * - the referenced object is unversioned
 * - the referenced object has only one single type
 * - the referenced object has a concrete type ('*' - not accepted; it is handled with CRDT-Register
 * algorithm)
 * @param {RecipeRule} rule
 * @param {unknown} objLatestVersionChild
 * @param {unknown} metadataLatestVersionChild
 * @param {unknown} metadataToMergeChild
 * @param {unknown} objToMergeChild
 * @param {Recipe.crdtConfig} crdtConfig
 * @returns {Promise<{itemprop: string, data: unknown, metadata: unknown}>}
 */
async function mergeCRDTDataForRuleWithReferenceToObj(
    rule: RecipeRule,
    objLatestVersionChild: unknown,
    metadataLatestVersionChild: unknown,
    metadataToMergeChild: unknown,
    objToMergeChild: unknown,
    crdtConfig: Recipe['crdtConfig']
): Promise<{itemprop: string; data: unknown; metadata: unknown}> {
    if (rule.itemtype === undefined) {
        rule.itemtype = {type: 'string'};
    }

    if (rule.itemtype.type !== 'referenceToObj') {
        throw new Error(`referenceToObj is undefined for rule: ${rule}`);
    }

    const objToMergeChildHash = ensureHash<OneObjectTypes>(objToMergeChild);

    const objLatestVersionChildHash =
        objLatestVersionChild === undefined
            ? undefined
            : ensureHash<OneObjectTypes>(objLatestVersionChild);

    if (metadataLatestVersionChild !== undefined && !isObject(metadataLatestVersionChild)) {
        throw new Error(
            `metadataLatestVersionChild is defined, but it is not an object :${metadataLatestVersionChild}`
        );
    }

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

    const referenceToObjType = rule.itemtype.allowedTypes.values().next().value;

    if (
        !isVersionedObjectType(referenceToObjType) &&
        referenceToObjType !== '*' &&
        rule.itemtype.allowedTypes.size === 1 &&
        (rule.optional === false || rule.optional === undefined)
    ) {
        // Handle child rules by handling members individually
        const recipeOfUnversionedObj = getRecipe(referenceToObjType as OneObjectTypeNames);

        const objectToMergeChild = await getObjectWithType(objToMergeChildHash);

        let objectLatestVersionChild = undefined;

        if (objLatestVersionChildHash !== undefined) {
            objectLatestVersionChild = await getObjectWithType(objLatestVersionChildHash);
        }

        const objectCRDT = getCrdtImplementation('object');

        const mergeData = await objectCRDT.mergeMetaData(
            objectLatestVersionChild,
            metadataLatestVersionChild,
            objectToMergeChild,
            metadataToMergeChild,
            {
                itemprop: DUMMY_RULE_ITEMPROP,
                itemtype: {type: 'object', rules: recipeOfUnversionedObj.rule}
            },
            crdtConfig
        );

        if (!isObject(mergeData) || !isObject(mergeData.data) || !isObject(mergeData.metadata)) {
            throw new Error(`Object CRDT merged metadata result is invalid: ${mergeData}`);
        }

        mergeData.data.$type$ = objectToMergeChild.$type$;

        const unversionedObjectResult = await storeUnversionedObject(
            mergeData.data as OneUnversionedObjectTypes
        );

        return {
            itemprop: rule.itemprop,
            data: unversionedObjectResult.hash,
            metadata: mergeData.metadata
        };
    }

    // Handle referenced object non-recursively
    const registerCrdt = getCrdtImplementation(
        'register',
        crdtConfig ? crdtConfig.get(rule.itemprop) : undefined
    );

    const mergeData = await registerCrdt.mergeMetaData(
        objLatestVersionChildHash,
        metadataLatestVersionChild,
        objToMergeChildHash,
        metadataToMergeChild,
        rule
    );

    return {
        itemprop: rule.itemprop,
        data: mergeData.data,
        metadata: mergeData.metadata
    };
}