/**
* @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);
}