94 lines
2.5 KiB
TypeScript
94 lines
2.5 KiB
TypeScript
import { isPlainObject } from "../utils.js";
|
|
|
|
type PlainObject = Record<string, unknown>;
|
|
|
|
type MergePatchOptions = {
|
|
mergeObjectArraysById?: boolean;
|
|
};
|
|
|
|
function isObjectWithStringId(value: unknown): value is Record<string, unknown> & { id: string } {
|
|
if (!isPlainObject(value)) {
|
|
return false;
|
|
}
|
|
return typeof value.id === "string" && value.id.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Merge arrays of object-like entries keyed by `id`.
|
|
*
|
|
* Contract:
|
|
* - Base array must be fully id-keyed; otherwise return undefined (caller should replace).
|
|
* - Patch entries with valid id merge by id (or append when the id is new).
|
|
* - Patch entries without valid id append as-is, avoiding destructive full-array replacement.
|
|
*/
|
|
function mergeObjectArraysById(
|
|
base: unknown[],
|
|
patch: unknown[],
|
|
options: MergePatchOptions,
|
|
): unknown[] | undefined {
|
|
if (!base.every(isObjectWithStringId)) {
|
|
return undefined;
|
|
}
|
|
|
|
const merged: unknown[] = [...base];
|
|
const indexById = new Map<string, number>();
|
|
for (const [index, entry] of merged.entries()) {
|
|
if (!isObjectWithStringId(entry)) {
|
|
return undefined;
|
|
}
|
|
indexById.set(entry.id, index);
|
|
}
|
|
|
|
for (const patchEntry of patch) {
|
|
if (!isObjectWithStringId(patchEntry)) {
|
|
merged.push(structuredClone(patchEntry));
|
|
continue;
|
|
}
|
|
|
|
const existingIndex = indexById.get(patchEntry.id);
|
|
if (existingIndex === undefined) {
|
|
merged.push(structuredClone(patchEntry));
|
|
indexById.set(patchEntry.id, merged.length - 1);
|
|
continue;
|
|
}
|
|
|
|
merged[existingIndex] = applyMergePatch(merged[existingIndex], patchEntry, options);
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
export function applyMergePatch(
|
|
base: unknown,
|
|
patch: unknown,
|
|
options: MergePatchOptions = {},
|
|
): unknown {
|
|
if (!isPlainObject(patch)) {
|
|
return patch;
|
|
}
|
|
|
|
const result: PlainObject = isPlainObject(base) ? { ...base } : {};
|
|
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (value === null) {
|
|
delete result[key];
|
|
continue;
|
|
}
|
|
if (options.mergeObjectArraysById && Array.isArray(result[key]) && Array.isArray(value)) {
|
|
const mergedArray = mergeObjectArraysById(result[key] as unknown[], value, options);
|
|
if (mergedArray) {
|
|
result[key] = mergedArray;
|
|
continue;
|
|
}
|
|
}
|
|
if (isPlainObject(value)) {
|
|
const baseValue = result[key];
|
|
result[key] = applyMergePatch(isPlainObject(baseValue) ? baseValue : {}, value, options);
|
|
continue;
|
|
}
|
|
result[key] = value;
|
|
}
|
|
|
|
return result;
|
|
}
|