Migrate update step changes to diff (#14663)
This PR should fix optimistic rendering issues on step updates: - compute a diff for trigger and steps on mutations - build an util that applies that diff (built a more robust version from https://github.com/AsyncBanana/micropatch) - apply diff in cache
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
import { type Difference } from 'microdiff';
|
||||
|
||||
import { applyDiff } from '@/utils/applyDiff';
|
||||
|
||||
describe('applyDiff', () => {
|
||||
describe('input validation', () => {
|
||||
it('should throw error for non-array diffs', () => {
|
||||
const obj = { test: 'value' };
|
||||
|
||||
expect(() => applyDiff(obj, null as any)).toThrow(
|
||||
'Diffs must be an array'
|
||||
);
|
||||
expect(() => applyDiff(obj, 'invalid' as any)).toThrow(
|
||||
'Diffs must be an array'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty diffs array', () => {
|
||||
const obj = { test: 'value' };
|
||||
const result = applyDiff(obj, []);
|
||||
|
||||
expect(result).toEqual({ test: 'value' });
|
||||
expect(result).not.toBe(obj); // Should return a copy
|
||||
});
|
||||
|
||||
it('should skip invalid diffs', () => {
|
||||
const obj = { test: 'value' };
|
||||
const diffs: Difference[] = [
|
||||
null as any,
|
||||
{ type: 'CREATE', path: [], value: 'test' } as any,
|
||||
{ type: 'CREATE', path: ['test'], value: 'updated' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({ test: 'updated' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CREATE operations', () => {
|
||||
it('should create new properties in objects', () => {
|
||||
const obj = { existing: 'value' };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['newProp'], value: 'newValue' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
existing: 'value',
|
||||
newProp: 'newValue',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create nested properties', () => {
|
||||
const obj = { level1: { existing: 'value' } };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['level1', 'newProp'], value: 'newValue' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
existing: 'value',
|
||||
newProp: 'newValue',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create array elements', () => {
|
||||
const obj = ['existing'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: [1], value: 'newElement' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['existing', 'newElement']);
|
||||
});
|
||||
|
||||
it('should create deeply nested structures with existing intermediate objects', () => {
|
||||
const obj = { level1: { level2: {} } };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['level1', 'level2', 'prop'], value: 'deep' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
prop: 'deep',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should build deeply nested structures with multiple CREATE operations', () => {
|
||||
const obj = {};
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['level1'], value: {} },
|
||||
{ type: 'CREATE', path: ['level1', 'level2'], value: {} },
|
||||
{ type: 'CREATE', path: ['level1', 'level2', 'prop'], value: 'deep' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
prop: 'deep',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHANGE operations', () => {
|
||||
it('should change existing properties', () => {
|
||||
const obj = { prop: 'oldValue' };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['prop'], oldValue: 'oldValue', value: 'newValue' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({ prop: 'newValue' });
|
||||
});
|
||||
|
||||
it('should change nested properties', () => {
|
||||
const obj = { level1: { prop: 'oldValue' } };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['level1', 'prop'], oldValue: 'oldValue', value: 'newValue' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
level1: { prop: 'newValue' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should change array elements', () => {
|
||||
const obj = ['old', 'values'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: [0], oldValue: 'old', value: 'new' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['new', 'values']);
|
||||
});
|
||||
|
||||
it('should change complex nested structures', () => {
|
||||
const obj = {
|
||||
users: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
};
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['users', 0, 'name'], oldValue: 'John', value: 'Johnny' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
users: [
|
||||
{ id: 1, name: 'Johnny' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('REMOVE operations', () => {
|
||||
it('should remove properties from objects', () => {
|
||||
const obj = { keep: 'value', remove: 'toDelete' };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: ['remove'], oldValue: 'toDelete' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({ keep: 'value' });
|
||||
});
|
||||
|
||||
it('should remove nested properties', () => {
|
||||
const obj = {
|
||||
level1: {
|
||||
keep: 'value',
|
||||
remove: 'toDelete',
|
||||
},
|
||||
};
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: ['level1', 'remove'], oldValue: 'toDelete' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
level1: { keep: 'value' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove single array element', () => {
|
||||
const obj = ['keep1', 'remove', 'keep2'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: [1], oldValue: 'remove' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['keep1', 'keep2']);
|
||||
});
|
||||
|
||||
it('should remove multiple array elements in correct order', () => {
|
||||
const obj = ['a', 'b', 'c', 'd', 'e'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: [1], oldValue: 'b' }, // Remove 'b'
|
||||
{ type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd'
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['a', 'c', 'e']);
|
||||
});
|
||||
|
||||
it('should handle complex array removal scenarios', () => {
|
||||
const obj = ['a', 'b', 'c', 'd', 'e', 'f'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: [0], oldValue: 'a' }, // Remove 'a'
|
||||
{ type: 'REMOVE', path: [2], oldValue: 'c' }, // Remove 'c'
|
||||
{ type: 'REMOVE', path: [4], oldValue: 'e' }, // Remove 'e'
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['b', 'd', 'f']);
|
||||
});
|
||||
|
||||
it('should remove from nested arrays', () => {
|
||||
const obj = {
|
||||
items: [
|
||||
{ id: 1, tags: ['tag1', 'tag2', 'tag3'] },
|
||||
{ id: 2, tags: ['tag4', 'tag5'] },
|
||||
],
|
||||
};
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: ['items', 0, 'tags', 1], oldValue: 'tag2' }, // Remove 'tag2'
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 1, tags: ['tag1', 'tag3'] },
|
||||
{ id: 2, tags: ['tag4', 'tag5'] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for non-numeric array index', () => {
|
||||
const obj = ['a', 'b', 'c'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: ['invalid'], oldValue: 'invalid' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
'Expected numeric index for array removal, got string'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed operations', () => {
|
||||
it('should apply multiple different operations', () => {
|
||||
const obj = {
|
||||
keep: 'value',
|
||||
change: 'oldValue',
|
||||
remove: 'toDelete',
|
||||
nested: {
|
||||
array: ['a', 'b', 'c'],
|
||||
},
|
||||
};
|
||||
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['newProp'], value: 'newValue' },
|
||||
{ type: 'CHANGE', path: ['change'], oldValue: 'oldValue', value: 'newValue' },
|
||||
{ type: 'REMOVE', path: ['remove'], oldValue: 'toDelete' },
|
||||
{ type: 'REMOVE', path: ['nested', 'array', 1], oldValue: 'b' }, // Remove 'b'
|
||||
{ type: 'CREATE', path: ['nested', 'newArray'], value: [1, 2, 3] },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
keep: 'value',
|
||||
change: 'newValue',
|
||||
newProp: 'newValue',
|
||||
nested: {
|
||||
array: ['a', 'c'],
|
||||
newArray: [1, 2, 3],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle operations on the same array', () => {
|
||||
const obj = ['a', 'b', 'c', 'd'];
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: [0], oldValue: 'a', value: 'A' }, // Change 'a' to 'A'
|
||||
{ type: 'REMOVE', path: [1], oldValue: 'b' }, // Remove 'b'
|
||||
{ type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd'
|
||||
{ type: 'REMOVE', path: [3], oldValue: 'd' }, // Remove 'd'
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual(['A', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error for invalid diff type', () => {
|
||||
const obj = { test: 'value' };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'INVALID' as any, path: ['test'], value: 'newValue' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
'Unsupported diff type: INVALID'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error with path information for invalid operations', () => {
|
||||
const obj = { test: 'value' };
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['nonExistent', 'deep', 'path'], oldValue: 'value', value: 'value' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
'Failed to apply diff at path nonExistent.deep.path'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FORBIDDEN_OBJECT_KEYS protection', () => {
|
||||
it('should throw error when trying to CREATE forbidden property keys', () => {
|
||||
const obj = { safe: 'value' };
|
||||
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: ['__proto__'], value: 'malicious' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
"Refusing to set forbidden property key '__proto__' on object (prototype pollution protection)"
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent Unicode escape bypasses of forbidden property keys', () => {
|
||||
const obj = { safe: 'value' };
|
||||
|
||||
const unicodeBypassAttempts = [
|
||||
// __proto__ with Unicode escapes
|
||||
'__\u0070roto__', // \u0070 = 'p'
|
||||
'__\u{70}roto__', // ES6 syntax
|
||||
'__pr\u006fto__', // \u006f = 'o'
|
||||
'__proto\u005f\u005f', // \u005f = '_'
|
||||
|
||||
// constructor with Unicode escapes
|
||||
'construc\u0074or', // \u0074 = 't'
|
||||
'constr\u0075ctor', // \u0075 = 'u'
|
||||
'\u0063onstructor', // \u0063 = 'c'
|
||||
|
||||
// prototype with Unicode escapes
|
||||
'proto\u0074ype', // \u0074 = 't'
|
||||
'prototy\u0070e', // \u0070 = 'p'
|
||||
'\u0070rototype', // \u0070 = 'p'
|
||||
];
|
||||
|
||||
unicodeBypassAttempts.forEach((maliciousKey) => {
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CREATE', path: [maliciousKey], value: 'malicious' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
new RegExp(`Refusing to set forbidden property key.*prototype pollution protection`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when trying to CHANGE forbidden property keys', () => {
|
||||
const obj = { safe: 'value' };
|
||||
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['constructor'], oldValue: 'old', value: 'malicious' },
|
||||
];
|
||||
|
||||
expect(() => applyDiff(obj, diffs)).toThrow(
|
||||
"Refusing to set forbidden property key 'constructor' on object (prototype pollution protection)"
|
||||
);
|
||||
});
|
||||
|
||||
it('should silently skip removal of forbidden property keys', () => {
|
||||
const obj = {
|
||||
safe: 'value',
|
||||
normalProp: 'normal',
|
||||
};
|
||||
|
||||
const diffs: Difference[] = [
|
||||
// Try to remove forbidden keys (these should be silently skipped)
|
||||
{ type: 'REMOVE', path: ['__proto__'], oldValue: 'anything' },
|
||||
{ type: 'REMOVE', path: ['constructor'], oldValue: 'anything' },
|
||||
{ type: 'REMOVE', path: ['prototype'], oldValue: 'anything' },
|
||||
// Remove a normal property (this should work)
|
||||
{ type: 'REMOVE', path: ['safe'], oldValue: 'value' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
|
||||
// Only the safe property should be removed, normalProp should remain
|
||||
expect(result).toEqual({
|
||||
normalProp: 'normal',
|
||||
});
|
||||
|
||||
// Verify safe property was actually removed
|
||||
expect(result).not.toHaveProperty('safe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
it('should not modify the original object', () => {
|
||||
const obj = { prop: 'value', nested: { deep: 'value' } };
|
||||
const originalObj = JSON.parse(JSON.stringify(obj));
|
||||
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['prop'], oldValue: 'value', value: 'newValue' },
|
||||
{ type: 'CHANGE', path: ['nested', 'deep'], oldValue: 'value', value: 'newDeepValue' },
|
||||
];
|
||||
|
||||
applyDiff(obj, diffs);
|
||||
|
||||
expect(obj).toEqual(originalObj);
|
||||
});
|
||||
|
||||
it('should not modify the original array', () => {
|
||||
const obj = ['a', 'b', 'c'];
|
||||
const originalObj = [...obj];
|
||||
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'REMOVE', path: [1], oldValue: 'b' },
|
||||
];
|
||||
|
||||
applyDiff(obj, diffs);
|
||||
|
||||
expect(obj).toEqual(originalObj);
|
||||
});
|
||||
|
||||
it('should handle frozen objects', () => {
|
||||
const obj = Object.freeze({ prop: 'value' });
|
||||
const diffs: Difference[] = [
|
||||
{ type: 'CHANGE', path: ['prop'], oldValue: 'value', value: 'newValue' },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({ prop: 'newValue' });
|
||||
expect(obj.prop).toBe('value'); // Original unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should handle workflow-like data structures', () => {
|
||||
const obj = {
|
||||
trigger: {
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: { table: 'users' },
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'CODE',
|
||||
settings: { code: 'console.log("hello")' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'EMAIL',
|
||||
settings: { to: 'test@example.com' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const diffs: Difference[] = [
|
||||
// Update trigger settings
|
||||
{ type: 'CHANGE', path: ['trigger', 'settings', 'table'], oldValue: 'users', value: 'contacts' },
|
||||
// Remove first step
|
||||
{ type: 'REMOVE', path: ['steps', 0], oldValue: '1' },
|
||||
// Update remaining step
|
||||
{ type: 'CHANGE', path: ['steps', 1, 'settings', 'to'], oldValue: 'test@example.com', value: 'new@example.com' },
|
||||
// Add new step
|
||||
{ type: 'CREATE', path: ['steps', 2], value: {
|
||||
id: '3',
|
||||
type: 'WEBHOOK',
|
||||
settings: { url: 'https://api.example.com/webhook' },
|
||||
}},
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
trigger: {
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: { table: 'contacts' },
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: '2',
|
||||
type: 'EMAIL',
|
||||
settings: { to: 'new@example.com' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'WEBHOOK',
|
||||
settings: { url: 'https://api.example.com/webhook' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array with multiple removals and additions', () => {
|
||||
const obj = {
|
||||
items: [
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
{ id: 3, name: 'Item 3' },
|
||||
{ id: 4, name: 'Item 4' },
|
||||
{ id: 5, name: 'Item 5' },
|
||||
],
|
||||
};
|
||||
|
||||
const diffs: Difference[] = [
|
||||
// Remove items at indices 1 and 3 (Item 2 and Item 4)
|
||||
{ type: 'REMOVE', path: ['items', 1], oldValue: 'Item 2' },
|
||||
{ type: 'REMOVE', path: ['items', 3], oldValue: 'Item 4' },
|
||||
{ type: 'REMOVE', path: ['items', 3], oldValue: 'Item 4' },
|
||||
// Update remaining item
|
||||
{ type: 'CHANGE', path: ['items', 0, 'name'], oldValue: 'Item 1', value: 'Updated Item 1' },
|
||||
// Add new item
|
||||
{ type: 'CREATE', path: ['items', 5], value: { id: 6, name: 'New Item' } },
|
||||
];
|
||||
|
||||
const result = applyDiff(obj, diffs);
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 1, name: 'Updated Item 1' },
|
||||
{ id: 3, name: 'Item 3' },
|
||||
{ id: 5, name: 'Item 5' },
|
||||
{ id: 6, name: 'New Item' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
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');
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,7 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export { applyDiff } from './applyDiff';
|
||||
export { filterOutByProperty } from './array/filterOutByProperty';
|
||||
export { findById } from './array/findById';
|
||||
export { findByProperty } from './array/findByProperty';
|
||||
|
||||
Reference in New Issue
Block a user