UNPKG

@sanity/diff-patch

Version:

Generates a set of Sanity patches needed to change an item (usually a document) from one shape to another

1 lines 38 kB
{"version":3,"file":"index.cjs","sources":["../src/paths.ts","../src/diffError.ts","../src/validate.ts","../src/setOperations.ts","../src/diffPatch.ts"],"sourcesContent":["const IS_DOTTABLE_RE = /^[A-Za-z_][A-Za-z0-9_]*$/\n\n/**\n * A segment of a path\n *\n * @public\n */\nexport type PathSegment =\n | string // Property\n | number // Array index\n | {_key: string} // Array `_key` lookup\n | [number | '', number | ''] // From/to array index\n\n/**\n * An array of path segments representing a path in a document\n *\n * @public\n */\nexport type Path = PathSegment[]\n\n/**\n * Converts an array path to a string path\n *\n * @param path - The array path to convert\n * @returns A stringified path\n * @internal\n */\nexport function pathToString(path: Path): string {\n return path.reduce((target: string, segment: PathSegment, i: number) => {\n if (Array.isArray(segment)) {\n return `${target}[${segment.join(':')}]`\n }\n\n if (isKeyedObject(segment)) {\n return `${target}[_key==\"${segment._key}\"]`\n }\n\n if (typeof segment === 'number') {\n return `${target}[${segment}]`\n }\n\n if (typeof segment === 'string' && !IS_DOTTABLE_RE.test(segment)) {\n return `${target}['${segment}']`\n }\n\n if (typeof segment === 'string') {\n const separator = i === 0 ? '' : '.'\n return `${target}${separator}${segment}`\n }\n\n throw new Error(`Unsupported path segment \"${segment}\"`)\n }, '')\n}\n\n/**\n * An object (record) that has a `_key` property\n *\n * @internal\n */\nexport interface KeyedSanityObject {\n [key: string]: unknown\n _key: string\n}\n\nexport function isKeyedObject(obj: unknown): obj is KeyedSanityObject {\n return typeof obj === 'object' && !!obj && '_key' in obj && typeof obj._key === 'string'\n}\n","import {type Path, pathToString} from './paths.js'\n\n/**\n * Represents an error that occurred during a diff process.\n * Contains `path`, `value` and `serializedPath` properties,\n * which is helpful for debugging and showing friendly messages.\n *\n * @public\n */\nexport class DiffError extends Error {\n public path: Path\n public value: unknown\n public serializedPath: string\n\n constructor(message: string, path: Path, value?: unknown) {\n const serializedPath = pathToString(path)\n super(`${message} (at '${serializedPath}')`)\n\n this.path = path\n this.serializedPath = serializedPath\n this.value = value\n }\n}\n","import {DiffError} from './diffError.js'\nimport type {Path} from './paths.js'\n\nconst idPattern = /^[a-z0-9][a-z0-9_.-]+$/i\nconst propPattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/\nconst propStartPattern = /^[a-z_]/i\n\n/**\n * Validate the given document/subtree for Sanity compatibility\n *\n * @param item - The document or subtree to validate\n * @param path - The path to the current item, for error reporting\n * @returns True if valid, throws otherwise\n * @internal\n */\nexport function validateDocument(item: unknown, path: Path = []): boolean {\n if (Array.isArray(item)) {\n return item.every((child, i) => {\n if (Array.isArray(child)) {\n throw new DiffError('Multi-dimensional arrays not supported', path.concat(i))\n }\n\n return validateDocument(child, path.concat(i))\n })\n }\n\n if (typeof item === 'object' && item !== null) {\n const obj = item as {[key: string]: any}\n return Object.keys(obj).every(\n (key) =>\n validateProperty(key, obj[key], path) && validateDocument(obj[key], path.concat(key)),\n )\n }\n\n return true\n}\n\n/**\n * Validate a property for Sanity compatibility\n *\n * @param property - The property to valide\n * @param value - The value of the property\n * @param path - The path of the property, for error reporting\n * @returns The property name, if valid\n * @internal\n */\nexport function validateProperty(property: string, value: unknown, path: Path): string {\n if (!propStartPattern.test(property)) {\n throw new DiffError('Keys must start with a letter (a-z)', path.concat(property), value)\n }\n\n if (!propPattern.test(property)) {\n throw new DiffError(\n 'Keys can only contain letters, numbers and underscores',\n path.concat(property),\n value,\n )\n }\n\n if (property === '_key' || property === '_ref' || property === '_type') {\n if (typeof value !== 'string') {\n throw new DiffError('Keys must be strings', path.concat(property), value)\n }\n\n if (!idPattern.test(value)) {\n throw new DiffError('Invalid key - use less exotic characters', path.concat(property), value)\n }\n }\n\n return property\n}\n","/// <reference lib=\"esnext\" />\n\nexport function difference<T>(source: Set<T>, target: Set<T>): Set<T> {\n if ('difference' in Set.prototype) {\n return source.difference(target)\n }\n\n const result = new Set<T>()\n for (const item of source) {\n if (!target.has(item)) {\n result.add(item)\n }\n }\n return result\n}\n\nexport function intersection<T>(source: Set<T>, target: Set<T>): Set<T> {\n if ('intersection' in Set.prototype) {\n return source.intersection(target)\n }\n\n const result = new Set<T>()\n for (const item of source) {\n if (target.has(item)) {\n result.add(item)\n }\n }\n return result\n}\n","import {makePatches, stringifyPatches} from '@sanity/diff-match-patch'\nimport {DiffError} from './diffError.js'\nimport {isKeyedObject, type KeyedSanityObject, type Path, pathToString} from './paths.js'\nimport {validateProperty} from './validate.js'\nimport {\n type Patch,\n type UnsetPatch,\n type DiffMatchPatch,\n type SanityPatchMutation,\n type SanityPatchOperations,\n type SanitySetPatchOperation,\n type SanityUnsetPatchOperation,\n type SanityInsertPatchOperation,\n type SanityDiffMatchPatchOperation,\n} from './patches.js'\nimport {difference, intersection} from './setOperations.js'\n\n/**\n * Document keys that are ignored during diff operations.\n * These are system-managed fields that should not be included in patches on\n * top-level documents and should not be diffed with diff-match-patch.\n */\nconst SYSTEM_KEYS = ['_id', '_type', '_createdAt', '_updatedAt', '_rev']\n\n/**\n * Maximum size of strings to consider for diff-match-patch (1MB)\n * Based on testing showing consistently good performance up to this size\n */\nconst DMP_MAX_STRING_SIZE = 1_000_000\n\n/**\n * Maximum difference in string length before falling back to set operations (40%)\n * Above this threshold, likely indicates text replacement which can be slow\n */\nconst DMP_MAX_STRING_LENGTH_CHANGE_RATIO = 0.4\n\n/**\n * Minimum string size to apply change ratio check (10KB)\n * Small strings are always fast regardless of change ratio\n */\nconst DMP_MIN_SIZE_FOR_RATIO_CHECK = 10_000\n\n/**\n * An object (record) that _may_ have a `_key` property\n *\n * @internal\n */\nexport type SanityObject = KeyedSanityObject | Partial<KeyedSanityObject>\n\n/**\n * Represents a partial Sanity document (eg a \"stub\").\n *\n * @public\n */\nexport interface DocumentStub {\n _id?: string\n _type?: string\n _rev?: string\n _createdAt?: string\n _updatedAt?: string\n [key: string]: unknown\n}\n\n/**\n * Options for the patch generator\n *\n * @public\n */\nexport interface PatchOptions {\n /**\n * Document ID to apply the patch to.\n *\n * @defaultValue `undefined` - tries to extract `_id` from passed document\n */\n id?: string\n\n /**\n * Base path to apply the patch to - useful if diffing sub-branches of a document.\n *\n * @defaultValue `[]` - eg root of the document\n */\n basePath?: Path\n\n /**\n * Only apply the patch if the document revision matches this value.\n * If the property is the boolean value `true`, it will attempt to extract\n * the revision from the document `_rev` property.\n *\n * @defaultValue `undefined` (do not apply revision check)\n */\n ifRevisionID?: string | true\n}\n\n/**\n * Generates an array of mutations for Sanity, based on the differences between\n * the two passed documents/trees.\n *\n * @param source - The first document/tree to compare\n * @param target - The second document/tree to compare\n * @param opts - Options for the diff generation\n * @returns Array of mutations\n * @public\n */\nexport function diffPatch(\n source: DocumentStub,\n target: DocumentStub,\n options: PatchOptions = {},\n): SanityPatchMutation[] {\n const id = options.id || (source._id === target._id && source._id)\n const revisionLocked = options.ifRevisionID\n const ifRevisionID = typeof revisionLocked === 'boolean' ? source._rev : revisionLocked\n const basePath = options.basePath || []\n if (!id) {\n throw new Error(\n '_id on source and target not present or differs, specify document id the mutations should be applied to',\n )\n }\n\n if (revisionLocked === true && !ifRevisionID) {\n throw new Error(\n '`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.',\n )\n }\n\n if (basePath.length === 0 && source._type !== target._type) {\n throw new Error(`_type is immutable and cannot be changed (${source._type} => ${target._type})`)\n }\n\n const operations = diffItem(source, target, basePath, [])\n return serializePatches(operations).map((patchOperations, i) => ({\n patch: {\n id,\n // only add `ifRevisionID` to the first patch\n ...(i === 0 && ifRevisionID && {ifRevisionID}),\n ...patchOperations,\n },\n }))\n}\n\n/**\n * Generates an array of patch operation objects for Sanity, based on the\n * differences between the two passed values\n *\n * @param source - The source value to start off with\n * @param target - The target value that the patch operations will aim to create\n * @param basePath - An optional path that will be prefixed to all subsequent patch operations\n * @returns Array of mutations\n * @public\n */\nexport function diffValue(\n source: unknown,\n target: unknown,\n basePath: Path = [],\n): SanityPatchOperations[] {\n return serializePatches(diffItem(source, target, basePath))\n}\n\nfunction diffItem(\n source: unknown,\n target: unknown,\n path: Path = [],\n patches: Patch[] = [],\n): Patch[] {\n if (source === target) {\n return patches\n }\n\n if (typeof source === 'string' && typeof target === 'string') {\n diffString(source, target, path, patches)\n return patches\n }\n\n if (Array.isArray(source) && Array.isArray(target)) {\n diffArray(source, target, path, patches)\n return patches\n }\n\n if (isRecord(source) && isRecord(target)) {\n diffObject(source, target, path, patches)\n return patches\n }\n\n if (target === undefined) {\n patches.push({op: 'unset', path})\n return patches\n }\n\n patches.push({op: 'set', path, value: target})\n return patches\n}\n\nfunction diffObject(\n source: Record<string, unknown>,\n target: Record<string, unknown>,\n path: Path,\n patches: Patch[],\n) {\n const atRoot = path.length === 0\n const aKeys = Object.keys(source)\n .filter(atRoot ? isNotIgnoredKey : yes)\n .map((key) => validateProperty(key, source[key], path))\n\n const aKeysLength = aKeys.length\n const bKeys = Object.keys(target)\n .filter(atRoot ? isNotIgnoredKey : yes)\n .map((key) => validateProperty(key, target[key], path))\n\n const bKeysLength = bKeys.length\n\n // Check for deleted items\n for (let i = 0; i < aKeysLength; i++) {\n const key = aKeys[i]\n if (!(key in target)) {\n patches.push({op: 'unset', path: path.concat(key)})\n }\n }\n\n // Check for changed items\n for (let i = 0; i < bKeysLength; i++) {\n const key = bKeys[i]\n diffItem(source[key], target[key], path.concat([key]), patches)\n }\n\n return patches\n}\n\nfunction diffArray(source: unknown[], target: unknown[], path: Path, patches: Patch[]) {\n if (isUniquelyKeyed(source) && isUniquelyKeyed(target)) {\n return diffArrayByKey(source, target, path, patches)\n }\n\n return diffArrayByIndex(source, target, path, patches)\n}\n\nfunction diffArrayByIndex(source: unknown[], target: unknown[], path: Path, patches: Patch[]) {\n // Check for new items\n if (target.length > source.length) {\n patches.push({\n op: 'insert',\n position: 'after',\n path: path.concat([-1]),\n items: target.slice(source.length).map(nullifyUndefined),\n })\n }\n\n // Check for deleted items\n if (target.length < source.length) {\n const isSingle = source.length - target.length === 1\n const unsetItems = source.slice(target.length)\n\n // If we have unique array keys, we'll want to unset by key, as this is\n // safer in a realtime, collaborative setting\n if (isUniquelyKeyed(unsetItems)) {\n patches.push(\n ...unsetItems.map(\n (item): UnsetPatch => ({op: 'unset', path: path.concat({_key: item._key})}),\n ),\n )\n } else {\n patches.push({\n op: 'unset',\n path: path.concat([isSingle ? target.length : [target.length, '']]),\n })\n }\n }\n\n // Check for illegal array contents\n for (let i = 0; i < target.length; i++) {\n if (Array.isArray(target[i])) {\n throw new DiffError('Multi-dimensional arrays not supported', path.concat(i), target[i])\n }\n }\n\n const overlapping = Math.min(source.length, target.length)\n const segmentA = source.slice(0, overlapping)\n const segmentB = target.slice(0, overlapping)\n\n for (let i = 0; i < segmentA.length; i++) {\n diffItem(segmentA[i], nullifyUndefined(segmentB[i]), path.concat(i), patches)\n }\n\n return patches\n}\n\n/**\n * Diffs two arrays of keyed objects by their `_key` properties.\n *\n * This approach is preferred over index-based diffing for collaborative editing scenarios\n * because it generates patches that are more resilient to concurrent modifications.\n * When multiple users edit the same array simultaneously, key-based patches have better\n * conflict resolution characteristics than index-based patches.\n *\n * The function handles three main operations:\n * 1. **Reordering**: When existing items change positions within the array\n * 2. **Content changes**: When the content of existing items is modified\n * 3. **Insertions/Deletions**: When items are added or removed from the array\n *\n * @param source - The original array with keyed objects\n * @param target - The target array with keyed objects\n * @param path - The path to this array within the document\n * @param patches - Array to accumulate generated patches\n * @returns The patches array with new patches appended\n */\nfunction diffArrayByKey(\n source: KeyedSanityObject[],\n target: KeyedSanityObject[],\n path: Path,\n patches: Patch[],\n) {\n // Create lookup maps for efficient key-based access to array items\n const sourceItemsByKey = new Map(source.map((item) => [item._key, item]))\n const targetItemsByKey = new Map(target.map((item) => [item._key, item]))\n\n // Categorize keys by their presence in source vs target arrays\n const sourceKeys = new Set(sourceItemsByKey.keys())\n const targetKeys = new Set(targetItemsByKey.keys())\n const keysRemovedFromSource = difference(sourceKeys, targetKeys)\n const keysAddedToTarget = difference(targetKeys, sourceKeys)\n const keysInBothArrays = intersection(sourceKeys, targetKeys)\n\n // Handle reordering of existing items within the array.\n // We detect reordering by comparing the relative positions of keys that exist in both arrays,\n // excluding keys that were added or removed (since they don't participate in reordering).\n const sourceKeysStillPresent = Array.from(difference(sourceKeys, keysRemovedFromSource))\n const targetKeysAlreadyPresent = Array.from(difference(targetKeys, keysAddedToTarget))\n\n // Track which keys need to be reordered by comparing their relative positions\n const keyReorderOperations: {sourceKey: string; targetKey: string}[] = []\n\n for (let i = 0; i < keysInBothArrays.size; i++) {\n const keyAtPositionInSource = sourceKeysStillPresent[i]\n const keyAtPositionInTarget = targetKeysAlreadyPresent[i]\n\n // If different keys occupy the same relative position, a reorder is needed\n if (keyAtPositionInSource !== keyAtPositionInTarget) {\n keyReorderOperations.push({\n sourceKey: keyAtPositionInSource,\n targetKey: keyAtPositionInTarget,\n })\n }\n }\n\n // Generate reorder patch if any items changed positions\n if (keyReorderOperations.length) {\n patches.push({\n op: 'reorder',\n path,\n snapshot: source,\n reorders: keyReorderOperations,\n })\n }\n\n // Process content changes for items that exist in both arrays\n for (const key of keysInBothArrays) {\n diffItem(sourceItemsByKey.get(key), targetItemsByKey.get(key), [...path, {_key: key}], patches)\n }\n\n // Remove items that no longer exist in the target array\n for (const keyToRemove of keysRemovedFromSource) {\n patches.push({op: 'unset', path: [...path, {_key: keyToRemove}]})\n }\n\n // Insert new items that were added to the target array\n // We batch consecutive insertions for efficiency and insert them at the correct positions\n if (keysAddedToTarget.size) {\n let insertionAnchorKey: string // The key after which we'll insert pending items\n let itemsPendingInsertion: unknown[] = []\n\n const flushPendingInsertions = () => {\n if (itemsPendingInsertion.length) {\n patches.push({\n op: 'insert',\n // Insert after the anchor key if we have one, otherwise insert at the beginning\n ...(insertionAnchorKey\n ? {position: 'after', path: [...path, {_key: insertionAnchorKey}]}\n : {position: 'before', path: [...path, 0]}),\n items: itemsPendingInsertion,\n })\n }\n }\n\n // Walk through the target array to determine where new items should be inserted\n for (const key of targetKeys) {\n if (keysAddedToTarget.has(key)) {\n // This is a new item - add it to the pending insertion batch\n itemsPendingInsertion.push(targetItemsByKey.get(key)!)\n } else if (keysInBothArrays.has(key)) {\n // This is an existing item - flush any pending insertions before it\n flushPendingInsertions()\n insertionAnchorKey = key\n itemsPendingInsertion = []\n }\n }\n\n // Flush any remaining insertions at the end\n flushPendingInsertions()\n }\n\n return patches\n}\n\n/**\n * Determines whether to use diff-match-patch or fallback to a `set` operation\n * when creating a patch to transform a `source` string to `target` string.\n *\n * `diffMatchPatch` patches are typically preferred to `set` operations because\n * they handle conflicts better (when multiple editors work simultaneously) by\n * preserving the user's intended and allowing for 3-way merges.\n *\n * **Heuristic rationale:**\n *\n * Perf analysis revealed that string length has minimal impact on small,\n * keystroke-level changes, but large text replacements (high change ratio) can\n * trigger worst-case algorithm behavior. The 40% change ratio threshold is a\n * simple heuristic that catches problematic replacement scenarios while\n * allowing the algorithm to excel at insertions and deletions.\n *\n * **Performance characteristics (tested on M2 MacBook Pro):**\n *\n * *Keystroke-level editing (most common use case):*\n * - Small strings (1KB-10KB): 0ms for 1-5 keystrokes, consistently sub-millisecond\n * - Medium strings (50KB-200KB): 0ms for 1-5 keystrokes, consistently sub-millisecond\n * - 10 simultaneous keystrokes: ~12ms on 100KB strings\n *\n * *Copy-paste operations (less frequent):*\n * - Small copy-paste operations (<50KB): 0-10ms regardless of string length\n * - Large insertions/deletions (50KB+): 0-50ms (excellent performance)\n * - Large text replacements (50KB+): 70ms-2s+ (can be slow due to algorithm complexity)\n *\n * **Algorithm details:**\n * Uses Myers' diff algorithm with O(ND) time complexity where N=text length and D=edit distance.\n * Includes optimizations: common prefix/suffix removal, line-mode processing, and timeout protection.\n *\n *\n * **Test methodology:**\n * - Generated realistic word-based text patterns\n * - Simulated actual editing behaviors (keystrokes vs copy-paste)\n * - Measured performance across string sizes from 1KB to 10MB\n * - Validated against edge cases including repetitive text and scattered changes\n *\n * @param source - The previous version of the text\n * @param target - The new version of the text\n * @returns true if diff-match-patch should be used, false if fallback to set operation\n *\n * @example\n * ```typescript\n * // Keystroke editing - always fast\n * shouldUseDiffMatchPatch(largeDoc, largeDocWithTypo) // true, ~0ms\n *\n * // Small paste - always fast\n * shouldUseDiffMatchPatch(doc, docWithSmallInsertion) // true, ~0ms\n *\n * // Large replacement - potentially slow\n * shouldUseDiffMatchPatch(article, completelyDifferentArticle) // false, use set\n * ```\n *\n * Compatible with @sanity/diff-match-patch@3.2.0\n */\nexport function shouldUseDiffMatchPatch(source: string, target: string): boolean {\n const maxLength = Math.max(source.length, target.length)\n\n // Always reject strings larger than our tested size limit\n if (maxLength > DMP_MAX_STRING_SIZE) {\n return false\n }\n\n // For small strings, always use diff-match-patch regardless of change ratio\n // Performance testing showed these are always fast (<10ms)\n if (maxLength < DMP_MIN_SIZE_FOR_RATIO_CHECK) {\n return true\n }\n\n // Calculate the change ratio to detect large text replacements\n // High ratios indicate replacement scenarios which can trigger slow algorithm paths\n const lengthDifference = Math.abs(target.length - source.length)\n const changeRatio = lengthDifference / maxLength\n\n // If change ratio is high, likely a replacement operation that could be slow\n // Fall back to set operation for better user experience\n if (changeRatio > DMP_MAX_STRING_LENGTH_CHANGE_RATIO) {\n return false\n }\n\n // All other cases: use diff-match-patch\n // This covers keystroke editing and insertion/deletion scenarios which perform excellently\n return true\n}\n\nfunction getDiffMatchPatch(source: string, target: string, path: Path): DiffMatchPatch | undefined {\n const last = path.at(-1)\n // don't use diff-match-patch for system keys\n if (typeof last === 'string' && last.startsWith('_')) return undefined\n if (!shouldUseDiffMatchPatch(source, target)) return undefined\n\n try {\n // Using `makePatches(string, string)` directly instead of the multi-step approach e.g.\n // `stringifyPatches(makePatches(cleanupEfficiency(makeDiff(source, target))))`.\n // this is because `makePatches` internally handles diff generation and\n // automatically applies both `cleanupSemantic()` and `cleanupEfficiency()`\n // when beneficial, resulting in cleaner code with near identical performance and\n // better error handling.\n // [source](https://github.com/sanity-io/diff-match-patch/blob/v3.2.0/src/patch/make.ts#L67-L76)\n //\n // Performance validation (M2 MacBook Pro):\n // Both approaches measured at identical performance:\n // - 10KB strings: 0-1ms total processing time\n // - 100KB strings: 0-1ms total processing time\n // - Individual step breakdown: makeDiff(0ms) + cleanup(0ms) + makePatches(0ms) + stringify(~1ms)\n const strPatch = stringifyPatches(makePatches(source, target))\n return {op: 'diffMatchPatch', path, value: strPatch}\n } catch (err) {\n // Fall back to using regular set patch\n return undefined\n }\n}\n\nfunction diffString(source: string, target: string, path: Path, patches: Patch[]) {\n const dmp = getDiffMatchPatch(source, target, path)\n patches.push(dmp ?? {op: 'set', path, value: target})\n return patches\n}\n\nfunction isNotIgnoredKey(key: string) {\n return SYSTEM_KEYS.indexOf(key) === -1\n}\n\n// mutually exclusive operations\ntype SanityPatchOperation =\n | SanitySetPatchOperation\n | SanityUnsetPatchOperation\n | SanityInsertPatchOperation\n | SanityDiffMatchPatchOperation\n\nfunction serializePatches(patches: Patch[], curr?: SanityPatchOperation): SanityPatchOperations[] {\n const [patch, ...rest] = patches\n if (!patch) return curr ? [curr] : []\n\n switch (patch.op) {\n case 'set':\n case 'diffMatchPatch': {\n // TODO: reconfigure eslint to use @typescript-eslint/no-unused-vars\n // eslint-disable-next-line no-unused-vars\n type CurrentOp = Extract<SanityPatchOperation, {[K in typeof patch.op]: {}}>\n const emptyOp = {[patch.op]: {}} as CurrentOp\n\n if (!curr) return serializePatches(patches, emptyOp)\n if (!(patch.op in curr)) return [curr, ...serializePatches(patches, emptyOp)]\n\n Object.assign((curr as CurrentOp)[patch.op], {[pathToString(patch.path)]: patch.value})\n return serializePatches(rest, curr)\n }\n case 'unset': {\n const emptyOp = {unset: []}\n if (!curr) return serializePatches(patches, emptyOp)\n if (!('unset' in curr)) return [curr, ...serializePatches(patches, emptyOp)]\n\n curr.unset.push(pathToString(patch.path))\n return serializePatches(rest, curr)\n }\n case 'insert': {\n if (curr) return [curr, ...serializePatches(patches)]\n\n return [\n {\n insert: {\n [patch.position]: pathToString(patch.path),\n items: patch.items,\n },\n } as SanityInsertPatchOperation,\n ...serializePatches(rest),\n ]\n }\n case 'reorder': {\n if (curr) return [curr, ...serializePatches(patches)]\n\n // REORDER STRATEGY: Two-phase approach to avoid key collisions\n //\n // Problem: Direct key swaps can cause collisions. For example, swapping A↔B:\n // - Set A's content to B: ✓\n // - Set B's content to A: ✗ (A's content was already overwritten)\n //\n // Solution: Use temporary keys as an intermediate step\n // Phase 1: Move all items to temporary keys with their final content\n // Phase 2: Update just the _key property to restore the final keys\n\n // Phase 1: Move items to collision-safe temporary keys\n const tempKeyOperations: SanityPatchOperations = {}\n tempKeyOperations.set = {}\n\n for (const {sourceKey, targetKey} of patch.reorders) {\n const temporaryKey = `__temp_reorder_${sourceKey}__`\n const finalContentForThisPosition =\n patch.snapshot[getIndexForKey(patch.snapshot, targetKey)]\n\n Object.assign(tempKeyOperations.set, {\n [pathToString([...patch.path, {_key: sourceKey}])]: {\n ...finalContentForThisPosition,\n _key: temporaryKey,\n },\n })\n }\n\n // Phase 2: Update _key properties to restore the intended final keys\n const finalKeyOperations: SanityPatchOperations = {}\n finalKeyOperations.set = {}\n\n for (const {sourceKey, targetKey} of patch.reorders) {\n const temporaryKey = `__temp_reorder_${sourceKey}__`\n\n Object.assign(finalKeyOperations.set, {\n [pathToString([...patch.path, {_key: temporaryKey}, '_key'])]: targetKey,\n })\n }\n\n return [tempKeyOperations, finalKeyOperations, ...serializePatches(rest)]\n }\n default: {\n return []\n }\n }\n}\n\nfunction isUniquelyKeyed(arr: unknown[]): arr is KeyedSanityObject[] {\n const seenKeys = new Set<string>()\n\n for (const item of arr) {\n // Each item must be a keyed object with a _key property\n if (!isKeyedObject(item)) return false\n\n // Each _key must be unique within the array\n if (seenKeys.has(item._key)) return false\n\n seenKeys.add(item._key)\n }\n\n return true\n}\n\n// Cache to avoid recomputing key-to-index mappings for the same array\nconst keyToIndexCache = new WeakMap<KeyedSanityObject[], Record<string, number>>()\n\nfunction getIndexForKey(keyedArray: KeyedSanityObject[], targetKey: string) {\n const cachedMapping = keyToIndexCache.get(keyedArray)\n if (cachedMapping) return cachedMapping[targetKey]\n\n // Build a mapping from _key to array index\n const keyToIndexMapping = keyedArray.reduce<Record<string, number>>(\n (mapping, {_key}, arrayIndex) => {\n mapping[_key] = arrayIndex\n return mapping\n },\n {},\n )\n\n keyToIndexCache.set(keyedArray, keyToIndexMapping)\n\n return keyToIndexMapping[targetKey]\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && !!value && !Array.isArray(value)\n}\n\n/**\n * Simplify returns `null` if the value given was `undefined`. This behavior\n * is the same as how `JSON.stringify` works so this is relatively expected\n * behavior.\n */\nfunction nullifyUndefined(item: unknown) {\n if (item === undefined) return null\n return item\n}\n\nfunction yes(_: unknown) {\n return true\n}\n"],"names":["stringifyPatches","makePatches"],"mappings":"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"}