@sanity/diff-patch
Version:
Generates a set of Sanity patches needed to change an item (usually a document) from one shape to another
272 lines (271 loc) • 12.6 kB
JavaScript
import { stringifyPatches, makePatches } from "@sanity/diff-match-patch";
const IS_DOTTABLE_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function pathToString(path) {
return path.reduce((target, segment, i) => {
if (Array.isArray(segment))
return `${target}[${segment.join(":")}]`;
if (isKeyedObject(segment))
return `${target}[_key=="${segment._key}"]`;
if (typeof segment == "number")
return `${target}[${segment}]`;
if (typeof segment == "string" && !IS_DOTTABLE_RE.test(segment))
return `${target}['${segment}']`;
if (typeof segment == "string")
return `${target}${i === 0 ? "" : "."}${segment}`;
throw new Error(`Unsupported path segment "${segment}"`);
}, "");
}
function isKeyedObject(obj) {
return typeof obj == "object" && !!obj && "_key" in obj && typeof obj._key == "string";
}
class DiffError extends Error {
path;
value;
serializedPath;
constructor(message, path, value) {
const serializedPath = pathToString(path);
super(`${message} (at '${serializedPath}')`), this.path = path, this.serializedPath = serializedPath, this.value = value;
}
}
const idPattern = /^[a-z0-9][a-z0-9_.-]+$/i, propPattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/, propStartPattern = /^[a-z_]/i;
function validateProperty(property, value, path) {
if (!propStartPattern.test(property))
throw new DiffError("Keys must start with a letter (a-z)", path.concat(property), value);
if (!propPattern.test(property))
throw new DiffError(
"Keys can only contain letters, numbers and underscores",
path.concat(property),
value
);
if (property === "_key" || property === "_ref" || property === "_type") {
if (typeof value != "string")
throw new DiffError("Keys must be strings", path.concat(property), value);
if (!idPattern.test(value))
throw new DiffError("Invalid key - use less exotic characters", path.concat(property), value);
}
return property;
}
function difference(source, target) {
if ("difference" in Set.prototype)
return source.difference(target);
const result = /* @__PURE__ */ new Set();
for (const item of source)
target.has(item) || result.add(item);
return result;
}
function intersection(source, target) {
if ("intersection" in Set.prototype)
return source.intersection(target);
const result = /* @__PURE__ */ new Set();
for (const item of source)
target.has(item) && result.add(item);
return result;
}
const SYSTEM_KEYS = ["_id", "_type", "_createdAt", "_updatedAt", "_rev"], DMP_MAX_STRING_SIZE = 1e6, DMP_MAX_STRING_LENGTH_CHANGE_RATIO = 0.4, DMP_MIN_SIZE_FOR_RATIO_CHECK = 1e4;
function diffPatch(source, target, options = {}) {
const id = options.id || source._id === target._id && source._id, revisionLocked = options.ifRevisionID, ifRevisionID = typeof revisionLocked == "boolean" ? source._rev : revisionLocked, basePath = options.basePath || [];
if (!id)
throw new Error(
"_id on source and target not present or differs, specify document id the mutations should be applied to"
);
if (revisionLocked === !0 && !ifRevisionID)
throw new Error(
"`ifRevisionID` is set to `true`, but no `_rev` was passed in item A. Either explicitly set `ifRevisionID` to a revision, or pass `_rev` as part of item A."
);
if (basePath.length === 0 && source._type !== target._type)
throw new Error(`_type is immutable and cannot be changed (${source._type} => ${target._type})`);
const operations = diffItem(source, target, basePath, []);
return serializePatches(operations).map((patchOperations, i) => ({
patch: {
id,
// only add `ifRevisionID` to the first patch
...i === 0 && ifRevisionID && { ifRevisionID },
...patchOperations
}
}));
}
function diffValue(source, target, basePath = []) {
return serializePatches(diffItem(source, target, basePath));
}
function diffItem(source, target, path = [], patches = []) {
return source === target ? patches : typeof source == "string" && typeof target == "string" ? (diffString(source, target, path, patches), patches) : Array.isArray(source) && Array.isArray(target) ? (diffArray(source, target, path, patches), patches) : isRecord(source) && isRecord(target) ? (diffObject(source, target, path, patches), patches) : target === void 0 ? (patches.push({ op: "unset", path }), patches) : (patches.push({ op: "set", path, value: target }), patches);
}
function diffObject(source, target, path, patches) {
const atRoot = path.length === 0, aKeys = Object.keys(source).filter(atRoot ? isNotIgnoredKey : yes).map((key) => validateProperty(key, source[key], path)), aKeysLength = aKeys.length, bKeys = Object.keys(target).filter(atRoot ? isNotIgnoredKey : yes).map((key) => validateProperty(key, target[key], path)), bKeysLength = bKeys.length;
for (let i = 0; i < aKeysLength; i++) {
const key = aKeys[i];
key in target || patches.push({ op: "unset", path: path.concat(key) });
}
for (let i = 0; i < bKeysLength; i++) {
const key = bKeys[i];
diffItem(source[key], target[key], path.concat([key]), patches);
}
return patches;
}
function diffArray(source, target, path, patches) {
return isUniquelyKeyed(source) && isUniquelyKeyed(target) ? diffArrayByKey(source, target, path, patches) : diffArrayByIndex(source, target, path, patches);
}
function diffArrayByIndex(source, target, path, patches) {
if (target.length > source.length && patches.push({
op: "insert",
position: "after",
path: path.concat([-1]),
items: target.slice(source.length).map(nullifyUndefined)
}), target.length < source.length) {
const isSingle = source.length - target.length === 1, unsetItems = source.slice(target.length);
isUniquelyKeyed(unsetItems) ? patches.push(
...unsetItems.map(
(item) => ({ op: "unset", path: path.concat({ _key: item._key }) })
)
) : patches.push({
op: "unset",
path: path.concat([isSingle ? target.length : [target.length, ""]])
});
}
for (let i = 0; i < target.length; i++)
if (Array.isArray(target[i]))
throw new DiffError("Multi-dimensional arrays not supported", path.concat(i), target[i]);
const overlapping = Math.min(source.length, target.length), segmentA = source.slice(0, overlapping), segmentB = target.slice(0, overlapping);
for (let i = 0; i < segmentA.length; i++)
diffItem(segmentA[i], nullifyUndefined(segmentB[i]), path.concat(i), patches);
return patches;
}
function diffArrayByKey(source, target, path, patches) {
const sourceItemsByKey = new Map(source.map((item) => [item._key, item])), targetItemsByKey = new Map(target.map((item) => [item._key, item])), sourceKeys = new Set(sourceItemsByKey.keys()), targetKeys = new Set(targetItemsByKey.keys()), keysRemovedFromSource = difference(sourceKeys, targetKeys), keysAddedToTarget = difference(targetKeys, sourceKeys), keysInBothArrays = intersection(sourceKeys, targetKeys), sourceKeysStillPresent = Array.from(difference(sourceKeys, keysRemovedFromSource)), targetKeysAlreadyPresent = Array.from(difference(targetKeys, keysAddedToTarget)), keyReorderOperations = [];
for (let i = 0; i < keysInBothArrays.size; i++) {
const keyAtPositionInSource = sourceKeysStillPresent[i], keyAtPositionInTarget = targetKeysAlreadyPresent[i];
keyAtPositionInSource !== keyAtPositionInTarget && keyReorderOperations.push({
sourceKey: keyAtPositionInSource,
targetKey: keyAtPositionInTarget
});
}
keyReorderOperations.length && patches.push({
op: "reorder",
path,
snapshot: source,
reorders: keyReorderOperations
});
for (const key of keysInBothArrays)
diffItem(sourceItemsByKey.get(key), targetItemsByKey.get(key), [...path, { _key: key }], patches);
for (const keyToRemove of keysRemovedFromSource)
patches.push({ op: "unset", path: [...path, { _key: keyToRemove }] });
if (keysAddedToTarget.size) {
let insertionAnchorKey, itemsPendingInsertion = [];
const flushPendingInsertions = () => {
itemsPendingInsertion.length && patches.push({
op: "insert",
// Insert after the anchor key if we have one, otherwise insert at the beginning
...insertionAnchorKey ? { position: "after", path: [...path, { _key: insertionAnchorKey }] } : { position: "before", path: [...path, 0] },
items: itemsPendingInsertion
});
};
for (const key of targetKeys)
keysAddedToTarget.has(key) ? itemsPendingInsertion.push(targetItemsByKey.get(key)) : keysInBothArrays.has(key) && (flushPendingInsertions(), insertionAnchorKey = key, itemsPendingInsertion = []);
flushPendingInsertions();
}
return patches;
}
function shouldUseDiffMatchPatch(source, target) {
const maxLength = Math.max(source.length, target.length);
return maxLength > DMP_MAX_STRING_SIZE ? !1 : maxLength < DMP_MIN_SIZE_FOR_RATIO_CHECK ? !0 : !(Math.abs(target.length - source.length) / maxLength > DMP_MAX_STRING_LENGTH_CHANGE_RATIO);
}
function getDiffMatchPatch(source, target, path) {
const last = path.at(-1);
if (!(typeof last == "string" && last.startsWith("_")) && shouldUseDiffMatchPatch(source, target))
try {
const strPatch = stringifyPatches(makePatches(source, target));
return { op: "diffMatchPatch", path, value: strPatch };
} catch {
return;
}
}
function diffString(source, target, path, patches) {
const dmp = getDiffMatchPatch(source, target, path);
return patches.push(dmp ?? { op: "set", path, value: target }), patches;
}
function isNotIgnoredKey(key) {
return SYSTEM_KEYS.indexOf(key) === -1;
}
function serializePatches(patches, curr) {
const [patch, ...rest] = patches;
if (!patch) return curr ? [curr] : [];
switch (patch.op) {
case "set":
case "diffMatchPatch": {
const emptyOp = { [patch.op]: {} };
return curr ? patch.op in curr ? (Object.assign(curr[patch.op], { [pathToString(patch.path)]: patch.value }), serializePatches(rest, curr)) : [curr, ...serializePatches(patches, emptyOp)] : serializePatches(patches, emptyOp);
}
case "unset": {
const emptyOp = { unset: [] };
return curr ? "unset" in curr ? (curr.unset.push(pathToString(patch.path)), serializePatches(rest, curr)) : [curr, ...serializePatches(patches, emptyOp)] : serializePatches(patches, emptyOp);
}
case "insert":
return curr ? [curr, ...serializePatches(patches)] : [
{
insert: {
[patch.position]: pathToString(patch.path),
items: patch.items
}
},
...serializePatches(rest)
];
case "reorder": {
if (curr) return [curr, ...serializePatches(patches)];
const tempKeyOperations = {};
tempKeyOperations.set = {};
for (const { sourceKey, targetKey } of patch.reorders) {
const temporaryKey = `__temp_reorder_${sourceKey}__`, finalContentForThisPosition = patch.snapshot[getIndexForKey(patch.snapshot, targetKey)];
Object.assign(tempKeyOperations.set, {
[pathToString([...patch.path, { _key: sourceKey }])]: {
...finalContentForThisPosition,
_key: temporaryKey
}
});
}
const finalKeyOperations = {};
finalKeyOperations.set = {};
for (const { sourceKey, targetKey } of patch.reorders) {
const temporaryKey = `__temp_reorder_${sourceKey}__`;
Object.assign(finalKeyOperations.set, {
[pathToString([...patch.path, { _key: temporaryKey }, "_key"])]: targetKey
});
}
return [tempKeyOperations, finalKeyOperations, ...serializePatches(rest)];
}
default:
return [];
}
}
function isUniquelyKeyed(arr) {
const seenKeys = /* @__PURE__ */ new Set();
for (const item of arr) {
if (!isKeyedObject(item) || seenKeys.has(item._key)) return !1;
seenKeys.add(item._key);
}
return !0;
}
const keyToIndexCache = /* @__PURE__ */ new WeakMap();
function getIndexForKey(keyedArray, targetKey) {
const cachedMapping = keyToIndexCache.get(keyedArray);
if (cachedMapping) return cachedMapping[targetKey];
const keyToIndexMapping = keyedArray.reduce(
(mapping, { _key }, arrayIndex) => (mapping[_key] = arrayIndex, mapping),
{}
);
return keyToIndexCache.set(keyedArray, keyToIndexMapping), keyToIndexMapping[targetKey];
}
function isRecord(value) {
return typeof value == "object" && !!value && !Array.isArray(value);
}
function nullifyUndefined(item) {
return item === void 0 ? null : item;
}
function yes(_) {
return !0;
}
export {
DiffError,
diffPatch,
diffValue
};
//# sourceMappingURL=index.js.map