Files
twenty/packages/twenty-shared/src/utils/applyDiff.ts
T
f60817a1e1 Morph many to one picker (#14155)
This PR follows the multiSelect PR merged previously. It will enable
morph relation Many to One to be handled from the table, using a
singleSelect picker

Main point : I decided to change the singleSelect API to take an array
of **objectMetadataName** instead of only one to deal with both our
usecases.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2025-10-07 17:25:11 +02:00

249 lines
6.0 KiB
TypeScript

import { isDefined } from '@/utils/validation';
import { isNumber, isObject, isString } from '@sniptt/guards';
import { type Difference } from 'microdiff';
type ObjectType = Record<string, unknown>;
type ArrayType = unknown[];
type MutableData = ObjectType | ArrayType;
type ArrayDeletionFn = () => void;
// Symbol used to mark array elements for deletion
const REMOVE_SYMBOL = Symbol('micropatch-delete');
const FORBIDDEN_OBJECT_KEYS = ['__proto__', 'constructor', 'prototype'];
export const applyDiff = <T>(obj: T, diffs: Difference[]): T => {
if (!isDefined(obj)) {
throw new Error('Cannot apply diff to null or undefined object');
}
if (!Array.isArray(diffs)) {
throw new Error('Diffs must be an array');
}
// Create a deep copy to avoid modifying read-only/frozen objects
const mutableObj = deepClone(obj) as T;
const arrayDeletionQueue: ArrayDeletionFn[] = [];
for (const diff of diffs) {
if (!diff || !diff.path || diff.path.length === 0) {
continue;
}
try {
applyDiffToPath(mutableObj as MutableData, diff, arrayDeletionQueue);
} catch (error) {
throw new Error(
`Failed to apply diff at path ${diff.path.join('.')}: ${error}`,
);
}
}
arrayDeletionQueue.forEach((deletionFn) => deletionFn());
return mutableObj;
};
const applyDiffToPath = (
obj: MutableData,
diff: Difference,
arrayDeletionQueue: ArrayDeletionFn[],
) => {
const { path, type } = diff;
const value = 'value' in diff ? diff.value : undefined;
const pathLength = path.length;
const lastPathElement = path[pathLength - 1];
const parentContainer = navigateToParent(obj, path);
switch (type) {
case 'CREATE':
case 'CHANGE':
setValueAtPath(parentContainer, lastPathElement, value);
break;
case 'REMOVE':
handleRemoval(
obj,
path,
parentContainer,
lastPathElement,
arrayDeletionQueue,
);
break;
default:
throw new Error(`Unsupported diff type: ${type}`);
}
};
const navigateToParent = (
obj: MutableData,
path: (string | number)[],
): MutableData => {
let current = obj;
for (let i = 0; i < path.length - 1; i++) {
const pathElement = path[i];
if (current === null || current === undefined) {
throw new Error(
`Cannot traverse path: found null/undefined at element ${i}`,
);
}
if (isNumber(pathElement) && !Array.isArray(current)) {
throw new Error(
`Expected array at path element ${i}, got ${typeof current}`,
);
}
if (isString(pathElement) && Array.isArray(current)) {
throw new Error(`Expected object at path element ${i}, got array`);
}
if (Array.isArray(current)) {
current = current[pathElement as number] as MutableData;
} else {
current = (current as ObjectType)[pathElement as string] as MutableData;
}
}
return current;
};
const setValueAtPath = (
container: MutableData,
pathElement: string | number,
value: unknown,
): void => {
if (Array.isArray(container)) {
if (!isNumber(pathElement)) {
throw new Error(
`Expected numeric index for array, got ${typeof pathElement}`,
);
}
try {
container[pathElement] = value;
} catch (error) {
throw new Error(
`Cannot set array element at index ${pathElement}: ${error}. Array may be non-extensible.`,
);
}
} else if (isObject(container)) {
if (FORBIDDEN_OBJECT_KEYS.includes(pathElement as string)) {
throw new Error(
`Refusing to set forbidden property key '${pathElement}' on object (prototype pollution protection)`,
);
}
try {
container[pathElement] = value;
} catch (error) {
throw new Error(
`Cannot set property '${String(pathElement)}': ${error}. Object may be non-extensible.`,
);
}
} else {
throw new Error(`Expected object or array, got ${typeof container}`);
}
};
const handleRemoval = (
rootObj: MutableData,
fullPath: (string | number)[],
parentContainer: MutableData,
lastPathElement: string | number,
arrayDeletionQueue: ArrayDeletionFn[],
): void => {
if (Array.isArray(parentContainer)) {
handleArrayRemoval(
rootObj,
fullPath,
parentContainer,
lastPathElement,
arrayDeletionQueue,
);
} else {
handleObjectRemoval(parentContainer as ObjectType, lastPathElement);
}
};
// Handles removal from arrays by marking for deletion and queuing cleanup
const handleArrayRemoval = (
rootObj: MutableData,
fullPath: (string | number)[],
parentArray: ArrayType,
index: string | number,
arrayDeletionQueue: ArrayDeletionFn[],
): void => {
if (typeof index !== 'number') {
throw new Error(
`Expected numeric index for array removal, got ${typeof index}`,
);
}
parentArray[index] = REMOVE_SYMBOL;
arrayDeletionQueue.push(() => {
if (fullPath.length === 1) {
if (Array.isArray(rootObj)) {
filterRemoveSymbols(rootObj);
}
} else {
filterRemoveSymbols(parentArray);
}
});
};
const handleObjectRemoval = (
parentObject: ObjectType,
key: string | number,
): void => {
if (FORBIDDEN_OBJECT_KEYS.includes(key as string)) {
return;
}
delete parentObject[key];
};
const filterRemoveSymbols = (array: ArrayType): void => {
const indicesToRemove: number[] = [];
for (let i = 0; i < array.length; i++) {
if (array[i] === REMOVE_SYMBOL) {
indicesToRemove.push(i);
}
}
for (let i = indicesToRemove.length - 1; i >= 0; i--) {
array.splice(indicesToRemove[i], 1);
}
};
const deepClone = (obj: MutableData): MutableData => {
if (obj === null || !isObject(obj)) {
return obj;
}
if (typeof structuredClone !== 'undefined') {
try {
return structuredClone(obj) as MutableData;
} catch {
return deepCloneJson(obj);
}
}
return deepCloneJson(obj);
};
const deepCloneJson = (obj: MutableData): MutableData => {
try {
return JSON.parse(JSON.stringify(obj)) as MutableData;
} catch {
throw new Error('Failed to clone object');
}
};