sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
379 lines (323 loc) • 10.5 kB
text/typescript
import {
isIndexSegment,
isKeyedObject,
isKeySegment,
isTypedObject,
type PatchOperations,
type Path,
} from '@sanity/types'
import {
diffItem,
type DiffOptions,
type InsertAfterPatch,
type SetPatch,
type UnsetPatch,
} from 'sanity-diff-patch'
import {isRecord} from '../../../util'
import {
findIndex,
getItemKeySegment,
getValueAtPath,
isEmptyObject,
pathToString,
} from '../../paths'
import {
type ArrayDiff,
type ChangeNode,
type Diff,
type FieldOperationsAPI,
type ItemDiff,
type ObjectDiff,
} from '../../types'
import {flattenChangeNode, isAddedAction, isSubpathOf, pathSegmentOfCorrectType} from './helpers'
const diffOptions: DiffOptions = {
diffMatchPatch: {enabled: false, lengthThresholdAbsolute: 30, lengthThresholdRelative: 1.2},
}
export function undoChange(
change: ChangeNode,
rootDiff: ObjectDiff | null,
documentOperations: FieldOperationsAPI,
): void {
if (!rootDiff) {
return
}
const patches: PatchOperations[] = []
if (change.type === 'group') {
const allChanges = flattenChangeNode(change)
const unsetChanges = allChanges.filter(isAddedAction)
allChanges
.filter((child) => !isAddedAction(child))
.forEach((child) => undoChange(child, rootDiff, documentOperations))
patches.push(
...buildUnsetPatches(rootDiff, unsetChanges.map((unsetChange) => unsetChange.path).reverse()),
)
} else if (change.diff.action === 'added') {
// The reverse of an add operation is an unset -
// so we don't need to worry about moved items in this case
patches.push(...buildUnsetPatches(rootDiff, [change.path]))
} else if (
change.type === 'field' &&
change.itemDiff &&
change.parentDiff &&
change.parentDiff.type === 'array' &&
change.itemDiff.hasMoved
) {
// If an array item has moved, we need to unset + insert it again
// (we lack a "move" patch currently)
patches.push(...buildMovePatches(change.itemDiff, change.parentDiff, change.path))
} else {
// For all other operations, try to find the most optimal case
patches.push(...buildUndoPatches(change.diff, rootDiff, change.path))
}
documentOperations.patch.execute(patches)
}
function buildUnsetPatch(rootDiff: ObjectDiff, path: Path, concurrentUnsetPaths: Path[]): Path {
const previousValue = rootDiff.toValue as Record<string, unknown>
return furthestEmptyAncestor(previousValue, path, concurrentUnsetPaths)
}
function buildUnsetPatches(rootDiff: ObjectDiff, paths: Path[]): PatchOperations[] {
const patches: Path[] = []
for (let i = 0; i < paths.length; i++) {
const unsetByEarlierPatch = patches.some((patch) => isSubpathOf(paths[i], patch))
if (unsetByEarlierPatch) {
continue
}
patches.push(buildUnsetPatch(rootDiff, paths[i], paths))
}
return [{unset: [...new Set(patches.map(pathToString))]}]
}
/**
* Find the path to the furthest empty ancestor that's also a stub.
*
* Used for removing all stubs when unsetting a nested value.
*/
function furthestEmptyAncestor(
/**
* The state of the tree before the change was made.
*/
previousValue: Record<string, unknown>,
/**
* Path of the value to unset. Used for recursing.
*/
currentPath: Path,
/**
* An optional list of path to forcefully mark as a stub regardless of what it actually is.
*/
ignorePaths: Path[] = [],
/**
* Same as the first value of currentPath.
*/
initialPath?: Path,
): Path {
if (currentPath.length <= 0) {
/*
* This means we are at root and no ancestors are stubs. We
* can therefore safely unset only the actual value.
*/
if (!initialPath) {
/*
* Will happen if the function is started with `currentPath = []`.
*/
throw new Error('Root has no ancestor')
}
return initialPath
}
const ancestorPath = currentPath.slice(0, -1)
const ancestorValue = getValueAtPath(previousValue, ancestorPath)
/*
* If the ancestor also is a stub we can add it to the ignore-list
* so it'll be "remembered" as a stub without us having to scan
* the whole tree again.
*/
const updatedIgnorePaths = [
ancestorPath,
/*
* We can filter out all the subpaths from under this ancestor
* because since we ignore it higher up in the tree it doesn't
* matter anymore what the values of subpaths are.
*/
...ignorePaths.filter((path) => !isSubpathOf(path, ancestorPath)),
]
return isStub(ancestorValue, ancestorPath, ignorePaths)
? furthestEmptyAncestor(previousValue, ancestorPath, updatedIgnorePaths, initialPath)
: currentPath
}
function buildMovePatches(
itemDiff: ItemDiff,
parentDiff: ArrayDiff,
path: Path,
): PatchOperations[] {
const basePath = path.slice(0, -1)
const {parentValue, fromIndex, fromValue} = getFromItem(parentDiff, itemDiff)
let insertLocation
if (fromIndex === 0) {
// If it was moved from the beginning, we can use a simple prepend
insertLocation = {before: pathToString([...basePath, 0])}
} else {
// Try to use item key segments where possible, falling back to array indexes
const prevIndex = fromIndex - 1
const prevItemKey = getItemKeySegment(parentValue[prevIndex])
const prevSegment = prevItemKey || prevIndex
insertLocation = {after: pathToString([...basePath, prevSegment])}
}
return [
{
unset: [pathToString(path)],
},
{
insert: {...insertLocation, items: [fromValue]} as any,
},
]
}
function buildUndoPatches(diff: Diff, rootDiff: ObjectDiff, path: Path): PatchOperations[] {
const patches = diffItem(diff.toValue, diff.fromValue, diffOptions, path)
const inserts = patches
.filter((patch): patch is InsertAfterPatch => patch.op === 'insert')
.map(({after, items}) => ({insert: {after: pathToString(after), items}}) as any)
const unsets = patches
.filter((patch): patch is UnsetPatch => patch.op === 'unset')
.reduce((acc, patch) => acc.concat(pathToString(patch.path)), [] as string[])
const stubbedPaths = new Set<string>()
const stubs: PatchOperations[] = []
let hasSets = false
const sets = patches
.filter((patch): patch is SetPatch => patch.op === 'set')
.reduce(
(acc, patch) => {
hasSets = true
stubs.push(...getParentStubs(patch.path, rootDiff, stubbedPaths))
acc[pathToString(patch.path)] = patch.value
return acc
},
{} as Record<string, unknown>,
)
return [
...stubs,
...inserts,
...(unsets.length > 0 ? [{unset: unsets}] : []),
...(hasSets ? [{set: sets}] : []),
]
}
function getParentStubs(path: Path, rootDiff: ObjectDiff, stubbed: Set<string>): PatchOperations[] {
const value = rootDiff.fromValue as Record<string, unknown>
const nextValue = rootDiff.toValue as Record<string, unknown>
const stubs: PatchOperations[] = []
for (let i = 1; i <= path.length; i++) {
const subPath = path.slice(0, i)
const pathStr = pathToString(subPath)
if (stubbed.has(pathStr)) {
continue
}
const nextSegment = path[i]
const nextIsArrayElement = isKeySegment(nextSegment) || isIndexSegment(nextSegment)
const itemValue = getValueAtPath(value, subPath)
const stub = getStubValue(itemValue)
// If the next array element does not exist, we need to inject an insert stub here
if (
nextIsArrayElement &&
Array.isArray(itemValue) &&
!getValueAtPath(nextValue, path.slice(0, i + 1))
) {
const indexAtPrev = findIndex(itemValue, nextSegment)
const prevItem = itemValue[indexAtPrev - 1]
const nextItem = getValueAtPath(value, subPath.concat(nextSegment))
const prevSeg = isKeyedObject(prevItem) ? {_key: prevItem._key} : indexAtPrev - 1
const after = pathToString(subPath.concat(indexAtPrev < 1 ? 0 : prevSeg))
stubs.push({setIfMissing: {[pathStr]: []}})
stubs.push({insert: {after, items: [getStubValue(nextItem)]} as any})
i++
continue
}
if (typeof stub === 'undefined') {
continue
}
stubbed.add(pathStr)
stubs.push({setIfMissing: {[pathStr]: stub as Record<string, unknown>}})
}
return stubs
}
/**
* Check if all items in an object or an array are stubs.
*/
function onlyContainsStubs(
/**
* The item to check whether is a stub.
*/
item: unknown,
/**
* The path to the item we're checking.
*/
path: Path,
/**
* An optional list of path to forcefully mark as a stub regardless of what it actually is.
*/
ignorePaths?: Path[],
): boolean {
/*
* If we're trying to check for stubs inside something which isn't an object
* or an array we're checking a string for example and it they cannot
* contain stubs.
*/
if (!isRecord(item) || !Array.isArray(item)) {
return false
}
for (const child in item) {
if (!Object.prototype.hasOwnProperty.call(item, child)) {
continue
}
/*
* _type or _key field alone doesn't affect whether the field is a stub or
* not.
*/
if (child === '_type' || child === '_key') {
continue
}
const nextPath = [...path, pathSegmentOfCorrectType(item as Record<string, unknown>, child)]
if (!isStub(item[child], nextPath, ignorePaths)) {
return false
}
}
return true
}
function isStub(item: unknown, path: Path, ignorePaths?: Path[]): boolean {
const isIgnoredPath = ignorePaths?.some(
(ignorePath) => pathToString(ignorePath) === pathToString(path),
)
const isEmptyArray = Array.isArray(item) && item.length <= 0
return (
isIgnoredPath ||
item === undefined ||
item === null ||
isEmptyArray ||
isEmptyObject(item) ||
onlyContainsStubs(item, path, ignorePaths)
)
}
function getStubValue(item: unknown): unknown {
if (Array.isArray(item)) {
return []
}
if (typeof item !== 'object' || item === null) {
return undefined
}
const props: Record<string, unknown> = {}
if (isKeyedObject(item)) {
props._key = item._key
}
if (isTypedObject(item)) {
props._type = item._type
}
return props
}
function getFromItem(parentDiff: ArrayDiff, itemDiff: ItemDiff) {
if (parentDiff.fromValue && typeof itemDiff.fromIndex === 'number') {
const fromValue = parentDiff.fromValue[itemDiff.fromIndex]
return {
parentValue: parentDiff.fromValue,
fromIndex: itemDiff.fromIndex,
fromValue,
}
}
// Shouldn't ever happen
throw new Error(`Failed to find item at index ${itemDiff.fromIndex}`)
}