Source: crdt-recipes.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
 */

/**
 * A module for CRDT recipes:
 * For each CRDT object metadata is needed and this module takes
 * care of how to generate the recipes for them. As well as supplying some checking functions.
 * @module
 */

import type {OneCrdtToMetaObjectInterfaces} from '@OneObjectInterfaces';
import {getCrdtImplementation} from './crdt/CRDTRegistry';
import {createError} from './errors';
import {addRecipeToRuntime, getRecipe, hasRecipe, isVersionedObjectType} from './object-recipes';
import type {
    OneCrdtMetaObjectTypeNames,
    OneCrdtObjectTypeNames,
    OneCrdtObjectTypes,
    OneObjectTypeNames,
    OneObjectTypes,
    Recipe,
    RecipeRule
} from './recipes';
import type {VersionedObjectResult} from './storage-versioned-objects';
import {storeVersionedObject} from './storage-versioned-objects';
import {ensureRecipeObj} from './util/recipe-checks';
import {ruleHasItemType} from './util/type-checks';

const DUMMY_RULE_ITEMPROP = 'DUMMY';

// ######## Recipe Implementation ########

// CrdtRecipes have crdtConfig as a mandatory property
export interface CrdtRecipe extends Omit<Recipe, 'crdtConfig'> {
    crdtConfig: Recipe['crdtConfig'];
}

const crdtTypes = new Set<OneCrdtObjectTypeNames>();
const crdtMetaTypes = new Set<OneCrdtMetaObjectTypeNames>();

/**
 * The runtime collections need to be cleared when the instance is closed
 */
export function clearCrdtRuntimeSets(): void {
    crdtTypes.clear();
    crdtMetaTypes.clear();
}

/**
 * Get the metadata recipe name based on the data crdt recipe name.
 *
 * @param {T} crdtObjectName
 * @returns {OneCrdtMetaObjectTypeNames}
 */
export function getCrdtMetaRecipeName<T extends OneCrdtObjectTypeNames>(
    crdtObjectName: T
): `${T}CrdtMeta` {
    return `${crdtObjectName}CrdtMeta`;
}

/**
 * Check if the passed versioned object is a crdt type.
 *
 * @param {OneObjectTypes} arg
 * @returns {boolean}
 */
export function isCrdtObject(arg: OneObjectTypes): arg is OneCrdtObjectTypes {
    return isCrdtTypeName(arg.$type$);
}

/**
 * Check if the passed versioned object is a crdt type.
 *
 * @param {Recipe} arg
 * @returns {boolean}
 */
export function isCrdtObjectRecipe(arg: Recipe): arg is CrdtRecipe {
    return arg.crdtConfig !== undefined;
}

/**
 * Checks if the passed object type name is a crdt object type name.
 *
 * @param {OneObjectTypeNames} arg
 * @returns {boolean}
 */
export function isCrdtTypeName(arg: OneObjectTypeNames): arg is OneCrdtObjectTypeNames {
    return crdtTypes.has(arg as OneCrdtObjectTypeNames);
}

/**
 * Check if the object type name is a crdt meta-object type name
 *
 * @param {OneObjectTypeNames} arg
 * @returns {boolean}
 */
export function isCrdtMetaTypeName(arg: OneObjectTypeNames): arg is OneCrdtMetaObjectTypeNames {
    return crdtMetaTypes.has(arg as OneCrdtMetaObjectTypeNames);
}

/**
 * Check if the passed object is a meta-object for the passed crdt type.
 *
 * @param {OneObjectTypes} arg
 * @param {T} baseType
 * @returns {boolean}
 */
export function isCrdtMetaObjectForCrdtObjectTypeName<T extends OneCrdtObjectTypeNames>(
    arg: OneObjectTypes,
    baseType: T
): arg is OneCrdtToMetaObjectInterfaces[T] {
    return arg.$type$ === getCrdtMetaRecipeName(baseType);
}

/**
 * Check if the passed meta type is the base type for a crdt type.
 *
 * @param {OneObjectTypeNames} arg
 * @param {OneCrdtObjectTypeNames} baseType
 * @returns {boolean}
 */
export function isCrdtMetaObjectTypeNameForCrdtObjectTypeName(
    arg: OneObjectTypeNames,
    baseType: OneCrdtObjectTypeNames
): arg is OneCrdtMetaObjectTypeNames {
    return arg === getCrdtMetaRecipeName(baseType);
}

/**
 * When loading already installed recipes from the instance on instance startup the names of
 * CRDT recipes need to be registered in two module-internal sets for teh CRDT and the CRDT-meta
 * recipe type name.
 * @param {OneCrdtObjectTypeNames} crdtTypeName
 * @returns {undefined}
 */
export function registerCrdtRecipeName(crdtTypeName: OneCrdtObjectTypeNames): void {
    const crdtMetaRecipeName: OneCrdtMetaObjectTypeNames = `${crdtTypeName}CrdtMeta`;
    crdtTypes.add(crdtTypeName);
    crdtMetaTypes.add(crdtMetaRecipeName);
}

/**
 * Function for generating the meta recipe for a crdt based recipe.
 *
 * This also registers the passed recipe as CRDT recipe, so that the is* functions in this module
 * return true.
 *
 * Silently returns the existing meta recipe if it was already added.
 *
 * @param {RecipeT} recipe
 * @returns {Recipe}
 */
export function generateCrdtMetaRecipe(recipe: CrdtRecipe): Recipe {
    if (!isCrdtObjectRecipe(recipe)) {
        throw createError('CRDTR-G1', recipe);
    }

    const crdtTypeName = recipe.name as OneCrdtObjectTypeNames;
    const crdtMetaRecipeName: OneCrdtMetaObjectTypeNames = `${crdtTypeName}CrdtMeta`;

    if (crdtMetaTypes.has(crdtMetaRecipeName)) {
        return getRecipe(crdtMetaRecipeName);
    }

    // Build the runtime maps for crdt- and crdt-meta types
    crdtTypes.add(crdtTypeName);
    crdtMetaTypes.add(crdtMetaRecipeName);

    const objectCRDT = getCrdtImplementation('object');

    if (objectCRDT === undefined) {
        throw createError('CRDTR-G2');
    }

    // Build the recipe and return it.
    return {
        $type$: 'Recipe',
        name: crdtMetaRecipeName,
        rule: [
            {
                itemprop: 'data',
                itemtype: {type: 'referenceToObj', allowedTypes: new Set(['*'])}
            },
            {
                itemprop: 'crdt',
                // create a dummy rule, so that ObjectCRDT complies to the CRDTImplementation
                // interface
                itemtype: {
                    type: 'object',
                    rules: objectCRDT.generateRecipeRules(
                        {
                            itemprop: DUMMY_RULE_ITEMPROP,
                            itemtype: {type: 'object', rules: recipe.rule}
                        },
                        recipe.name,
                        recipe.crdtConfig
                    )
                }
            },
            {
                itemprop: 'source',
                itemtype: {type: 'string'},
                optional: true
            }
        ]
    };
}

/**
 * This function is called by instance-creator and updater and adds recipes to runtime and storage.
 *
 * Since CRDT object recipes we implicitly want to generate and add CRDT meta recipes as well as the
 * base recipe. This function takes care supplied recipes are in the right order before adding
 * them to the runtime and storage.
 *
 * If recipes already exist at runtime they aren't added again.
 *
 * @param {Readonly<Recipe[]>} recipes
 * @returns {Promise<Array<VersionedObjectResult<Recipe>>>}
 */
export async function addRecipesToRuntimeAndStorage(
    recipes: Readonly<Recipe[]>
): Promise<Array<VersionedObjectResult<Recipe>>> {
    // Sort recipes so that CRDT recipes are at the end of the array.
    // Prevents errors due to crdt recipes linking to recipes not yet registered.
    const crdtRecipesAtTheEnd = [...recipes].sort(recipe => (isCrdtObjectRecipe(recipe) ? 1 : -1));
    const recipeResultPromises = [];

    for (const recipe of crdtRecipesAtTheEnd) {
        if (!hasRecipe(recipe.name)) {
            addRecipeToRuntime(recipe);
        }

        if (isCrdtObjectRecipe(recipe)) {
            const metaRecipe = generateCrdtMetaRecipe(recipe);

            if (!hasRecipe(metaRecipe.name)) {
                addRecipeToRuntime(metaRecipe);
            }

            recipeResultPromises.push(
                storeVersionedObject(ensureRecipeObj(recipe)),
                storeVersionedObject(ensureRecipeObj(metaRecipe))
            );
        } else {
            recipeResultPromises.push(storeVersionedObject(ensureRecipeObj(recipe)));
        }
    }

    return await Promise.all(recipeResultPromises);
}

/**
 * When a crdt has reference to object children which are not CRDTs themselves.
 * The crdtConfig still allows to configure the algorithm to use on these children.
 *
 * This is done via <itemprop>'.'<itemprop> notation when the path to the child is specified.
 * e.g.
 * ```
 * crdtConfig: new Map([
 * ['unversionedLvl1.unversionedLvl2.listStrOne', CRDT_IMPLEMENTATION_NAMES.LWWSet]
 * ]),
 * ```
 * in the example a parent object hast a property called 'unversionedLvl1' which references an
 * unversionedLevel1 object, 'unversionedLvl1' in turn has a property 'unversionedLvl2' property
 * which references an unversionedL2 object. The 'unversionedLvl2' then has a property 'listStrOne'
 * where we want to use the LWWSet as merge-algorithm.
 * In the generateMetaRecipeForRule we need to strip one level from the crdtConfig when the current
 * rule.itemprop is the top-most of the "pathToChildren"
 * ('unversionedLvl1.unversionedLvl2.listStrOne') except if we only have one level left.
 *
 * @param {RecipeRule} rule
 * @param {Recipe.crdtConfig} crdtConfig
 * @returns {undefined | Map<string, CRDTImplementationNames>}
 */
export function adjustCrdtConfig(
    rule: RecipeRule,
    crdtConfig: Recipe['crdtConfig']
): Recipe['crdtConfig'] {
    if (crdtConfig) {
        const crdtConfigRecursiveAdjustments: Recipe['crdtConfig'] = new Map();

        for (const [key, value] of crdtConfig.entries()) {
            const pathToChild = key.split('.');

            if (pathToChild.length === 1) {
                // if length 1: the code arrived at the property we want to configure.
                crdtConfigRecursiveAdjustments.set(key, value);
            } else if (rule.itemprop === pathToChild[0]) {
                // else if the current rule is the first level specified in the path:
                // Remove one level from the path
                pathToChild.shift();

                const newPath = pathToChild.join('.');
                crdtConfigRecursiveAdjustments.set(newPath, value);
            }
        }

        return crdtConfigRecursiveAdjustments;
    } else {
        return crdtConfig;
    }
}

/**
 * Generate the CRDT meta recipe rules for the given rule.
 * @param {RecipeRule} rule
 * @param {string} path
 * @param {Recipe.crdtConfig} crdtConfig
 * @returns {RecipeRule[]}
 */
export function generateMetaRecipeForRule(
    rule: RecipeRule,
    path: string,
    crdtConfig?: Recipe['crdtConfig']
): RecipeRule[] {
    const currPath = path + '.' + rule.itemprop;
    const adjustedCrdtConfig = adjustCrdtConfig(rule, crdtConfig);

    const ACCEPTED_RECORD_TYPE = new Set(['array', 'bag']);

    if (ruleHasItemType(rule) && ACCEPTED_RECORD_TYPE.has(rule.itemtype.type) && rule.optional) {
        throw createError('CRDTR-GM1', rule);
    }

    // Handle lists by choosing the default list crdt
    if (ruleHasItemType(rule) && ACCEPTED_RECORD_TYPE.has(rule.itemtype.type)) {
        return generateMetaRecipeRuleFromListRule(rule, currPath, adjustedCrdtConfig);
    }

    if (ruleHasItemType(rule) && rule.itemtype.type === 'object') {
        const objectCRDT = getCrdtImplementation('object');

        // Handle child rules by handling members individually
        return objectCRDT.generateRecipeRules(rule, currPath, adjustedCrdtConfig);
    }

    if (ruleHasItemType(rule) && rule.itemtype.type === 'referenceToObj') {
        return generateMetaRecipeRuleFromRuleWithReferenceToObj(rule, currPath, adjustedCrdtConfig);
    }

    // Handle normal types, id links CLOB and BLOB types as register
    // If not a container, then we have a register crdt
    const registerCrdt = getCrdtImplementation(
        'register',
        adjustedCrdtConfig ? adjustedCrdtConfig.get(rule.itemprop) : undefined
    );

    return registerCrdt.generateRecipeRules(rule, currPath);
}

/**
 * Generate meta recipe rule for a list rule using the CRDT-Set algorithm.
 * @param {RecipeRule} rule - The rule to be processed for the meta recipe generation.
 * @param {string} currPath
 * @param {Recipe.crdtConfig} crdtConfig
 * @returns {RecipeRule[]}
 */
function generateMetaRecipeRuleFromListRule(
    rule: RecipeRule,
    currPath: string,
    crdtConfig?: Recipe['crdtConfig']
): RecipeRule[] {
    const listCrdt = getCrdtImplementation(
        'list',
        crdtConfig ? crdtConfig.get(rule.itemprop) : undefined
    );

    return listCrdt.generateRecipeRules(rule, currPath);
}

/**
 * Generate the meta recipe rule for a rule with a 'referenceToObj' property. The reference to an
 * unversioned child object is handled recursively if it has only one concrete type ('*' is not
 * handled recursively).
 * @param {RecipeRule} rule - The rule to be processed for the meta recipe generation.
 * @param {string} currPath
 * @param {Recipe.crdtConfig} crdtConfig
 * @returns {RecipeRule[]}
 */
function generateMetaRecipeRuleFromRuleWithReferenceToObj(
    rule: RecipeRule,
    currPath: string,
    crdtConfig: Recipe['crdtConfig']
): RecipeRule[] {
    // by default, rule.itemtype is string
    if (rule.itemtype === undefined) {
        rule.itemtype = {type: 'string'};
    }

    if (rule.itemtype.type !== 'referenceToObj') {
        throw createError('CRDTR-GMR1', rule);
    }

    const references = rule.itemtype.allowedTypes;

    if (references === undefined) {
        throw createError('CRDTR-GMR2', rule);
    }

    // Extract the first object reference from the set
    const referenceToObject = rule.itemtype.allowedTypes.values().next().value;

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

        return objectCRDT.generateRecipeRules(
            {
                itemprop: DUMMY_RULE_ITEMPROP,
                itemtype: {type: 'object', rules: recipeOfUnversionedObj.rule}
            },
            recipeOfUnversionedObj.name,
            crdtConfig
        );
    }

    // Rule not treated recursively
    // If not a container, then we have a register crdt
    const registerCrdt = getCrdtImplementation(
        'register',
        crdtConfig ? crdtConfig.get(rule.itemprop) : undefined
    );

    return registerCrdt.generateRecipeRules(rule, currPath);
}