Source: object-recipes.ts

/**
 * @author Michael Hasenstein <hasenstein@yahoo.com>
 * @copyright REFINIO GmbH 2017
 * @license CC-BY-NC-SA-2.5; portions MIT License
 * @version 0.0.1
 */

/**
 * Provides types for all core ONE object types. Also see module
 * {@link core-types.module:ts|core-types}.
 *
 * Provides an API to
 *
 * - check if a type string has a recipe
 * - get a recipe for a type string
 * - add your own recipes
 * - check if a type string is a versioned object
 *
 * @module
 */

import {createError} from './errors';
import type {
    OneObjectTypeNames,
    OneObjectTypes,
    OneVersionedObjectTypeNames,
    OneVersionedObjectTypes,
    Recipe,
    RecipeRule
} from './recipes';
import {CORE_RECIPES} from './recipes';
import {clone} from './util/clone-object';
import {ensureRecipeObj, isRuleInheritanceWithOptions} from './util/recipe-checks';
import {isObject} from './util/type-checks-basic';

/**
 * In addition to ONE object type names declared in recipes we have one type with the meaning of
 * "everything binary". For example, it is used during Chum synchronization to inform a
 * remote instance about the type behind a hash to select the right method (binary websocket stream
 * transfer) to obtain the file. We define it as a string constant in one place to be sure there
 * is only one such string in the code and only one place to change it.
 * @static
 * @type {'BLOB'}
 */
export const BINARY = 'BLOB';

/**
 * In addition to ONE object type names declared in recipes we have one type with the meaning of
 * "everything UTF-8 but not a ONE object". For example, it is used during Chum synchronization to
 * inform a remote instance about the type behind a hash to select the right method (utf-8 based
 * websocket stream transfer) to obtain the file. We define it as a string constant in one place
 * to be sure there is only one such string in the code and only one place to change it.
 * @static
 * @type {'CLOB'}
 */
export const UTF8 = 'CLOB';

/**
 * The storage for all recipes known to the runtime.
 * Key: ONE object type name strings
 * Value: The ONE Recipe object with its array of rules
 * @private
 * @type {Map<string,Recipe>}
 */
const recipes: Map<string, Recipe> = new Map();

/**
 * This Set contains the "type" strings of all recipes that have at least one ID property, which
 * shows that it is a recipe for a versioned object.
 * @private
 * @type {Set<string>}
 */
const versionedObjects: Set<string> = new Set();

/**
 * Key: A RecipeRule that has "inheritFrom" set to point to another rule
 * Value: The target rule after merging it with the source rule
 * @private
 * @static
 * @type {Map<RecipeRule, RecipeRule>}
 */
const ruleInheritanceCache: Map<RecipeRule, RecipeRule> = new Map();

/**
 * Check if we have a recipe *in memory* for the given type string. The recipe must have been
 * registered with the instance and loaded into memory, the function does not check for
 * {@link Recipe|Recipe objects} in storage.
 * @static
 * @param {string} type - Any string
 * @returns {boolean} Returns true if the given string is the name of a type that we have a
 * recipe consisting of an array of rules for.
 */
export function hasRecipe(type: string): boolean {
    return recipes.has(type);
}

/**
 * Convenience function for the static type checker: Turns a generic "string" into a
 * "OneObjectTypeNames". Check if we have a recipe *in memory* for the given type string. The
 * recipe must have been registered with the instance and loaded into memory, the function does
 * not check for {@link Recipe|Recipe objects} in storage. If we have the recipe the function
 * returns the string, but now tagged as "OneObjectTypeNames" instead of as generic "string".
 * @static
 * @param {string} type - Any string
 * @returns {OneObjectTypeNames} Returns the given type string if the given string is the name of a
 * type that we have a recipe consisting of an array of rules for.
 * @throws {Error} Throws an error if the given type string is not a recipe we know about
 */
export function ensureValidTypeName<K extends OneObjectTypeNames>(type: K | string): K {
    // The reason to use a generic type parameter is that if the type is valid the returned
    // string will be tagged as being of that concrete object name, otherwise it would be tagged
    // as "one of all the valid names", if we just used OneObjectTypeNames as return type.
    if (recipes.has(type)) {
        return type as K;
    }

    throw createError('OR-ET1', {type});
}

/**
 * Returns the {@link Recipe} object describing how a ONE object of the given type looks like.
 *
 * ### Example
 *
 * See {@link object-recipes.module:ts.addRecipeToRuntime|addRecipeToRuntime}
 * for an example of a definition of a versioned ONE object. Using that example of a `Mailbox`
 * object the following code
 * ```
 * console.log(
 *     ObjectRecipes.getRecipe('Mailbox')
 * );
 * ```
 *
 * might produce output that looks like this:
 * ```
 * {
 *     $type$: 'Recipe',
 *     name: 'Mailbox',
 *     rule: [
 *         {
 *            itemprop: 'account',
 *            isId: true
 *         },
 *         {
 *             itemprop: 'name',
 *             isId: true
 *         },
 *         {
 *             itemprop: 'uidEmailBlobMap',
 *             valueType: 'object'
 *         }
 *     ]
 * }
 * ```
 * @static
 * @param {OneObjectTypeNames|string} type - The name of a ONE type for which we have a recipe
 * @returns {Recipe} returns a Recipe object describing the ONE object type
 * @throws {Error} If the given type is unknown
 */
export function getRecipe(type: OneObjectTypeNames): Recipe {
    const recipe = recipes.get(type);

    // If we have a recipe for such a type we accept it. We check the string against RECIPES as
    // a more complete test: The type string can be anything at this point given how we still
    // have to look for the two characters ">
    if (recipe) {
        return recipe;
    }

    throw createError('OR-GR1', {type});
}

/**
 * Add a recipe to working memory. The "Recipe" object may or may not exist in storage.
 *
 * A recipe consists of an array of objects of type RecipeRule for the given type ("name")
 * string. The recipe's rules are checked for the property names and the corresponding types but
 * no further: For example, if you forget "itemprop" (mandatory, a string) in a rule, or if you
 * have the wrong type of item on a rule property an Error is thrown. However, for "type" and
 * a rule's "itemprop" string property we only guard against HTML-breaking "<" and ">" and
 * against whitespace, and we don't check if any other recipes you refer to exist and a wide
 * range of conceivable problems.
 *
 * A recipe for a ONE object type consists of arrays of objects describing the data properties.
 * Allowed rule properties are explained in {@link RecipeRule}:
 *
 * ### Example
 *
 * The following code adds a recipe for a ONE object type "Mailbox":
 *
 * ```javascript
 * import * as ObjectRecipes from 'one.core/lib/object-recipes';
 *
 * if (!ObjectRecipes.hasRecipe('Mailbox')) {
 *     ObjectRecipes.addRecipeToRuntime({
 *         $type$: 'Recipe',
 *         name: 'Mailbox',
 *         rule: [
 *             // SHA-256 hash pointing to OneTest$ImapAccount object that mailbox belongs to.
 *             // Both the account and the name are the ID attributes of this VERSIONED object,
 *             // meaning any Mailbox object with the same account and name will be a version of
 *             // the same object, varying only in the data properties not marked as "isID:true".
 *             { itemprop: 'account', isId: true, referenceToObj: new Set(['Account']) },
 *             { itemprop: 'name', isId: true },
 *             // This is an IMAP protocol feature to check if the IMAP-UIDs from last time are
 *             // still valid. This 32-bit integer can be fully represented by a Javascript number.
 *             { itemprop: 'uidValidity', valueType: 'number' },
 *             // A JSON-stringified UID => OneTest$Email-BLOB-hash map
 *             { itemprop: 'uidEmailBlobMap', valueType: 'object' },
 *         ]
 *     });
 * }
 * ```
 *
 * A ONE object created using this recipe would look like this (indentation and newlines added
 * fore readability, not present in the actual microdata):
 *
 * ```html
 * <div itemscope itemtype="//refin.io/Mailbox">
 *   <span itemprop="account">8296cf598c1af767b5287....2bd96eae03448da3066aa</span>
 *   <span itemprop="name">INBOX</span>
 *   <span itemprop="uidValidity">1455785767</span>
 *   <span itemprop="uidEmailBlobMap">...[JSON]....</span>
 * </span>
 * ```
 * @static
 * @param {Recipe} uncheckedRecipe - (Hopefully) a {@link Recipe|ONE "Recipe" object}
 * @returns {undefined} Returns nothing, but if a type with the given name already exists it
 * throws an error, also if the given recipe does not pass a few basic tests
 * @throws {Error} Throws an Error when there is an error in the recipe that our
 * (incomplete) tests detect
 */
export function addRecipeToRuntime(uncheckedRecipe: Readonly<Recipe>): void {
    // Throws an Error if it is not a valid Recipe object
    const recipe = ensureRecipeObj(uncheckedRecipe);

    // We could ignore this because it has no immediate effect, but code that avoids it is usually
    // noticeable better - cleaner, more logical, easier to maintain.
    if (recipes.has(recipe.name)) {
        throw createError('OR-ADDR1', {rName: recipe.name});
    }

    // "isId:true" is only allowed in top level rules, so we don't have to recurse into nested
    // object rules and only have to examine the top level in this loop. We also don't need to
    // look at inherited rules because isId cannot be inherited (the decision was made).
    if (recipe.rule.some(rule => rule.isId === true)) {
        versionedObjects.add(recipe.name);
    }

    recipes.set(recipe.name, recipe);
}

/**
 * When then instance is closed is called all runtime recipes need to be removed
 */
export function clearRuntimeRecipes(): void {
    recipes.clear();
}

/**
 * Adds the core recipes to the Runtime needed for basic functionality.
 */
export function addCoreRecipesToRuntime(): void {
    for (const recipe of CORE_RECIPES) {
        addRecipeToRuntime(recipe);
    }
}

/**
 * Any ONE object with a recipe where there is a property `isId: true` is a *versioned object*
 * with an idHash common to all versions, a version map (stored using
 * the idHash) pointing to all versions.
 *
 * ### Example
 *
 * See {@link object-recipes.module:ts.addRecipeToRuntime|addRecipeToRuntime}
 * for an example of a definition of a versioned ONE object. Using that example of a `Mailbox`
 * object the following call would return `true` and the output would be `"Mailbox" is a
 * versioned type`:
 * ```
 * if (ObjectRecipes.isVersionedObjectType('Mailbox')) {
 *     console.log('"Mailbox" is a versioned type');
 * }
 * ```
 * @static
 * @param {string} type - A ONE object type string (or any string really)
 * @returns {boolean} Returns `true` and a type refinement to {@link OneVersionedObjectTypeNames}
 * if the given type is the type name of a versioned ONE object, false (and no type refinement
 * from `string`) otherwise.
 */
export function isVersionedObjectType(type: string): type is OneVersionedObjectTypeNames {
    return versionedObjects.has(type);
}

/**
 * This function checks the "`type`" property of the given ONE object against a `Set` of type
 * names of versioned object types currently registered in the running instance.
 * @static
 * @param {OneObjectTypes} obj - A versioned or an unversioned ONE object
 * @returns {boolean} Returns `true` and a type refinement to {@link OneVersionedObjectTypes} if
 * the given object is a versioned object type
 */
export function isVersionedObject(obj: OneObjectTypes): obj is OneVersionedObjectTypes {
    return versionedObjects.has(obj.$type$);
}

/**
 * A dynamic type check to turn a `string` into a {@link OneVersionedObjectTypeNames}.
 * @static
 * @param {string} type - A ONE object type string (or any string really)
 * @returns {OneVersionedObjectTypeNames} Returns the input string but now typed as
 * {@link OneVersionedObjectTypeNames}
 * @throws {Error} If the given type is unknown
 */
export function ensureVersionedObjectTypeName<K extends OneVersionedObjectTypeNames>(
    type: K | string
): K {
    if (versionedObjects.has(type)) {
        return type as K;
    }

    throw createError('OR-EVO1', {type});
}

/**
 * Get an array with all types known to the currently running instance. This checks memory, not
 * persistent storage, so only types actually loaded are found. Since all types registered with
 * an instance are automatically loaded this should match the registered types.
 * @static
 * @returns {OneObjectTypeNames[]} Returns an array of names of currently known (registered)
 * ONE object types.
 */
export function getKnownTypes(): OneObjectTypeNames[] {
    // Generally we use "string" for the keys because otherwise things like asking if some
    // string we got from storage is a valid object type string would already lead to type
    // errors. However, here we want to be specific, so we apply a type-cast - we know all keys
    // in recipes are ONE object type names.
    return Array.from(recipes.keys()) as OneObjectTypeNames[];
}

/**
 * @private
 * @static
 * @param {OneObjectTypeNames} recipeName - The recipe type string is only used for error messages
 * @param {RecipeRule[]} rules - Array of rules
 * @param {string[]} path - Path of itemprop strings
 * @returns {RecipeRule}
 */
function getRule(recipeName: OneObjectTypeNames, rules: RecipeRule[], path: string[]): RecipeRule {
    const rule = rules.find(r => r.itemprop === path[0]);

    if (rule === undefined) {
        throw createError('OR-GR01', {recipeName, path});
    }

    return rule;
}

/**
 * @private
 * @static
 * @param {string} path - A path starting with a recipe name followed by at least one or more
 * itemprop names for each level of object nesting. The separator is a dot ".".
 * @returns {RecipeRule}
 */
function getRuleWithPath(path: string): RecipeRule {
    const [recipeName, ...itempropPath] = path.split('.');
    const recipe = getRecipe(recipeName as OneObjectTypeNames);
    return getRule(recipe.name, recipe.rule, itempropPath);
}

/**
 * {@link RecipeRule} objects in {@link Recipe|Recipes} can link to other RecipeRule objects
 * in the same recipe by name. They inherit all properties of the linked rule.
 * @static
 * @param {RecipeRule} source - A rule that may or may not have an "`inheritFrom`"
 * property to inherit properties of the linked named rule
 * @returns {RecipeRule} Returns a new {@link RecipeRule} if the source rule links to a named
 * rule to inherit from, otherwise returns the source rule itself
 */
function resolveSimpleRuleInheritance(source: RecipeRule): RecipeRule {
    if (source.inheritFrom === undefined) {
        return source;
    }

    const cachedTarget = ruleInheritanceCache.get(source);

    if (cachedTarget !== undefined) {
        return cachedTarget;
    }

    const targetRule = isObject(source.inheritFrom) ? source.inheritFrom.rule : source.inheritFrom;

    const newRule = Object.assign(
        {},
        // Allow recursion, the target rule could also have "inheritFrom"
        resolveSimpleRuleInheritance(getRuleWithPath(targetRule)),
        source
    );

    if (
        isRuleInheritanceWithOptions(source.inheritFrom) &&
        newRule.itemtype !== undefined &&
        'item' in newRule.itemtype &&
        newRule.itemtype.item !== undefined
    ) {
        const {extract} = source.inheritFrom;

        if (extract === 'CollectionItemType') {
            if (!['bag', 'array', 'set'].includes(newRule.itemtype.type)) {
                throw createError('OR-RSRI1');
            }

            newRule.itemtype = newRule.itemtype.item;
        }

        if (extract === 'MapItemType') {
            if (newRule.itemtype.type !== 'map') {
                throw createError('OR-RSRI2');
            }

            const newRuleMapType = newRule.itemtype;

            newRule.itemtype = {
                type: 'object',
                rules: [
                    {itemprop: 'key', itemtype: newRuleMapType.key},
                    {itemprop: 'value', itemtype: newRuleMapType.value}
                ]
            };
        }
    }

    // This rule property cannot be inherited and is ignored should we encounter one
    if (source.isId === undefined && newRule.isId) {
        newRule.isId = false;
    }

    ruleInheritanceCache.set(source, newRule);

    return newRule;
}

/**
 *
 * @param {*} object
 * @param {string} key
 * @returns {RecipeRule | undefined}
 */
function resolveNestedRuleInheritanceInItemtype(object: any, key: string): RecipeRule {
    if (Object.prototype.hasOwnProperty.call(object, key)) {
        return resolveSimpleRuleInheritance(object);
    }

    const newObject = clone(object);

    for (const objectKey of Object.keys(newObject)) {
        const value = newObject[objectKey];

        if (typeof value === 'object' && value !== null) {
            newObject[objectKey] = resolveNestedRuleInheritanceInItemtype(
                newObject[objectKey],
                key
            );
        }
    }

    return newObject;
}

/**
 * {@link RecipeRule} objects in {@link Recipe|Recipes} can link to other RecipeRule objects
 * in the same recipe type by name. They inherit all properties of the linked rule.
 * @static
 * @param {RecipeRule} source - A rule that may or may not have an "`inheritFrom`"
 * property to inherit properties of the linked named rule in his type
 * @returns {RecipeRule} Returns a new {@link RecipeRule} if the source rule links to a named
 * rule to inherit from, otherwise returns the source rule itself
 */
export function resolveRuleInheritance(source: RecipeRule): RecipeRule {
    if (source.itemtype === undefined) {
        return source.inheritFrom === undefined ? source : resolveSimpleRuleInheritance(source);
    }

    return resolveNestedRuleInheritanceInItemtype(source, 'inheritFrom');
}