├── .eslintignore ├── .gitignore ├── .releaserc.json ├── test ├── fixtures │ ├── integration │ │ ├── __template__.json │ │ ├── 002.json │ │ ├── 005.json │ │ ├── 003.json │ │ ├── 009.json │ │ ├── 004.json │ │ ├── 001.json │ │ ├── 008.json │ │ ├── 007.json │ │ └── 006.json │ ├── set-and-unset.ts │ ├── nested.ts │ ├── primitive-array-add.ts │ ├── simple.ts │ ├── type-change.ts │ ├── object-array-change.ts │ ├── primitive-array-remove.ts │ ├── object-array-add.ts │ ├── object-array-remove.ts │ ├── image.ts │ ├── portableText.ts │ ├── deep.ts │ ├── data-types.ts │ ├── object-array-reorder.ts │ └── dmp.ts ├── pt.test.ts ├── __snapshots__ │ ├── pt.test.ts.snap │ ├── if-revision.test.ts.snap │ ├── api.test.ts.snap │ ├── primitive-arrays.test.ts.snap │ ├── set-unset.test.ts.snap │ ├── diff-match-patch.test.ts.snap │ └── object-arrays.test.ts.snap ├── if-revision.test.ts ├── primitive-arrays.test.ts ├── set-unset.test.ts ├── diff-match-patch.test.ts ├── api.test.ts ├── validate.test.ts ├── object-arrays.test.ts ├── data-types.test.ts ├── integration.test.ts └── safeguards.test.ts ├── tsconfig.json ├── tsconfig.dist.json ├── .editorconfig ├── src ├── index.ts ├── diffError.ts ├── setOperations.ts ├── paths.ts ├── validate.ts ├── patches.ts └── diffPatch.ts ├── package.config.ts ├── tsconfig.settings.json ├── LICENSE ├── .github └── workflows │ └── main.yml ├── package.json ├── README.md └── CHANGELOG.md /.eslintignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /dist 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | coverage 6 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main"] 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/integration/__template__.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": { 3 | "_id": "foo", 4 | "_type": "foo" 5 | }, 6 | "output": { 7 | "_id": "foo", 8 | "_type": "foo" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": ["**/__tests__/**", "**/*.test.ts"], 5 | "compilerOptions": { 6 | "noEmit": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/set-and-unset.ts: -------------------------------------------------------------------------------- 1 | import * as nested from './nested' 2 | 3 | export const a = {...nested.a, year: 1995, slug: {auto: true, ...nested.a.slug}, arr: [1, 2]} 4 | 5 | export const b = {...nested.b, arr: [1, undefined]} 6 | -------------------------------------------------------------------------------- /test/fixtures/nested.ts: -------------------------------------------------------------------------------- 1 | import * as simple from './simple' 2 | 3 | export const a = {slug: {_type: 'short', current: 'die-hard-iii'}, ...simple.a} 4 | export const b = {...a, slug: {_type: 'slug', current: 'die-hard-with-a-vengeance'}} 5 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": ["**/__tests__/**", "**/*.test.ts"], 5 | "compilerOptions": { 6 | "outDir": "./dist/types", 7 | "rootDir": "." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; editorconfig.org 2 | root = true 3 | charset= utf8 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /test/pt.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | import {diffPatch} from '../src' 3 | import * as fixture from './fixtures/portableText' 4 | 5 | describe('portable text', () => { 6 | test('undo bold change', () => { 7 | expect(diffPatch(fixture.a, fixture.b)).toMatchSnapshot() 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {diffPatch, diffValue} from './diffPatch.js' 2 | export {DiffError} from './diffError.js' 3 | 4 | export type {SanityPatch, SanityPatchMutation, SanityPatchOperations} from './patches.js' 5 | 6 | export type {DocumentStub, PatchOptions} from './diffPatch.js' 7 | 8 | export type {Path, PathSegment} from './paths.js' 9 | -------------------------------------------------------------------------------- /test/fixtures/primitive-array-add.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'die-hard-iii', 3 | title: 'Die Hard with a Vengeance', 4 | characters: ['John McClane'], 5 | } 6 | 7 | export const b = { 8 | _id: 'die-hard-iii', 9 | title: 'Die Hard with a Vengeance', 10 | characters: ['John McClane', 'Simon Gruber'], 11 | } 12 | 13 | export const c = { 14 | _id: 'die-hard-iii', 15 | title: 'Die Hard with a Vengeance', 16 | characters: ['John McClane', 'Simon Gruber', 'Zeus Carver'], 17 | } 18 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | import {visualizer} from 'rollup-plugin-visualizer' 3 | import {name, version} from './package.json' 4 | 5 | export default defineConfig({ 6 | rollup: { 7 | plugins: [ 8 | visualizer({ 9 | emitFile: true, 10 | filename: 'stats.html', 11 | gzipSize: true, 12 | title: `${name}@${version} bundle analysis`, 13 | }), 14 | ], 15 | }, 16 | 17 | tsconfig: 'tsconfig.dist.json', 18 | }) 19 | -------------------------------------------------------------------------------- /test/fixtures/simple.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'die-hard-iii', 3 | _type: 'movie', 4 | _createdAt: new Date().toISOString(), 5 | _updatedAt: new Date(Date.now() + 5000).toISOString(), 6 | _rev: 'datrev', 7 | title: 'Die Hard 3', 8 | rating: 3, 9 | } 10 | 11 | export const b = { 12 | _id: 'die-hard-iii', 13 | _type: 'movie', 14 | _createdAt: new Date().toISOString(), 15 | _updatedAt: new Date().toISOString(), 16 | title: 'Die Hard with a Vengeance', 17 | rating: 4, 18 | } 19 | -------------------------------------------------------------------------------- /test/fixtures/type-change.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'abc123', 3 | _type: 'isrequired', 4 | number: 13, 5 | string: 'foo', 6 | bool: true, 7 | array: ['zero', 1, {two: {levels: {deep: 'value'}}}], 8 | object: {f13: 13}, 9 | unset: 'me', 10 | } 11 | 12 | export const b = { 13 | _id: 'abc123', 14 | _type: 'isrequired', 15 | number: 1337, 16 | string: 'bar', 17 | bool: false, 18 | array: [0, 'one', {two: {levels: {other: 'value'}}}], 19 | object: {b12: '12', f13: null}, 20 | } 21 | -------------------------------------------------------------------------------- /test/fixtures/integration/002.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": { 3 | "_type": "fixture", 4 | "delete": { 5 | "l0o": { 6 | "l1s": "abcd", 7 | "l1o": { 8 | "l2s": "efed" 9 | } 10 | }, 11 | "l0a": ["abcd", ["efcg"]] 12 | } 13 | }, 14 | "output": { 15 | "_type": "fixture", 16 | "add": { 17 | "l0o": { 18 | "l1s": "abcd", 19 | "l1o": { 20 | "l2s": "efed" 21 | } 22 | }, 23 | "l0a": ["abcd", ["efcg"]] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/fixtures/integration/005.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": { 3 | "_id": "foo", 4 | "_type": "foo", 5 | "str": "true", 6 | "num_int": 3, 7 | "num_float": 99.99, 8 | "num_int_float": 5, 9 | "bool": true, 10 | "obj": { 11 | "0": "i0", 12 | "1": "i1" 13 | } 14 | }, 15 | "output": { 16 | "_id": "foo", 17 | "_type": "foo", 18 | "str": true, 19 | "num_int": "3", 20 | "num_float": "99.99", 21 | "num_int_float": 5.0, 22 | "bool": "true", 23 | "obj": ["i0", "i1"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/integration/003.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": { 3 | "_id": "foo", 4 | "_type": "foo", 5 | "arr": [ 6 | 1, 7 | 2, 8 | 6, 9 | { 10 | "str": "bcded", 11 | "arr": ["a", "b", 37.8] 12 | }, 13 | "string" 14 | ] 15 | }, 16 | "output": { 17 | "_id": "foo", 18 | "_type": "foo", 19 | "arr": [ 20 | 1, 21 | 2, 22 | 6, 23 | { 24 | "str": "bcdef", 25 | "arr": ["a", "b", 37.8] 26 | }, 27 | "string" 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/fixtures/object-array-change.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'die-hard-iii', 3 | title: 'Die Hard with a Vengeance', 4 | characters: [ 5 | {_key: 'john', name: 'John McClane'}, 6 | {_key: 'simon', name: 'Simon Gruber'}, 7 | {_key: 'zeus', name: 'Zeus Carver'}, 8 | ], 9 | } 10 | 11 | export const b = { 12 | _id: 'die-hard-iii', 13 | title: 'Die Hard with a Vengeance', 14 | characters: [ 15 | {_key: 'john', name: 'John McClane'}, 16 | {_key: 'simon', name: 'Simon Grüber'}, 17 | {_key: 'zeus', name: 'Zeus Carver'}, 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /test/fixtures/primitive-array-remove.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'die-hard-iii', 3 | title: 'Die Hard with a Vengeance', 4 | characters: ['John McClane', 'Simon Gruber', 'Zeus Carver'], 5 | } 6 | 7 | export const b = { 8 | _id: 'die-hard-iii', 9 | title: 'Die Hard with a Vengeance', 10 | characters: ['John McClane', 'Simon Gruber'], 11 | } 12 | 13 | export const c = { 14 | _id: 'die-hard-iii', 15 | title: 'Die Hard with a Vengeance', 16 | characters: ['John McClane'], 17 | } 18 | 19 | export const d = { 20 | _id: 'die-hard-iii', 21 | title: 'Die Hard with a Vengeance', 22 | characters: ['John McClane', 'Zeus Carver'], 23 | } 24 | -------------------------------------------------------------------------------- /test/fixtures/object-array-add.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'die-hard-iii', 3 | title: 'Die Hard with a Vengeance', 4 | characters: [{_key: 'john', name: 'John McClane'}], 5 | } 6 | 7 | export const b = { 8 | _id: 'die-hard-iii', 9 | title: 'Die Hard with a Vengeance', 10 | characters: [ 11 | {_key: 'john', name: 'John McClane'}, 12 | {_key: 'simon', name: 'Simon Gruber'}, 13 | ], 14 | } 15 | 16 | export const c = { 17 | _id: 'die-hard-iii', 18 | title: 'Die Hard with a Vengeance', 19 | characters: [ 20 | {_key: 'john', name: 'John McClane'}, 21 | {_key: 'simon', name: 'Simon Gruber'}, 22 | {_key: 'zeus', name: 'Zeus Carver'}, 23 | ], 24 | } 25 | -------------------------------------------------------------------------------- /test/__snapshots__/pt.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`portable text > undo bold change 1`] = ` 4 | [ 5 | { 6 | "patch": { 7 | "diffMatchPatch": { 8 | "portableText[_key=="920ebbba9ada"].children[_key=="80847f72abfd"].text": "@@ -15,8 +15,20 @@ 9 | med en 10 | +empty slate. 11 | ", 12 | }, 13 | "id": "die-hard-iii", 14 | }, 15 | }, 16 | { 17 | "patch": { 18 | "id": "die-hard-iii", 19 | "unset": [ 20 | "portableText[_key=="920ebbba9ada"].children[_key=="1bda8032e34c"]", 21 | "portableText[_key=="920ebbba9ada"].children[_key=="cb0568a8e746"]", 22 | ], 23 | }, 24 | }, 25 | ] 26 | `; 27 | -------------------------------------------------------------------------------- /test/fixtures/integration/009.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": { 3 | "_id": "abc123", 4 | "_type": "isrequired", 5 | "number": 13, 6 | "string": "foo", 7 | "bool": true, 8 | "array": ["zero", 1, {"two": {"levels": {"deep": "value"}}}], 9 | "object": { 10 | "other": 13, 11 | "dashed-unset-prop": "start", 12 | "dashed-set-prop": "start" 13 | }, 14 | "unset": "me" 15 | }, 16 | "output": { 17 | "_id": "abc123", 18 | "_type": "isrequired", 19 | "number": 1337, 20 | "string": "bar", 21 | "bool": false, 22 | "array": [0, "one", {"two": {"levels": {"other": "value"}}}], 23 | "object": {"target": "value", "other": null, "dashed-set-prop": "end"} 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/diffError.ts: -------------------------------------------------------------------------------- 1 | import {type Path, pathToString} from './paths.js' 2 | 3 | /** 4 | * Represents an error that occurred during a diff process. 5 | * Contains `path`, `value` and `serializedPath` properties, 6 | * which is helpful for debugging and showing friendly messages. 7 | * 8 | * @public 9 | */ 10 | export class DiffError extends Error { 11 | public path: Path 12 | public value: unknown 13 | public serializedPath: string 14 | 15 | constructor(message: string, path: Path, value?: unknown) { 16 | const serializedPath = pathToString(path) 17 | super(`${message} (at '${serializedPath}')`) 18 | 19 | this.path = path 20 | this.serializedPath = serializedPath 21 | this.value = value 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/setOperations.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export function difference(source: Set, target: Set): Set { 4 | if ('difference' in Set.prototype) { 5 | return source.difference(target) 6 | } 7 | 8 | const result = new Set() 9 | for (const item of source) { 10 | if (!target.has(item)) { 11 | result.add(item) 12 | } 13 | } 14 | return result 15 | } 16 | 17 | export function intersection(source: Set, target: Set): Set { 18 | if ('intersection' in Set.prototype) { 19 | return source.intersection(target) 20 | } 21 | 22 | const result = new Set() 23 | for (const item of source) { 24 | if (target.has(item)) { 25 | result.add(item) 26 | } 27 | } 28 | return result 29 | } 30 | -------------------------------------------------------------------------------- /test/fixtures/integration/004.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": { 3 | "_id": "foo", 4 | "_type": "foo", 5 | "str": "abcde", 6 | "num_int": 13, 7 | "num_float": 39.39, 8 | "bool": true, 9 | "arr": ["arr0", 21, {"str": "pek3f", "num": 1}], 10 | "obj": { 11 | "str": "bcded", 12 | "num": 19, 13 | "arr": [17, "str", {"str": "eafeb"}], 14 | "obj": {"str": "efj3", "num": 14} 15 | }, 16 | "null": null 17 | }, 18 | "output": { 19 | "_id": "foo", 20 | "_type": "foo", 21 | "str": "abcde", 22 | "num_int": 13, 23 | "num_float": 39.39, 24 | "bool": true, 25 | "arr": ["arr0", 21, {"str": "changed", "num": 1}], 26 | "obj": { 27 | "str": "bcded", 28 | "new": "added", 29 | "arr": [17, "str", {"str": "changed"}], 30 | "obj": {"str": "changed", "num": 9999} 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2023", "DOM"], 4 | "module": "Preserve", 5 | "target": "ES2022", 6 | 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | 11 | // Strict type-checking 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true, 15 | "strictFunctionTypes": true, 16 | "strictPropertyInitialization": true, 17 | "noImplicitThis": true, 18 | "alwaysStrict": true, 19 | 20 | // Additional checks 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noImplicitReturns": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "skipLibCheck": true, 26 | 27 | // Module resolution 28 | "moduleResolution": "node", 29 | "allowSyntheticDefaultImports": true, 30 | "esModuleInterop": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/fixtures/object-array-remove.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'die-hard-iii', 3 | title: 'Die Hard with a Vengeance', 4 | characters: [ 5 | {_key: 'john', name: 'John McClane'}, 6 | {_key: 'simon', name: 'Simon Gruber'}, 7 | {_key: 'zeus', name: 'Zeus Carver'}, 8 | ], 9 | } 10 | 11 | export const b = { 12 | _id: 'die-hard-iii', 13 | title: 'Die Hard with a Vengeance', 14 | characters: [ 15 | {_key: 'john', name: 'John McClane'}, 16 | {_key: 'simon', name: 'Simon Gruber'}, 17 | ], 18 | } 19 | 20 | export const c = { 21 | _id: 'die-hard-iii', 22 | title: 'Die Hard with a Vengeance', 23 | characters: [{_key: 'john', name: 'John McClane'}], 24 | } 25 | 26 | export const d = { 27 | _id: 'die-hard-iii', 28 | title: 'Die Hard with a Vengeance', 29 | characters: [ 30 | {_key: 'john', name: 'John McClane'}, 31 | {_key: 'zeus', name: 'Zeus Carver'}, 32 | ], 33 | } 34 | -------------------------------------------------------------------------------- /test/if-revision.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | import {diffPatch} from '../src' 3 | import * as simple from './fixtures/simple' 4 | 5 | describe('ifRevisionID', () => { 6 | test('can apply revision constraint (uppercase)', () => { 7 | expect(diffPatch(simple.a, simple.b, {ifRevisionID: 'abc123'})).toMatchSnapshot() 8 | }) 9 | 10 | test('can apply revision constraint (inferred from document)', () => { 11 | expect(diffPatch(simple.a, simple.b, {ifRevisionID: true})).toMatchSnapshot() 12 | }) 13 | 14 | test('throws if revision constraint is `true` but no `_rev` is given', () => { 15 | const doc = {...simple.a, _rev: undefined} 16 | expect(() => diffPatch(doc, simple.b, {ifRevisionID: true})).toThrowErrorMatchingInlineSnapshot( 17 | `[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.]`, 18 | ) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/fixtures/image.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'die-hard-iii', 3 | _type: 'movie', 4 | _rev: 'datrev', 5 | image: { 6 | _type: 'image', 7 | asset: { 8 | _type: 'reference', 9 | _ref: 'image-a', 10 | _weak: true, 11 | }, 12 | hotspot: { 13 | height: 1, 14 | width: 1, 15 | x: 0.5, 16 | y: 0.5, 17 | }, 18 | crop: { 19 | top: 0, 20 | left: 0, 21 | right: 0, 22 | bottom: 0, 23 | }, 24 | }, 25 | } 26 | 27 | export const b = { 28 | _id: 'die-hard-iii', 29 | _type: 'movie', 30 | _rev: 'datrev', 31 | image: { 32 | _type: 'image', 33 | asset: { 34 | _type: 'reference', 35 | _ref: 'image-b', 36 | _weak: true, 37 | }, 38 | hotspot: { 39 | height: 0.75, 40 | width: 0.75, 41 | x: 0.48, 42 | y: 0.5, 43 | }, 44 | crop: { 45 | top: 0.1, 46 | left: 0.2, 47 | right: 0.2, 48 | bottom: 0.1, 49 | }, 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /test/fixtures/portableText.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'die-hard-iii', 3 | _type: 'movie', 4 | portableText: [ 5 | { 6 | _key: '920ebbba9ada', 7 | _type: 'block', 8 | children: [ 9 | {_key: '80847f72abfd', _type: 'span', marks: [], text: 'La oss begynne med en '}, 10 | {_key: '1bda8032e34c', _type: 'span', marks: ['strong'], text: 'empty'}, 11 | {_key: 'cb0568a8e746', _type: 'span', marks: [], text: ' slate.'}, 12 | ], 13 | markDefs: [], 14 | style: 'normal', 15 | }, 16 | ], 17 | } 18 | 19 | export const b = { 20 | _id: 'die-hard-iii', 21 | _type: 'movie', 22 | portableText: [ 23 | { 24 | _key: '920ebbba9ada', 25 | _type: 'block', 26 | children: [ 27 | { 28 | _key: '80847f72abfd', 29 | _type: 'span', 30 | marks: [], 31 | text: 'La oss begynne med en empty slate.', 32 | }, 33 | ], 34 | markDefs: [], 35 | style: 'normal', 36 | }, 37 | ], 38 | } 39 | -------------------------------------------------------------------------------- /test/primitive-arrays.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | import {diffPatch} from '../src' 3 | import * as primitiveArrayAdd from './fixtures/primitive-array-add' 4 | import * as primitiveArrayRemove from './fixtures/primitive-array-remove' 5 | 6 | describe('primitive arrays', () => { 7 | test('add to end (single)', () => { 8 | expect(diffPatch(primitiveArrayAdd.a, primitiveArrayAdd.b)).toMatchSnapshot() 9 | }) 10 | 11 | test('add to end (multiple)', () => { 12 | expect(diffPatch(primitiveArrayAdd.a, primitiveArrayAdd.c)).toMatchSnapshot() 13 | }) 14 | 15 | test('remove from end (single)', () => { 16 | expect(diffPatch(primitiveArrayRemove.a, primitiveArrayRemove.b)).toMatchSnapshot() 17 | }) 18 | 19 | test('remove from end (multiple)', () => { 20 | expect(diffPatch(primitiveArrayRemove.a, primitiveArrayRemove.c)).toMatchSnapshot() 21 | }) 22 | 23 | test('remove from middle (single)', () => { 24 | expect(diffPatch(primitiveArrayRemove.a, primitiveArrayRemove.d)).toMatchSnapshot() 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/__snapshots__/if-revision.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ifRevisionID > can apply revision constraint (inferred from document) 1`] = ` 4 | [ 5 | { 6 | "patch": { 7 | "diffMatchPatch": { 8 | "title": "@@ -6,5 +6,20 @@ 9 | ard 10 | -3 11 | +with a Vengeance 12 | ", 13 | }, 14 | "id": "die-hard-iii", 15 | "ifRevisionID": "datrev", 16 | }, 17 | }, 18 | { 19 | "patch": { 20 | "id": "die-hard-iii", 21 | "set": { 22 | "rating": 4, 23 | }, 24 | }, 25 | }, 26 | ] 27 | `; 28 | 29 | exports[`ifRevisionID > can apply revision constraint (uppercase) 1`] = ` 30 | [ 31 | { 32 | "patch": { 33 | "diffMatchPatch": { 34 | "title": "@@ -6,5 +6,20 @@ 35 | ard 36 | -3 37 | +with a Vengeance 38 | ", 39 | }, 40 | "id": "die-hard-iii", 41 | "ifRevisionID": "abc123", 42 | }, 43 | }, 44 | { 45 | "patch": { 46 | "id": "die-hard-iii", 47 | "set": { 48 | "rating": 4, 49 | }, 50 | }, 51 | }, 52 | ] 53 | `; 54 | -------------------------------------------------------------------------------- /test/set-unset.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | import {diffPatch} from '../src' 3 | import * as deep from './fixtures/deep' 4 | import * as image from './fixtures/image' 5 | import * as nested from './fixtures/nested' 6 | import * as setAndUnset from './fixtures/set-and-unset' 7 | import * as simple from './fixtures/simple' 8 | 9 | describe('set/unset', () => { 10 | test('simple root-level changes', () => { 11 | expect(diffPatch(simple.a, simple.b)).toMatchSnapshot() 12 | }) 13 | 14 | test('basic nested changes', () => { 15 | expect(diffPatch(nested.a, nested.b)).toMatchSnapshot() 16 | }) 17 | 18 | test('set + unset, nested changes', () => { 19 | expect(diffPatch(setAndUnset.a, setAndUnset.b)).toMatchSnapshot() 20 | }) 21 | 22 | test('set + unset, image example', () => { 23 | expect(diffPatch(image.a, image.b)).toMatchSnapshot() 24 | }) 25 | 26 | test('deep nested changes', () => { 27 | expect(diffPatch(deep.a, deep.b)).toMatchSnapshot() 28 | }) 29 | 30 | test('no diff', () => { 31 | expect(diffPatch(nested.a, nested.a)).toEqual([]) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sanity.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/fixtures/deep.ts: -------------------------------------------------------------------------------- 1 | import * as simple from './simple' 2 | 3 | export const a = { 4 | ...simple.a, 5 | products: [ 6 | { 7 | _key: 'item-1', 8 | name: 'Item 1', 9 | comparisonFields: { 10 | 'support-level': 'basic', 11 | }, 12 | variants: [ 13 | { 14 | _key: 'variant-1', 15 | name: 'Variant 1', 16 | price: 100, 17 | 'lace-type': 'waxed', 18 | }, 19 | { 20 | _key: 'variant-2', 21 | name: 'Variant 2', 22 | price: 200, 23 | 'lace-type': 'knurled', 24 | }, 25 | ], 26 | }, 27 | ], 28 | } 29 | export const b = { 30 | ...a, 31 | products: [ 32 | { 33 | ...a.products[0], 34 | comparisonFields: {'support-level': 'advanced'}, 35 | variants: [ 36 | { 37 | _key: 'variant-1', 38 | name: 'Variant 1', 39 | price: 100, 40 | 'lace-type': 'slick', 41 | }, 42 | { 43 | _key: 'variant-2', 44 | name: 'Variant 2', 45 | price: 200, 46 | }, 47 | ], 48 | }, 49 | ], 50 | } 51 | -------------------------------------------------------------------------------- /test/diff-match-patch.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | import {diffPatch} from '../src' 3 | import * as dmp from './fixtures/dmp' 4 | 5 | describe('diff match patch', () => { 6 | test('respects absolute length threshold', () => { 7 | expect(diffPatch(dmp.absoluteIn, dmp.absoluteOut)).toMatchSnapshot() 8 | }) 9 | 10 | test('respects relative length threshold', () => { 11 | expect(diffPatch(dmp.relativeOverIn, dmp.relativeOverOut)).toMatchSnapshot() 12 | }) 13 | 14 | test('respects relative length threshold (allowed)', () => { 15 | expect(diffPatch(dmp.relativeUnderIn, dmp.relativeUnderOut)).toMatchSnapshot() 16 | }) 17 | 18 | test('does not use dmp for "privates" (underscore-prefixed keys)', () => { 19 | expect(diffPatch(dmp.privateChangeIn, dmp.privateChangeOut)).toMatchSnapshot() 20 | }) 21 | 22 | test('does not use dmp for "type changes" (number => string)', () => { 23 | expect(diffPatch(dmp.typeChangeIn, dmp.typeChangeOut)).toMatchSnapshot() 24 | }) 25 | 26 | test('handles patching with unicode surrogate pairs', () => { 27 | expect( 28 | diffPatch(dmp.unicodeChangeIn, dmp.unicodeChangeOut, { 29 | diffMatchPatch: {lengthThresholdAbsolute: 1, lengthThresholdRelative: 3}, 30 | }), 31 | ).toMatchSnapshot() 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /test/api.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | import {diffPatch} from '../src' 3 | import {pathToString} from '../src/paths' 4 | import * as setAndUnset from './fixtures/set-and-unset' 5 | 6 | describe('module api', () => { 7 | test('can include ifRevisionID', () => { 8 | expect(diffPatch(setAndUnset.a, setAndUnset.b, {ifRevisionID: 'foo'})).toMatchSnapshot() 9 | }) 10 | 11 | test('can pass different document ID', () => { 12 | expect(diffPatch(setAndUnset.a, setAndUnset.b, {id: 'moop'})).toMatchSnapshot() 13 | }) 14 | 15 | test('throws if ids do not match', () => { 16 | const b = {...setAndUnset.b, _id: 'zing'} 17 | expect(() => diffPatch(setAndUnset.a, b)).toThrowError( 18 | `_id on source and target not present or differs, specify document id the mutations should be applied to`, 19 | ) 20 | }) 21 | 22 | test('does not throw if ids do not match and id is provided', () => { 23 | const b = {...setAndUnset.b, _id: 'zing'} 24 | expect(diffPatch(setAndUnset.a, b, {id: 'yup'})).not.toHaveLength(0) 25 | }) 26 | 27 | test('pathToString throws on invalid path segments', () => { 28 | expect(() => 29 | pathToString(['foo', {foo: 'bar'} as any, 'blah']), 30 | ).toThrowErrorMatchingInlineSnapshot(`[Error: Unsupported path segment "[object Object]"]`) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /test/fixtures/data-types.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'die-hard-iii', 3 | _type: 'movie', 4 | title: 'Die Hard 3', // String 5 | rating: 3.14, // Float 6 | isFeatured: true, // Boolean 7 | characters: ['John McClane'], // Array 8 | slug: {current: 'die-hard-iii'}, // Object 9 | year: 1994, // Integer 10 | } 11 | 12 | export const b = { 13 | _id: 'die-hard-iii', 14 | _type: 'movie', 15 | title: 'Die Hard with a Vengeance', // String 16 | rating: 4.24, // Float 17 | isFeatured: false, // Boolean 18 | characters: ['Simon Gruber'], // Array 19 | slug: {current: 'die-hard-with-a-vengeance'}, // Object 20 | year: 1995, // Integer 21 | } 22 | 23 | export const c = { 24 | _id: 'die-hard-iii', 25 | _type: 'movie', 26 | title: ['Die Hard with a Vengeance'], // String => Array 27 | rating: {current: 4.24}, // Float => Object 28 | isFeatured: 'yup', // Boolean => String 29 | characters: {simon: 'Simon Gruber'}, // Array => Object 30 | slug: 'die-hard-with-a-vengeance', // Object => String 31 | year: {released: 1995}, // Integer => Object 32 | } 33 | 34 | export const d = { 35 | _id: 'die-hard-iii', 36 | _type: 'movie', 37 | title: 'Die Hard 3', // String 38 | rating: 3.14, // Float 39 | isFeatured: true, // Boolean 40 | characters: ['John McClane'], // Array 41 | slug: ['die-hard-with-a-vengeance'], // Object => Array 42 | year: 1994, // Integer 43 | } 44 | -------------------------------------------------------------------------------- /test/fixtures/integration/001.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": { 3 | "_type": "fixture", 4 | "AidanGillen": { 5 | "array": ["Game of Thron\"es", "The Wire"], 6 | "string": "some string", 7 | "int": 2, 8 | "aboolean": true, 9 | "boolean": true, 10 | "object": { 11 | "foo": "bar", 12 | "object1": {"newprop1": "newprop value"}, 13 | "object2": {"newprop1": "newprop value"}, 14 | "object3": {"newprop1": "newprop value"}, 15 | "object4": {"newprop1": "newprop value"} 16 | } 17 | }, 18 | "AmyRyan": {"one": "In Treatment", "two": "The Wire"}, 19 | "AnnieFitzgerald": ["Big Love", "True Blood"], 20 | "AnwanGlover": ["Treme", "The Wire"], 21 | "AlexanderSkarsgard": ["Generation Kill", "True Blood"], 22 | "ClarkePeters": null 23 | }, 24 | "output": { 25 | "_type": "fixture", 26 | "AidanGillen": { 27 | "array": ["Game of Thrones", "The Wire"], 28 | "string": "some string", 29 | "int": "2", 30 | "otherint": 4, 31 | "aboolean": "true", 32 | "boolean": false, 33 | "object": {"foo": "bar"} 34 | }, 35 | "AmyRyan": ["In Treatment", "The Wire"], 36 | "AnnieFitzgerald": ["True Blood", "Big Love", "The Sopranos", "Oz"], 37 | "AnwanGlover": ["Treme", "The Wire"], 38 | "AlexanderSkarsgaard": ["Generation Kill", "True Blood"], 39 | "AliceFarmer": ["The Corner", "Oz", "The Wire"] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/validate.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | import {validateDocument} from '../src/validate' 3 | 4 | describe('validate', () => { 5 | test('validate does not throw on legal documents', () => { 6 | const doc = { 7 | _id: 'abc123', 8 | arr: [1, 2, 3], 9 | obj: {nested: 'values'}, 10 | bool: true, 11 | number: 1337, 12 | } 13 | 14 | expect(() => validateDocument(doc)).not.toThrow() 15 | expect(validateDocument(doc)).toBe(true) 16 | }) 17 | 18 | test('validate throws on multidimensional arrays', () => { 19 | expect(() => { 20 | validateDocument({_id: 'abc123', arr: [['foo', 'bar']]}) 21 | }).toThrowErrorMatchingInlineSnapshot( 22 | `[Error: Multi-dimensional arrays not supported (at 'arr[0]')]`, 23 | ) 24 | }) 25 | 26 | test('invalid keys', () => { 27 | expect(() => { 28 | validateDocument({_id: 'agot', _type: 'book', author: {_key: '$', name: 'GRRM'}}) 29 | }).toThrowErrorMatchingInlineSnapshot( 30 | `[Error: Invalid key - use less exotic characters (at 'author._key')]`, 31 | ) 32 | 33 | expect(() => { 34 | validateDocument({_id: 'agot', _type: 'book', author: {_ref: '$foo'}}) 35 | }).toThrowErrorMatchingInlineSnapshot( 36 | `[Error: Invalid key - use less exotic characters (at 'author._ref')]`, 37 | ) 38 | 39 | expect(() => { 40 | validateDocument({_id: 'agot', _type: 'book', author: {_type: 'some%value'}}) 41 | }).toThrowErrorMatchingInlineSnapshot( 42 | `[Error: Invalid key - use less exotic characters (at 'author._type')]`, 43 | ) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/__snapshots__/api.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`module api > can include ifRevisionID 1`] = ` 4 | [ 5 | { 6 | "patch": { 7 | "id": "die-hard-iii", 8 | "ifRevisionID": "foo", 9 | "unset": [ 10 | "year", 11 | "slug.auto", 12 | ], 13 | }, 14 | }, 15 | { 16 | "patch": { 17 | "id": "die-hard-iii", 18 | "set": { 19 | "slug._type": "slug", 20 | }, 21 | }, 22 | }, 23 | { 24 | "patch": { 25 | "diffMatchPatch": { 26 | "slug.current": "@@ -6,7 +6,20 @@ 27 | ard- 28 | -iii 29 | +with-a-vengeance 30 | ", 31 | }, 32 | "id": "die-hard-iii", 33 | }, 34 | }, 35 | { 36 | "patch": { 37 | "id": "die-hard-iii", 38 | "set": { 39 | "arr[1]": null, 40 | }, 41 | }, 42 | }, 43 | ] 44 | `; 45 | 46 | exports[`module api > can pass different document ID 1`] = ` 47 | [ 48 | { 49 | "patch": { 50 | "id": "moop", 51 | "unset": [ 52 | "year", 53 | "slug.auto", 54 | ], 55 | }, 56 | }, 57 | { 58 | "patch": { 59 | "id": "moop", 60 | "set": { 61 | "slug._type": "slug", 62 | }, 63 | }, 64 | }, 65 | { 66 | "patch": { 67 | "diffMatchPatch": { 68 | "slug.current": "@@ -6,7 +6,20 @@ 69 | ard- 70 | -iii 71 | +with-a-vengeance 72 | ", 73 | }, 74 | "id": "moop", 75 | }, 76 | }, 77 | { 78 | "patch": { 79 | "id": "moop", 80 | "set": { 81 | "arr[1]": null, 82 | }, 83 | }, 84 | }, 85 | ] 86 | `; 87 | -------------------------------------------------------------------------------- /test/__snapshots__/primitive-arrays.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`primitive arrays > add to end (multiple) 1`] = ` 4 | [ 5 | { 6 | "patch": { 7 | "id": "die-hard-iii", 8 | "insert": { 9 | "after": "characters[-1]", 10 | "items": [ 11 | "Simon Gruber", 12 | "Zeus Carver", 13 | ], 14 | }, 15 | }, 16 | }, 17 | ] 18 | `; 19 | 20 | exports[`primitive arrays > add to end (single) 1`] = ` 21 | [ 22 | { 23 | "patch": { 24 | "id": "die-hard-iii", 25 | "insert": { 26 | "after": "characters[-1]", 27 | "items": [ 28 | "Simon Gruber", 29 | ], 30 | }, 31 | }, 32 | }, 33 | ] 34 | `; 35 | 36 | exports[`primitive arrays > remove from end (multiple) 1`] = ` 37 | [ 38 | { 39 | "patch": { 40 | "id": "die-hard-iii", 41 | "unset": [ 42 | "characters[1:]", 43 | ], 44 | }, 45 | }, 46 | ] 47 | `; 48 | 49 | exports[`primitive arrays > remove from end (single) 1`] = ` 50 | [ 51 | { 52 | "patch": { 53 | "id": "die-hard-iii", 54 | "unset": [ 55 | "characters[2]", 56 | ], 57 | }, 58 | }, 59 | ] 60 | `; 61 | 62 | exports[`primitive arrays > remove from middle (single) 1`] = ` 63 | [ 64 | { 65 | "patch": { 66 | "id": "die-hard-iii", 67 | "unset": [ 68 | "characters[2]", 69 | ], 70 | }, 71 | }, 72 | { 73 | "patch": { 74 | "diffMatchPatch": { 75 | "characters[1]": "@@ -1,12 +1,11 @@ 76 | -Simon Grub 77 | +Zeus Carv 78 | er 79 | ", 80 | }, 81 | "id": "die-hard-iii", 82 | }, 83 | }, 84 | ] 85 | `; 86 | -------------------------------------------------------------------------------- /src/paths.ts: -------------------------------------------------------------------------------- 1 | const IS_DOTTABLE_RE = /^[A-Za-z_][A-Za-z0-9_]*$/ 2 | 3 | /** 4 | * A segment of a path 5 | * 6 | * @public 7 | */ 8 | export type PathSegment = 9 | | string // Property 10 | | number // Array index 11 | | {_key: string} // Array `_key` lookup 12 | | [number | '', number | ''] // From/to array index 13 | 14 | /** 15 | * An array of path segments representing a path in a document 16 | * 17 | * @public 18 | */ 19 | export type Path = PathSegment[] 20 | 21 | /** 22 | * Converts an array path to a string path 23 | * 24 | * @param path - The array path to convert 25 | * @returns A stringified path 26 | * @internal 27 | */ 28 | export function pathToString(path: Path): string { 29 | return path.reduce((target: string, segment: PathSegment, i: number) => { 30 | if (Array.isArray(segment)) { 31 | return `${target}[${segment.join(':')}]` 32 | } 33 | 34 | if (isKeyedObject(segment)) { 35 | return `${target}[_key=="${segment._key}"]` 36 | } 37 | 38 | if (typeof segment === 'number') { 39 | return `${target}[${segment}]` 40 | } 41 | 42 | if (typeof segment === 'string' && !IS_DOTTABLE_RE.test(segment)) { 43 | return `${target}['${segment}']` 44 | } 45 | 46 | if (typeof segment === 'string') { 47 | const separator = i === 0 ? '' : '.' 48 | return `${target}${separator}${segment}` 49 | } 50 | 51 | throw new Error(`Unsupported path segment "${segment}"`) 52 | }, '') 53 | } 54 | 55 | /** 56 | * An object (record) that has a `_key` property 57 | * 58 | * @internal 59 | */ 60 | export interface KeyedSanityObject { 61 | [key: string]: unknown 62 | _key: string 63 | } 64 | 65 | export function isKeyedObject(obj: unknown): obj is KeyedSanityObject { 66 | return typeof obj === 'object' && !!obj && '_key' in obj && typeof obj._key === 'string' 67 | } 68 | -------------------------------------------------------------------------------- /test/object-arrays.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | import {diffPatch} from '../src' 3 | import * as objectArrayAdd from './fixtures/object-array-add' 4 | import * as objectArrayRemove from './fixtures/object-array-remove' 5 | import * as objectArrayChange from './fixtures/object-array-change' 6 | import * as objectArrayReorder from './fixtures/object-array-reorder' 7 | 8 | describe('object arrays', () => { 9 | test('change item', () => { 10 | expect(diffPatch(objectArrayChange.a, objectArrayChange.b)).toMatchSnapshot() 11 | }) 12 | 13 | test('add to end (single)', () => { 14 | expect(diffPatch(objectArrayAdd.a, objectArrayAdd.b)).toMatchSnapshot() 15 | }) 16 | 17 | test('add to end (multiple)', () => { 18 | expect(diffPatch(objectArrayAdd.a, objectArrayAdd.c)).toMatchSnapshot() 19 | }) 20 | 21 | test('remove from end (single)', () => { 22 | expect(diffPatch(objectArrayRemove.a, objectArrayRemove.b)).toMatchSnapshot() 23 | }) 24 | 25 | test('remove from end (multiple)', () => { 26 | expect(diffPatch(objectArrayRemove.a, objectArrayRemove.c)).toMatchSnapshot() 27 | }) 28 | 29 | test('remove from middle (single)', () => { 30 | expect(diffPatch(objectArrayRemove.a, objectArrayRemove.d)).toMatchSnapshot() 31 | }) 32 | 33 | test('reorder (simple swap)', () => { 34 | expect(diffPatch(objectArrayReorder.a, objectArrayReorder.b)).toMatchSnapshot() 35 | }) 36 | 37 | test('reorder (complete reverse)', () => { 38 | expect(diffPatch(objectArrayReorder.a, objectArrayReorder.c)).toMatchSnapshot() 39 | }) 40 | 41 | test('reorder with content change', () => { 42 | expect(diffPatch(objectArrayReorder.a, objectArrayReorder.d)).toMatchSnapshot() 43 | }) 44 | 45 | test('reorder with insertion and deletion', () => { 46 | expect(diffPatch(objectArrayReorder.a, objectArrayReorder.e)).toMatchSnapshot() 47 | }) 48 | 49 | test('reorder with size change (multiple insertions)', () => { 50 | expect(diffPatch(objectArrayReorder.a, objectArrayReorder.f)).toMatchSnapshot() 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /test/fixtures/object-array-reorder.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | _id: 'die-hard-iii', 3 | title: 'Die Hard with a Vengeance', 4 | characters: [ 5 | {_key: 'john', name: 'John McClane'}, 6 | {_key: 'simon', name: 'Simon Gruber'}, 7 | {_key: 'zeus', name: 'Zeus Carver'}, 8 | ], 9 | } 10 | 11 | // Simple reorder: swap first two items 12 | export const b = { 13 | _id: 'die-hard-iii', 14 | title: 'Die Hard with a Vengeance', 15 | characters: [ 16 | {_key: 'simon', name: 'Simon Gruber'}, 17 | {_key: 'john', name: 'John McClane'}, 18 | {_key: 'zeus', name: 'Zeus Carver'}, 19 | ], 20 | } 21 | 22 | // Complex reorder: completely reverse the array 23 | export const c = { 24 | _id: 'die-hard-iii', 25 | title: 'Die Hard with a Vengeance', 26 | characters: [ 27 | {_key: 'zeus', name: 'Zeus Carver'}, 28 | {_key: 'simon', name: 'Simon Gruber'}, 29 | {_key: 'john', name: 'John McClane'}, 30 | ], 31 | } 32 | 33 | // Reorder with content change: move zeus to front and change his name 34 | export const d = { 35 | _id: 'die-hard-iii', 36 | title: 'Die Hard with a Vengeance', 37 | characters: [ 38 | {_key: 'zeus', name: 'Zeus Carver Jr.'}, 39 | {_key: 'john', name: 'John McClane'}, 40 | {_key: 'simon', name: 'Simon Gruber'}, 41 | ], 42 | } 43 | 44 | // Complex scenario: reorder + insertion + deletion 45 | // - Remove simon (deletion) 46 | // - Add hans as new villain (insertion) 47 | // - Reorder zeus to front, john to end (reordering) 48 | export const e = { 49 | _id: 'die-hard-iii', 50 | title: 'Die Hard with a Vengeance', 51 | characters: [ 52 | {_key: 'zeus', name: 'Zeus Carver'}, 53 | {_key: 'hans', name: 'Hans Gruber'}, 54 | {_key: 'john', name: 'John McClane'}, 55 | ], 56 | } 57 | 58 | // Complex scenario with size change: reorder + multiple insertions + deletion 59 | // - Remove simon (deletion) 60 | // - Add hans and karl as new villains (2 insertions) 61 | // - Reorder zeus to front, john to middle (reordering) 62 | // Result: array grows from 3 to 4 items 63 | export const f = { 64 | _id: 'die-hard-iii', 65 | title: 'Die Hard with a Vengeance', 66 | characters: [ 67 | {_key: 'zeus', name: 'Zeus Carver'}, 68 | {_key: 'john', name: 'John McClane'}, 69 | {_key: 'hans', name: 'Hans Gruber'}, 70 | {_key: 'karl', name: 'Karl Vreski'}, 71 | ], 72 | } 73 | -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | import {DiffError} from './diffError.js' 2 | import type {Path} from './paths.js' 3 | 4 | const idPattern = /^[a-z0-9][a-z0-9_.-]+$/i 5 | const propPattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/ 6 | const propStartPattern = /^[a-z_]/i 7 | 8 | /** 9 | * Validate the given document/subtree for Sanity compatibility 10 | * 11 | * @param item - The document or subtree to validate 12 | * @param path - The path to the current item, for error reporting 13 | * @returns True if valid, throws otherwise 14 | * @internal 15 | */ 16 | export function validateDocument(item: unknown, path: Path = []): boolean { 17 | if (Array.isArray(item)) { 18 | return item.every((child, i) => { 19 | if (Array.isArray(child)) { 20 | throw new DiffError('Multi-dimensional arrays not supported', path.concat(i)) 21 | } 22 | 23 | return validateDocument(child, path.concat(i)) 24 | }) 25 | } 26 | 27 | if (typeof item === 'object' && item !== null) { 28 | const obj = item as {[key: string]: any} 29 | return Object.keys(obj).every( 30 | (key) => 31 | validateProperty(key, obj[key], path) && validateDocument(obj[key], path.concat(key)), 32 | ) 33 | } 34 | 35 | return true 36 | } 37 | 38 | /** 39 | * Validate a property for Sanity compatibility 40 | * 41 | * @param property - The property to valide 42 | * @param value - The value of the property 43 | * @param path - The path of the property, for error reporting 44 | * @returns The property name, if valid 45 | * @internal 46 | */ 47 | export function validateProperty(property: string, value: unknown, path: Path): string { 48 | if (!propStartPattern.test(property)) { 49 | throw new DiffError('Keys must start with a letter (a-z)', path.concat(property), value) 50 | } 51 | 52 | if (!propPattern.test(property)) { 53 | throw new DiffError( 54 | 'Keys can only contain letters, numbers and underscores', 55 | path.concat(property), 56 | value, 57 | ) 58 | } 59 | 60 | if (property === '_key' || property === '_ref' || property === '_type') { 61 | if (typeof value !== 'string') { 62 | throw new DiffError('Keys must be strings', path.concat(property), value) 63 | } 64 | 65 | if (!idPattern.test(value)) { 66 | throw new DiffError('Invalid key - use less exotic characters', path.concat(property), value) 67 | } 68 | } 69 | 70 | return property 71 | } 72 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Workflow name based on selected inputs. Fallback to default Github naming when expression evaluates to empty string 4 | run-name: >- 5 | ${{ 6 | inputs.release && 'CI ➤ Publish to NPM' || 7 | '' 8 | }} 9 | 10 | on: 11 | pull_request: 12 | push: 13 | branches: [main] 14 | workflow_dispatch: 15 | inputs: 16 | release: 17 | description: 'Publish new release' 18 | required: true 19 | default: false 20 | type: boolean 21 | 22 | concurrency: 23 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 24 | cancel-in-progress: true 25 | 26 | jobs: 27 | test: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | node: [18, 20, 22] 33 | name: test (node ${{ matrix.node }}) 34 | env: 35 | ENABLE_INTEGRATION_TESTS: '1' 36 | SANITY_TEST_PROJECT_ID: ${{ vars.SANITY_TEST_PROJECT_ID }} 37 | SANITY_TEST_DATASET: ${{ vars.SANITY_TEST_DATASET }} 38 | SANITY_TEST_TOKEN: ${{ secrets.SANITY_TEST_TOKEN }} 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: pnpm/action-setup@v2 42 | - uses: actions/setup-node@v4 43 | with: 44 | node-version: ${{ matrix.node }} 45 | cache: pnpm 46 | - run: pnpm install 47 | - run: pnpm test 48 | 49 | release: 50 | needs: [test] 51 | # only run if opt-in during workflow_dispatch 52 | if: always() && github.event.inputs.release == 'true' 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v4 56 | with: 57 | # Need to fetch entire commit history to 58 | # analyze every commit since last release 59 | fetch-depth: 0 60 | - uses: pnpm/action-setup@v2 61 | - uses: actions/setup-node@v4 62 | with: 63 | node-version: lts/* 64 | cache: pnpm 65 | - run: pnpm install 66 | # Branches that will release new versions are defined in .releaserc.json 67 | - run: npx semantic-release 68 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 69 | # e.g. git tags were pushed but it exited before `npm publish` 70 | if: always() 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 74 | -------------------------------------------------------------------------------- /test/data-types.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | import {diffPatch} from '../src' 3 | import * as dataTypes from './fixtures/data-types' 4 | import * as typeChange from './fixtures/type-change' 5 | 6 | describe('diff data types', () => { 7 | test('same data type', () => { 8 | expect(diffPatch(dataTypes.a, dataTypes.b)).toEqual([ 9 | { 10 | patch: { 11 | id: dataTypes.a._id, 12 | 13 | diffMatchPatch: {title: '@@ -6,5 +6,20 @@\n ard \n-3\n+with a Vengeance\n'}, 14 | }, 15 | }, 16 | { 17 | patch: { 18 | id: dataTypes.a._id, 19 | set: {isFeatured: false, rating: 4.24}, 20 | }, 21 | }, 22 | { 23 | patch: { 24 | id: dataTypes.a._id, 25 | diffMatchPatch: { 26 | 'characters[0]': '@@ -1,12 +1,12 @@\n-John McClane\n+Simon Gruber\n', 27 | 'slug.current': '@@ -6,7 +6,20 @@\n ard-\n-iii\n+with-a-vengeance\n', 28 | }, 29 | }, 30 | }, 31 | { 32 | patch: { 33 | id: dataTypes.a._id, 34 | set: {year: 1995}, 35 | }, 36 | }, 37 | ]) 38 | }) 39 | 40 | test('different data type', () => { 41 | expect(diffPatch(dataTypes.a, dataTypes.c)).toEqual([ 42 | { 43 | patch: { 44 | id: dataTypes.a._id, 45 | set: { 46 | characters: {simon: 'Simon Gruber'}, 47 | isFeatured: 'yup', 48 | rating: {current: 4.24}, 49 | slug: 'die-hard-with-a-vengeance', 50 | title: ['Die Hard with a Vengeance'], 51 | year: {released: 1995}, 52 | }, 53 | }, 54 | }, 55 | ]) 56 | }) 57 | 58 | test('different data type (object => array)', () => { 59 | expect(diffPatch(dataTypes.a, dataTypes.d)).toEqual([ 60 | { 61 | patch: { 62 | id: dataTypes.a._id, 63 | set: {slug: ['die-hard-with-a-vengeance']}, 64 | }, 65 | }, 66 | ]) 67 | }) 68 | 69 | test('type changes', () => { 70 | expect(diffPatch(typeChange.a, typeChange.b)).toEqual([ 71 | {patch: {id: 'abc123', unset: ['unset']}}, 72 | {patch: {id: 'abc123', set: {number: 1337}}}, 73 | {patch: {diffMatchPatch: {string: '@@ -1,3 +1,3 @@\n-foo\n+bar\n'}, id: 'abc123'}}, 74 | {patch: {id: 'abc123', set: {'array[0]': 0, 'array[1]': 'one', bool: false}}}, 75 | {patch: {id: 'abc123', unset: ['array[2].two.levels.deep']}}, 76 | { 77 | patch: { 78 | id: 'abc123', 79 | set: {'array[2].two.levels.other': 'value', 'object.b12': '12', 'object.f13': null}, 80 | }, 81 | }, 82 | ]) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sanity/diff-patch", 3 | "version": "6.0.0", 4 | "description": "Generates a set of Sanity patches needed to change an item (usually a document) from one shape to another", 5 | "sideEffects": false, 6 | "type": "module", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+ssh://git@github.com/sanity-io/diff-patch.git" 10 | }, 11 | "exports": { 12 | ".": { 13 | "source": "./src/index.ts", 14 | "import": "./dist/index.js", 15 | "require": "./dist/index.cjs", 16 | "default": "./dist/index.js" 17 | }, 18 | "./package.json": "./package.json" 19 | }, 20 | "main": "./dist/index.cjs", 21 | "module": "./dist/index.js", 22 | "types": "./dist/index.d.ts", 23 | "engines": { 24 | "node": ">=18.2" 25 | }, 26 | "browserslist": "extends @sanity/browserslist-config", 27 | "author": "Sanity.io ", 28 | "license": "MIT", 29 | "files": [ 30 | "dist", 31 | "!dist/stats.html", 32 | "src" 33 | ], 34 | "keywords": [ 35 | "sanity", 36 | "patch", 37 | "diff", 38 | "mutation", 39 | "compare" 40 | ], 41 | "scripts": { 42 | "clean": "rimraf dist coverage", 43 | "coverage": "vitest --coverage", 44 | "build": "pkg-utils build && pkg-utils --strict", 45 | "prebuild": "npm run clean", 46 | "lint": "eslint . && tsc --noEmit", 47 | "test": "vitest --reporter=verbose", 48 | "posttest": "npm run lint", 49 | "prepublishOnly": "npm run build" 50 | }, 51 | "dependencies": { 52 | "@sanity/diff-match-patch": "^3.2.0" 53 | }, 54 | "devDependencies": { 55 | "@sanity/client": "^6.27.2", 56 | "@sanity/pkg-utils": "^6.13.4", 57 | "@sanity/semantic-release-preset": "^5.0.0", 58 | "@typescript-eslint/eslint-plugin": "^5.62.0", 59 | "@typescript-eslint/parser": "^5.62.0", 60 | "@vitest/coverage-v8": "2.1.3", 61 | "eslint": "^8.57.1", 62 | "eslint-config-prettier": "^8.10.0", 63 | "eslint-config-sanity": "^6.0.0", 64 | "p-queue": "^8.1.0", 65 | "prettier": "^3.4.2", 66 | "rimraf": "^6.0.1", 67 | "rollup-plugin-visualizer": "^5.14.0", 68 | "semantic-release": "^24.2.1", 69 | "typescript": "^5.7.3", 70 | "vite": "^5.4.14", 71 | "vitest": "^2.1.9" 72 | }, 73 | "prettier": { 74 | "semi": false, 75 | "printWidth": 100, 76 | "bracketSpacing": false, 77 | "singleQuote": true 78 | }, 79 | "eslintConfig": { 80 | "parserOptions": { 81 | "ecmaVersion": 9, 82 | "sourceType": "module", 83 | "ecmaFeatures": { 84 | "modules": true 85 | } 86 | }, 87 | "extends": [ 88 | "sanity", 89 | "sanity/typescript", 90 | "prettier" 91 | ] 92 | }, 93 | "packageManager": "pnpm@9.12.1" 94 | } 95 | -------------------------------------------------------------------------------- /test/__snapshots__/set-unset.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`set/unset > basic nested changes 1`] = ` 4 | [ 5 | { 6 | "patch": { 7 | "id": "die-hard-iii", 8 | "set": { 9 | "slug._type": "slug", 10 | }, 11 | }, 12 | }, 13 | { 14 | "patch": { 15 | "diffMatchPatch": { 16 | "slug.current": "@@ -6,7 +6,20 @@ 17 | ard- 18 | -iii 19 | +with-a-vengeance 20 | ", 21 | }, 22 | "id": "die-hard-iii", 23 | }, 24 | }, 25 | ] 26 | `; 27 | 28 | exports[`set/unset > deep nested changes 1`] = ` 29 | [ 30 | { 31 | "patch": { 32 | "diffMatchPatch": { 33 | "products[_key=="item-1"].comparisonFields['support-level']": "@@ -1,5 +1,8 @@ 34 | -basic 35 | +advanced 36 | ", 37 | "products[_key=="item-1"].variants[_key=="variant-1"]['lace-type']": "@@ -1,5 +1,5 @@ 38 | -waxed 39 | +slick 40 | ", 41 | }, 42 | "id": "die-hard-iii", 43 | }, 44 | }, 45 | { 46 | "patch": { 47 | "id": "die-hard-iii", 48 | "unset": [ 49 | "products[_key=="item-1"].variants[_key=="variant-2"]['lace-type']", 50 | ], 51 | }, 52 | }, 53 | ] 54 | `; 55 | 56 | exports[`set/unset > set + unset, image example 1`] = ` 57 | [ 58 | { 59 | "patch": { 60 | "id": "die-hard-iii", 61 | "set": { 62 | "image.asset._ref": "image-b", 63 | "image.crop.bottom": 0.1, 64 | "image.crop.left": 0.2, 65 | "image.crop.right": 0.2, 66 | "image.crop.top": 0.1, 67 | "image.hotspot.height": 0.75, 68 | "image.hotspot.width": 0.75, 69 | "image.hotspot.x": 0.48, 70 | }, 71 | }, 72 | }, 73 | ] 74 | `; 75 | 76 | exports[`set/unset > set + unset, nested changes 1`] = ` 77 | [ 78 | { 79 | "patch": { 80 | "id": "die-hard-iii", 81 | "unset": [ 82 | "year", 83 | "slug.auto", 84 | ], 85 | }, 86 | }, 87 | { 88 | "patch": { 89 | "id": "die-hard-iii", 90 | "set": { 91 | "slug._type": "slug", 92 | }, 93 | }, 94 | }, 95 | { 96 | "patch": { 97 | "diffMatchPatch": { 98 | "slug.current": "@@ -6,7 +6,20 @@ 99 | ard- 100 | -iii 101 | +with-a-vengeance 102 | ", 103 | }, 104 | "id": "die-hard-iii", 105 | }, 106 | }, 107 | { 108 | "patch": { 109 | "id": "die-hard-iii", 110 | "set": { 111 | "arr[1]": null, 112 | }, 113 | }, 114 | }, 115 | ] 116 | `; 117 | 118 | exports[`set/unset > simple root-level changes 1`] = ` 119 | [ 120 | { 121 | "patch": { 122 | "diffMatchPatch": { 123 | "title": "@@ -6,5 +6,20 @@ 124 | ard 125 | -3 126 | +with a Vengeance 127 | ", 128 | }, 129 | "id": "die-hard-iii", 130 | }, 131 | }, 132 | { 133 | "patch": { 134 | "id": "die-hard-iii", 135 | "set": { 136 | "rating": 4, 137 | }, 138 | }, 139 | }, 140 | ] 141 | `; 142 | -------------------------------------------------------------------------------- /test/fixtures/integration/008.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": { 3 | "_id": "5bc14f866f533ff3fbff5ede", 4 | "index": 0, 5 | "guid": "97842ba9-b448-465e-b91d-a4489a6f15a3", 6 | "isActive": true, 7 | "balance": "$1,817.59", 8 | "picture": "http://placehold.it/32x32", 9 | "age": 22, 10 | "eyeColor": "green", 11 | "name": { 12 | "first": "Amanda", 13 | "last": "Berger" 14 | }, 15 | "company": "AMTAP", 16 | "email": "amanda.berger@amtap.ca", 17 | "phone": "+1 (919) 503-3614", 18 | "address": "793 Coventry Road, Ada, Louisiana, 5318", 19 | "about": "Non eu fugiat elit id aliqua sint tempor anim amet cillum. Dolor est fugiat qui non esse nostrud ullamco consectetur ullamco amet. Ea exercitation aliquip culpa nulla sit cupidatat sint sint excepteur labore mollit officia enim. Labore esse velit mollit nostrud aute adipisicing incididunt fugiat. Do laboris officia dolore veniam velit labore nostrud eu dolor sint non adipisicing id. Do aliqua ipsum proident labore adipisicing. Amet aliquip veniam proident est sint aute occaecat nostrud quis laboris ipsum non cillum enim.", 20 | "registered": "Wednesday, September 14, 2016 11:33 AM", 21 | "latitude": "-69.910147", 22 | "longitude": "113.104458", 23 | "tags": ["excepteur", "excepteur", "enim", "tempor", "ex"], 24 | "range": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 25 | "friends": [ 26 | { 27 | "id": 0, 28 | "name": "Shepherd Meyer" 29 | }, 30 | { 31 | "id": 1, 32 | "name": "Leona Vance" 33 | }, 34 | { 35 | "id": 2, 36 | "name": "Miles Mcmahon" 37 | } 38 | ], 39 | "greeting": "Hello, Amanda! You have 8 unread messages.", 40 | "favoriteFruit": "apple" 41 | }, 42 | "output": { 43 | "_id": "5bc14f86bb18c2d301d2bcd5", 44 | "index": 1, 45 | "guid": "2de94615-b3c0-4880-9a19-65f70302ceee", 46 | "isActive": true, 47 | "balance": "$3,320.68", 48 | "picture": "http://placehold.it/32x32", 49 | "age": 38, 50 | "eyeColor": "green", 51 | "name": { 52 | "first": "Tamika", 53 | "last": "Simon" 54 | }, 55 | "company": "ZENTURY", 56 | "email": "tamika.simon@zentury.io", 57 | "phone": "+1 (898) 409-3449", 58 | "address": "450 Varick Street, Coultervillle, Alaska, 291", 59 | "about": "Adipisicing est aliquip dolor do exercitation est esse Lorem do ullamco. Lorem proident veniam sint tempor pariatur. Velit ex sint occaecat officia amet ea aute tempor nulla mollit reprehenderit duis. Ex reprehenderit incididunt et ut do labore adipisicing ut quis ut.", 60 | "registered": "Tuesday, November 22, 2016 2:16 PM", 61 | "latitude": "54.158631", 62 | "longitude": "125.295022", 63 | "tags": ["laboris", "veniam", "ex", "et", "et"], 64 | "range": [1, 0, 5, 2, 3, 4, 6, 8, 7, 9], 65 | "friends": [ 66 | { 67 | "id": 0, 68 | "name": "Park Rodriquez" 69 | }, 70 | { 71 | "id": 1, 72 | "name": "Judith Aguirre" 73 | }, 74 | { 75 | "id": 2, 76 | "name": "Shirley Curry" 77 | } 78 | ], 79 | "greeting": "Hello, Tamika! You have 10 unread messages.", 80 | "favoriteFruit": "strawberry" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /test/fixtures/dmp.ts: -------------------------------------------------------------------------------- 1 | const base = { 2 | _id: 'die-hard-ix', 3 | _type: 'movie', 4 | _createdAt: new Date().toISOString(), 5 | _updatedAt: new Date(Date.now() + 5000).toISOString(), 6 | } 7 | 8 | // Below absolute threshold 9 | export const absoluteIn = { 10 | ...base, 11 | its: 'short', 12 | } 13 | 14 | export const absoluteOut = { 15 | ...base, 16 | its: 'also short', 17 | } 18 | 19 | // Above relative threshold (too large patch) 20 | export const relativeOverIn = { 21 | ...base, 22 | synopsis: `When a confetti cake explodes at the Bonwit Teller department store, a man calling himself "Simon" phones Major Case Unit Inspector Walter Cobb at the police station and claims responsibility for the confetti. He orders suspended police officer Lt. John McClane to walk through the middle of Harlem, in his underwear. McClane is driven there by Cobb and three other officers. Harlem electrician Zeus Carver spots McClane and tries to get him off the street before he is killed, but a gang of youths attacks McClane and Carver, who barely escape.`, 23 | } 24 | 25 | export const relativeOverOut = { 26 | ...base, 27 | synopsis: `When a sack of coffee explodes at the Ultra Gnoch asteroid, a man calling himself "Ziltoid" phones Major Case Unit Inspector Mark Cimino at the Tellus police station and claims responsibility for the sack. He orders suspended police officer Lt. John McClane to travel to the asteroid, in his underwear. McClane is flown there by Cimino and three other officers. Ultra Gnoch miner Dave Young spots McClane and tries to get him off the asteroid before the coffee bugs infiltrate the asteroid, but a gang of underpant gnomes attacks McClane and Young, who barely escape.`, 28 | } 29 | 30 | // Within relative threshold (patch is smallish) 31 | export const relativeUnderIn = { 32 | ...base, 33 | reviews: [ 34 | {_type: 'review', _key: 'abc123', text: 'I liked this one better than #7 - it was really fun.'}, 35 | ], 36 | } 37 | 38 | export const relativeUnderOut = { 39 | ...base, 40 | reviews: [ 41 | {_type: 'review', _key: 'abc123', text: 'I liked this one better than #8! It was really fun.'}, 42 | ], 43 | } 44 | 45 | // _type changed (within threshold, but don't use DMP for "privates" (readability)) 46 | export const privateChangeIn = { 47 | ...base, 48 | useDmp: 'reviewFromUser', 49 | reviews: [ 50 | { 51 | _type: 'reviewFromUser', 52 | _key: 'abc123', 53 | text: 'I liked this one better than #7 - it was really fun.', 54 | }, 55 | ], 56 | } 57 | 58 | export const privateChangeOut = { 59 | ...base, 60 | useDmp: 'reviewFromUserRepresentingMovieOrNovelOrSomethingVeryLongThatCanBeShownAsStars', 61 | reviews: [ 62 | { 63 | _type: 'reviewFromUserRepresentingMovieOrNovelOrSomethingVeryLongThatCanBeShownAsStars', 64 | _key: 'abc123', 65 | text: 'I liked this one better than #7 - it was really fun.', 66 | }, 67 | ], 68 | } 69 | 70 | // Don't use for type changes (both needs to be string) 71 | export const typeChangeIn = { 72 | ...base, 73 | rating: 3, 74 | } 75 | 76 | export const typeChangeOut = { 77 | ...base, 78 | rating: 'I would give it a solid 9 on a scale from 1 to 26', 79 | } 80 | 81 | export const unicodeChangeIn = { 82 | ...base, 83 | ascii: 'Honestly? I thought it was total xx, really.', 84 | reviews: [ 85 | {_type: 'review', _key: 'abc123', text: 'Honestly? I thought it was total 😉, really.'}, 86 | ], 87 | } 88 | 89 | export const unicodeChangeOut = { 90 | ...base, 91 | ascii: 'Honestly? I thought it was total <3, really.', 92 | reviews: [ 93 | {_type: 'review', _key: 'abc123', text: 'Honestly? I thought it was total 😀, really.'}, 94 | ], 95 | } 96 | -------------------------------------------------------------------------------- /test/__snapshots__/diff-match-patch.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`diff match patch > does not use dmp for "privates" (underscore-prefixed keys) 1`] = ` 4 | [ 5 | { 6 | "patch": { 7 | "diffMatchPatch": { 8 | "useDmp": "@@ -7,8 +7,72 @@ 9 | FromUser 10 | +RepresentingMovieOrNovelOrSomethingVeryLongThatCanBeShownAsStars 11 | ", 12 | }, 13 | "id": "die-hard-ix", 14 | }, 15 | }, 16 | { 17 | "patch": { 18 | "id": "die-hard-ix", 19 | "set": { 20 | "reviews[_key=="abc123"]._type": "reviewFromUserRepresentingMovieOrNovelOrSomethingVeryLongThatCanBeShownAsStars", 21 | }, 22 | }, 23 | }, 24 | ] 25 | `; 26 | 27 | exports[`diff match patch > does not use dmp for "type changes" (number => string) 1`] = ` 28 | [ 29 | { 30 | "patch": { 31 | "id": "die-hard-ix", 32 | "set": { 33 | "rating": "I would give it a solid 9 on a scale from 1 to 26", 34 | }, 35 | }, 36 | }, 37 | ] 38 | `; 39 | 40 | exports[`diff match patch > handles patching with unicode surrogate pairs 1`] = ` 41 | [ 42 | { 43 | "patch": { 44 | "diffMatchPatch": { 45 | "ascii": "@@ -30,10 +30,10 @@ 46 | tal 47 | -xx 48 | +%3C3 49 | , re 50 | ", 51 | "reviews[_key=="abc123"].text": "@@ -30,15 +30,15 @@ 52 | tal 53 | -%F0%9F%98%89 54 | +%F0%9F%98%80 55 | , re 56 | ", 57 | }, 58 | "id": "die-hard-ix", 59 | }, 60 | }, 61 | ] 62 | `; 63 | 64 | exports[`diff match patch > respects absolute length threshold 1`] = ` 65 | [ 66 | { 67 | "patch": { 68 | "diffMatchPatch": { 69 | "its": "@@ -1,5 +1,10 @@ 70 | +also 71 | short 72 | ", 73 | }, 74 | "id": "die-hard-ix", 75 | }, 76 | }, 77 | ] 78 | `; 79 | 80 | exports[`diff match patch > respects relative length threshold (allowed) 1`] = ` 81 | [ 82 | { 83 | "patch": { 84 | "diffMatchPatch": { 85 | "reviews[_key=="abc123"].text": "@@ -27,13 +27,12 @@ 86 | an # 87 | -7 - i 88 | +8! I 89 | t wa 90 | ", 91 | }, 92 | "id": "die-hard-ix", 93 | }, 94 | }, 95 | ] 96 | `; 97 | 98 | exports[`diff match patch > respects relative length threshold 1`] = ` 99 | [ 100 | { 101 | "patch": { 102 | "diffMatchPatch": { 103 | "synopsis": "@@ -4,20 +4,21 @@ 104 | n a 105 | -confetti cak 106 | +sack of coffe 107 | e ex 108 | @@ -35,38 +35,28 @@ 109 | the 110 | -Bonwit Teller department store 111 | +Ultra Gnoch asteroid 112 | , a 113 | @@ -80,13 +80,15 @@ 114 | lf %22 115 | -Simon 116 | +Ziltoid 117 | %22 ph 118 | @@ -122,27 +122,34 @@ 119 | tor 120 | -Walter Cobb 121 | +Mark Cimino 122 | at the 123 | +Tellus 124 | poli 125 | @@ -197,16 +197,12 @@ 126 | the 127 | -confetti 128 | +sack 129 | . He 130 | @@ -258,41 +258,30 @@ 131 | to 132 | -walk through the middle of Harlem 133 | +travel to the asteroid 134 | , in 135 | @@ -311,13 +311,12 @@ 136 | is 137 | -drive 138 | +flow 139 | n th 140 | @@ -327,11 +327,13 @@ 141 | by C 142 | -obb 143 | +imino 144 | and 145 | @@ -359,38 +359,36 @@ 146 | rs. 147 | -Harlem electrician Zeus Carver 148 | +Ultra Gnoch miner Dave Young 149 | spo 150 | @@ -431,33 +431,62 @@ 151 | the 152 | +a 153 | st 154 | -reet 155 | +eroid 156 | before 157 | +t 158 | he 159 | -is kille 160 | +coffee bugs infiltrate the asteroi 161 | d, b 162 | @@ -502,13 +502,23 @@ 163 | of 164 | -youth 165 | +underpant gnome 166 | s at 167 | @@ -539,14 +539,13 @@ 168 | and 169 | -Carver 170 | +Young 171 | , wh 172 | ", 173 | }, 174 | "id": "die-hard-ix", 175 | }, 176 | }, 177 | ] 178 | `; 179 | -------------------------------------------------------------------------------- /src/patches.ts: -------------------------------------------------------------------------------- 1 | import type {KeyedSanityObject, Path} from './paths.js' 2 | 3 | /** 4 | * A `set` operation 5 | * Replaces the current path, does not merge 6 | * 7 | * @internal 8 | */ 9 | export interface SetPatch { 10 | op: 'set' 11 | path: Path 12 | value: unknown 13 | } 14 | 15 | /** 16 | * A `unset` operation 17 | * Unsets the entire value of the given path 18 | * 19 | * @internal 20 | */ 21 | export interface UnsetPatch { 22 | op: 'unset' 23 | path: Path 24 | } 25 | 26 | /** 27 | * A `insert` operation 28 | * Inserts the given items _after_ the given path 29 | * 30 | * @internal 31 | */ 32 | export interface InsertPatch { 33 | op: 'insert' 34 | position: 'before' | 'after' | 'replace' 35 | path: Path 36 | items: any[] 37 | } 38 | 39 | /** 40 | * A `diffMatchPatch` operation 41 | * Applies the given `value` (unidiff format) to the given path. Must be a string. 42 | * 43 | * @internal 44 | */ 45 | export interface DiffMatchPatch { 46 | op: 'diffMatchPatch' 47 | path: Path 48 | value: string 49 | } 50 | 51 | /** 52 | * A `reorder` operation used to ... 53 | * 54 | * Note: NOT a serializable mutation. 55 | * 56 | * @public 57 | */ 58 | export interface ReorderPatch { 59 | op: 'reorder' 60 | path: Path 61 | snapshot: KeyedSanityObject[] 62 | reorders: {sourceKey: string; targetKey: string}[] 63 | } 64 | 65 | /** 66 | * Internal patch representation used during diff generation 67 | * 68 | * @internal 69 | */ 70 | export type Patch = SetPatch | UnsetPatch | InsertPatch | DiffMatchPatch | ReorderPatch 71 | 72 | /** 73 | * A Sanity `set` patch mutation operation 74 | * Replaces the current path, does not merge 75 | * 76 | * @public 77 | */ 78 | export interface SanitySetPatchOperation { 79 | set: Record 80 | } 81 | 82 | /** 83 | * A Sanity `unset` patch mutation operation 84 | * Unsets the entire value of the given path 85 | * 86 | * @public 87 | */ 88 | export interface SanityUnsetPatchOperation { 89 | unset: string[] 90 | } 91 | 92 | /** 93 | * A Sanity `insert` patch mutation operation 94 | * Inserts the given items at the given path (before/after) 95 | * 96 | * @public 97 | */ 98 | export interface SanityInsertPatchOperation { 99 | insert: 100 | | {before: string; items: unknown[]} 101 | | {after: string; items: unknown[]} 102 | | {replace: string; items: unknown[]} 103 | } 104 | 105 | /** 106 | * A Sanity `diffMatchPatch` patch mutation operation 107 | * Patches the given path with the given unidiff string. 108 | * 109 | * @public 110 | */ 111 | export interface SanityDiffMatchPatchOperation { 112 | diffMatchPatch: Record 113 | } 114 | 115 | /** 116 | * Serializable patch operations that can be applied to a Sanity document. 117 | * 118 | * @public 119 | */ 120 | export type SanityPatchOperations = Partial< 121 | SanitySetPatchOperation & 122 | SanityUnsetPatchOperation & 123 | SanityInsertPatchOperation & 124 | SanityDiffMatchPatchOperation 125 | > 126 | 127 | /** 128 | * Meant to be used as the body of a {@link SanityPatchMutation}'s `patch` key. 129 | * 130 | * Contains additional properties to target a particular ID and optionally add 131 | * an optimistic lock via [`ifRevisionID`](https://www.sanity.io/docs/content-lake/transactions#k29b2c75639d5). 132 | * 133 | * @public 134 | */ 135 | export interface SanityPatch extends SanityPatchOperations { 136 | id: string 137 | ifRevisionID?: string 138 | } 139 | 140 | /** 141 | * A mutation containing a single patch 142 | * 143 | * @public 144 | */ 145 | export interface SanityPatchMutation { 146 | patch: SanityPatch 147 | } 148 | -------------------------------------------------------------------------------- /test/fixtures/integration/007.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": { 3 | "_id": "foo", 4 | "_type": "foo", 5 | "format": "example", 6 | "content": [ 7 | { 8 | "align": "center", 9 | "depth": 0, 10 | "list": false, 11 | "content": [ 12 | { 13 | "style": { 14 | "underline": 1, 15 | "bold": 2, 16 | "size": 20, 17 | "italic": 2, 18 | "color": "FFFFFF", 19 | "fontFamily": "Arial" 20 | }, 21 | "content": "And please, feel free to send us your feedback and comments to " 22 | }, 23 | { 24 | "style": { 25 | "underline": 1, 26 | "bold": 2, 27 | "size": 20, 28 | "italic": 2, 29 | "color": "4DC3FF", 30 | "fontFamily": "Arial" 31 | }, 32 | "content": "hello world" 33 | }, 34 | { 35 | "style": { 36 | "underline": 1, 37 | "bold": 2, 38 | "size": 20, 39 | "italic": 2, 40 | "color": "FFFFFF", 41 | "fontFamily": "Arial" 42 | }, 43 | "content": ", or just by clicking on the " 44 | }, 45 | { 46 | "style": { 47 | "underline": 1, 48 | "bold": 2, 49 | "size": 20, 50 | "italic": 2, 51 | "color": "4DC3FF", 52 | "fontFamily": "Arial" 53 | }, 54 | "content": "feedback" 55 | } 56 | ], 57 | "ordered": false 58 | } 59 | ], 60 | "version": 3 61 | }, 62 | "output": { 63 | "_id": "foo", 64 | "_type": "foo", 65 | "format": "example", 66 | "version": 3.1, 67 | "content": [ 68 | { 69 | "list": false, 70 | "depth": 0, 71 | "ordered": false, 72 | "content": [ 73 | { 74 | "content": "And please, feel free to send us your feedback and comments to ", 75 | "style": { 76 | "size": 20, 77 | "color": "FFFFFF", 78 | "name": "Arial", 79 | "bold": 2, 80 | "italic": 2, 81 | "underline": 2 82 | } 83 | }, 84 | { 85 | "content": "foo", 86 | "style": { 87 | "size": 20, 88 | "color": "4DC2FF", 89 | "name": "Arial", 90 | "bold": 2, 91 | "italic": 2, 92 | "underline": 2 93 | } 94 | }, 95 | { 96 | "content": ", or just by clicking on the ", 97 | "style": { 98 | "size": 20, 99 | "color": "FFFFFF", 100 | "name": "Arial", 101 | "bold": 2, 102 | "italic": 2, 103 | "underline": 2 104 | } 105 | }, 106 | { 107 | "content": "feedback", 108 | "style": { 109 | "size": 20, 110 | "color": "4DC2FF", 111 | "name": "Arial", 112 | "bold": 2, 113 | "italic": 2, 114 | "underline": 2 115 | } 116 | }, 117 | { 118 | "content": " button up above.", 119 | "style": { 120 | "size": 20, 121 | "color": "FFFFFF", 122 | "name": "Arial", 123 | "bold": 2, 124 | "italic": 2, 125 | "underline": 2 126 | } 127 | } 128 | ], 129 | "align": "center" 130 | } 131 | ] 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /test/integration.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-sync, max-nested-callbacks */ 2 | import fs from 'fs' 3 | import path from 'path' 4 | import PQueue from 'p-queue' 5 | import {createClient} from '@sanity/client' 6 | import {describe, test, expect} from 'vitest' 7 | 8 | import {diffPatch} from '../src' 9 | 10 | function omitIgnored(obj: {[key: string]: any}): {[key: string]: any} { 11 | const {_type, _createdAt, _updatedAt, _rev, ...rest} = obj 12 | return rest 13 | } 14 | 15 | function nullifyUndefinedArrayItems(item: unknown): unknown { 16 | if (Array.isArray(item)) { 17 | return item.map((child) => 18 | typeof child === 'undefined' ? null : nullifyUndefinedArrayItems(child), 19 | ) 20 | } 21 | 22 | if (typeof item === 'object' && item !== null) { 23 | const obj = item as {[key: string]: any} 24 | return Object.keys(obj).reduce((acc: {[key: string]: any}, key: string) => { 25 | return {...acc, [key]: nullifyUndefinedArrayItems(obj[key])} 26 | }, {}) 27 | } 28 | 29 | return item 30 | } 31 | 32 | /* eslint-disable no-process-env */ 33 | const enabled = process.env.ENABLE_INTEGRATION_TESTS || '' 34 | const projectId = process.env.SANITY_TEST_PROJECT_ID || '' 35 | const dataset = process.env.SANITY_TEST_DATASET || '' 36 | const token = process.env.SANITY_TEST_TOKEN || '' 37 | /* eslint-enable no-process-env */ 38 | 39 | const queue = new PQueue({concurrency: 4}) 40 | const lacksConfig = !enabled || !projectId || !dataset || !token 41 | interface FixturePair { 42 | input: any 43 | output: any 44 | } 45 | 46 | type Fixture = JsonFixture | JsFixture 47 | 48 | interface JsonFixture { 49 | file: string 50 | name?: string 51 | fixture: FixturePair 52 | } 53 | 54 | interface JsFixture { 55 | file: string 56 | name?: string 57 | fixture: {[key: string]: any} 58 | } 59 | 60 | describe.skipIf(lacksConfig)( 61 | 'integration tests', 62 | async () => { 63 | const client = createClient({ 64 | projectId: projectId || 'ci', 65 | dataset, 66 | token, 67 | useCdn: false, 68 | apiVersion: '2023-04-24', 69 | }) 70 | const fixturesDir = path.join(__dirname, 'fixtures') 71 | const jsonFixturesDir = path.join(fixturesDir, 'integration') 72 | 73 | const jsonFixtures: Fixture[] = fs 74 | .readdirSync(jsonFixturesDir) 75 | .filter((file) => /^\d+\.json$/.test(file)) 76 | .map((file) => ({file, fixture: readJsonFixture(path.join(jsonFixturesDir, file))})) 77 | 78 | const rawJsFixtures: {file: string; fixture: JsFixture}[] = await Promise.all( 79 | fs 80 | .readdirSync(fixturesDir) 81 | .filter((file) => /\.ts$/.test(file)) 82 | .map(async (file) => ({ 83 | file, 84 | fixture: await readCodeFixture(path.join(fixturesDir, file)), 85 | })), 86 | ) 87 | 88 | const jsFixtures = rawJsFixtures.reduce((acc: Fixture[], item: JsFixture) => { 89 | const entries = Object.keys(item.fixture) 90 | return acc.concat( 91 | entries.reduce((set: Fixture[], key: string) => { 92 | for (let x = 0; x < entries.length; x++) { 93 | // Don't diff against self 94 | if (key === entries[x]) { 95 | continue 96 | } 97 | 98 | const input = item.fixture[key] 99 | const output = item.fixture[entries[x]] 100 | const name = `${item.file} (${key} vs ${entries[x]})` 101 | set.push({file: item.file, name, fixture: {input, output}}) 102 | } 103 | 104 | return set 105 | }, []), 106 | ) 107 | }, []) 108 | 109 | const fixtures: Fixture[] = [...jsonFixtures, ...jsFixtures] 110 | 111 | fixtures.forEach((fix) => { 112 | test(fix.name || fix.file, async () => { 113 | const _type = 'test' 114 | const _id = `fix-${fix.name || fix.file}` 115 | .replace(/[^a-z0-9-]+/gi, '-') 116 | .replace(/(^-|-$)/g, '') 117 | 118 | const input = {...fix.fixture.input, _id, _type} 119 | const output = {...fix.fixture.output, _id, _type} 120 | const diff = diffPatch(input, output) 121 | 122 | const trx = client.transaction().createOrReplace(input).serialize() 123 | 124 | const result = await queue.add( 125 | () => 126 | client.transaction([...trx, ...diff]).commit({ 127 | visibility: 'async', 128 | returnDocuments: true, 129 | returnFirst: true, 130 | dryRun: true, 131 | }), 132 | {throwOnTimeout: true, timeout: 10000}, 133 | ) 134 | 135 | expect(omitIgnored(result)).toEqual(nullifyUndefinedArrayItems(omitIgnored(output))) 136 | }) 137 | }) 138 | }, 139 | { 140 | timeout: 120000, 141 | }, 142 | ) 143 | 144 | function readJsonFixture(fixturePath: string) { 145 | const content = fs.readFileSync(fixturePath, {encoding: 'utf8'}) 146 | try { 147 | return JSON.parse(content) 148 | } catch (err) { 149 | throw new Error(`Error reading fixture at ${fixturePath}: ${err.message}`) 150 | } 151 | } 152 | 153 | function readCodeFixture(fixturePath: string): Promise { 154 | const module = import(fixturePath) 155 | return module.then((mod: any) => (mod.default || mod) as JsFixture) 156 | } 157 | -------------------------------------------------------------------------------- /test/safeguards.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test, expect} from 'vitest' 2 | import {diffPatch} from '../src' 3 | 4 | describe('safeguards', () => { 5 | test('throws when attempting to change `_type` at root', () => { 6 | expect(() => { 7 | diffPatch({_id: 'foo', _type: 'bar'}, {_id: 'foo', _type: 'bar2'}) 8 | }).toThrowErrorMatchingInlineSnapshot( 9 | `[Error: _type is immutable and cannot be changed (bar => bar2)]`, 10 | ) 11 | }) 12 | 13 | test('changing non-root `_type` is allowed', () => { 14 | expect( 15 | diffPatch( 16 | {_type: 'author', name: 'Espen'}, 17 | {_type: 'person', name: 'Espen'}, 18 | {basePath: ['author'], id: 'foo'}, 19 | ), 20 | ).toEqual([ 21 | { 22 | patch: { 23 | id: 'foo', 24 | set: { 25 | 'author._type': 'person', 26 | }, 27 | }, 28 | }, 29 | ]) 30 | }) 31 | 32 | test('cannot contain multidimensional arrays', () => { 33 | expect(() => { 34 | diffPatch( 35 | {_id: 'agot', _type: 'book', title: 'A Game of Thrones', categories: []}, 36 | {_id: 'agot', _type: 'book', title: 'A Game of Thrones', categories: [['foo']]}, 37 | ) 38 | }).toThrowErrorMatchingInlineSnapshot( 39 | `[Error: Multi-dimensional arrays not supported (at 'categories[0]')]`, 40 | ) 41 | }) 42 | 43 | test('cannot contain numeric object keys', () => { 44 | expect(() => { 45 | diffPatch( 46 | {_id: 'agot', _type: 'book', '13': 'value'}, 47 | {_id: 'agot', _type: 'book', '13': 'changed'}, 48 | ) 49 | }).toThrowErrorMatchingInlineSnapshot( 50 | `[Error: Keys must start with a letter (a-z) (at '['13']')]`, 51 | ) 52 | 53 | expect(() => { 54 | diffPatch( 55 | {_id: 'agot', _type: 'book', nested: {'13': 'value'}}, 56 | {_id: 'agot', _type: 'book', nested: {'13': 'changed'}}, 57 | ) 58 | }).toThrowErrorMatchingInlineSnapshot( 59 | `[Error: Keys must start with a letter (a-z) (at 'nested['13']')]`, 60 | ) 61 | }) 62 | 63 | test('object keys cannot contain non-a-z characters', () => { 64 | expect(() => { 65 | diffPatch({_id: 'agot', _type: 'book', 'feeling💩today': true}, {_id: 'agot', _type: 'book'}) 66 | }).toThrowErrorMatchingInlineSnapshot( 67 | `[Error: Keys can only contain letters, numbers and underscores (at '['feeling💩today']')]`, 68 | ) 69 | 70 | expect(() => { 71 | diffPatch({_id: 'agot', _type: 'book'}, {_id: 'agot', _type: 'book', 'feeling💩today': true}) 72 | }).toThrowErrorMatchingInlineSnapshot( 73 | `[Error: Keys can only contain letters, numbers and underscores (at '['feeling💩today']')]`, 74 | ) 75 | 76 | expect(() => { 77 | diffPatch({_id: 'agot', _type: 'book', "it's a good day": true}, {_id: 'agot', _type: 'book'}) 78 | }).toThrowErrorMatchingInlineSnapshot( 79 | `[Error: Keys can only contain letters, numbers and underscores (at '['it's a good day']')]`, 80 | ) 81 | 82 | expect(() => { 83 | diffPatch({_id: 'agot', _type: 'book'}, {_id: 'agot', _type: 'book', "it's a good day": true}) 84 | }).toThrowErrorMatchingInlineSnapshot( 85 | `[Error: Keys can only contain letters, numbers and underscores (at '['it's a good day']')]`, 86 | ) 87 | }) 88 | 89 | test('object keys cannot be/contain whitespace', () => { 90 | expect(() => { 91 | diffPatch({_id: 'agot', _type: 'book', 'foo bar': true}, {_id: 'agot', _type: 'book'}) 92 | }).toThrowErrorMatchingInlineSnapshot( 93 | `[Error: Keys can only contain letters, numbers and underscores (at '['foo bar']')]`, 94 | ) 95 | 96 | expect(() => { 97 | diffPatch({_id: 'agot', _type: 'book'}, {_id: 'agot', _type: 'book', 'foo bar': true}) 98 | }).toThrowErrorMatchingInlineSnapshot( 99 | `[Error: Keys can only contain letters, numbers and underscores (at '['foo bar']')]`, 100 | ) 101 | 102 | expect(() => { 103 | diffPatch({_id: 'agot', _type: 'book', ' ': true}, {_id: 'agot', _type: 'book'}) 104 | }).toThrowErrorMatchingInlineSnapshot( 105 | `[Error: Keys must start with a letter (a-z) (at '[' ']')]`, 106 | ) 107 | 108 | expect(() => { 109 | diffPatch({_id: 'agot', _type: 'book'}, {_id: 'agot', _type: 'book', ' ': true}) 110 | }).toThrowErrorMatchingInlineSnapshot( 111 | `[Error: Keys must start with a letter (a-z) (at '[' ']')]`, 112 | ) 113 | 114 | expect(() => { 115 | diffPatch({_id: 'agot', _type: 'book', '': true}, {_id: 'agot', _type: 'book'}) 116 | }).toThrowErrorMatchingInlineSnapshot( 117 | `[Error: Keys must start with a letter (a-z) (at '['']')]`, 118 | ) 119 | 120 | expect(() => { 121 | diffPatch({_id: 'agot', _type: 'book'}, {_id: 'agot', _type: 'book', '': true}) 122 | }).toThrowErrorMatchingInlineSnapshot( 123 | `[Error: Keys must start with a letter (a-z) (at '['']')]`, 124 | ) 125 | }) 126 | 127 | test('object `_key` must be strings', () => { 128 | expect(() => { 129 | diffPatch( 130 | {_id: 'agot', _type: 'book', author: {_key: 13, name: 'GRRM'}}, 131 | {_id: 'agot', _type: 'book', author: {_key: 'abc', name: 'GRRM'}}, 132 | ) 133 | }).toThrowErrorMatchingInlineSnapshot(`[Error: Keys must be strings (at 'author._key')]`) 134 | 135 | expect(() => { 136 | diffPatch( 137 | {_id: 'agot', _type: 'book', author: {_key: 'abc', name: 'GRRM'}}, 138 | {_id: 'agot', _type: 'book', author: {_key: 13, name: 'GRRM'}}, 139 | ) 140 | }).toThrowErrorMatchingInlineSnapshot(`[Error: Keys must be strings (at 'author._key')]`) 141 | }) 142 | 143 | test('object `_key` must be identifiers', () => { 144 | expect(() => { 145 | diffPatch( 146 | {_id: 'agot', _type: 'book', author: {_key: '$', name: 'GRRM'}}, 147 | {_id: 'agot', _type: 'book', author: {_key: 'foo', name: 'GRRM'}}, 148 | ) 149 | }).toThrowErrorMatchingInlineSnapshot( 150 | `[Error: Invalid key - use less exotic characters (at 'author._key')]`, 151 | ) 152 | 153 | expect(() => { 154 | diffPatch( 155 | {_id: 'agot', _type: 'book', author: {_key: 'foo', name: 'GRRM'}}, 156 | {_id: 'agot', _type: 'book', author: {_key: '$', name: 'GRRM'}}, 157 | ) 158 | }).toThrowErrorMatchingInlineSnapshot( 159 | `[Error: Invalid key - use less exotic characters (at 'author._key')]`, 160 | ) 161 | }) 162 | }) 163 | -------------------------------------------------------------------------------- /test/__snapshots__/object-arrays.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`object arrays > add to end (multiple) 1`] = ` 4 | [ 5 | { 6 | "patch": { 7 | "id": "die-hard-iii", 8 | "insert": { 9 | "after": "characters[_key=="john"]", 10 | "items": [ 11 | { 12 | "_key": "simon", 13 | "name": "Simon Gruber", 14 | }, 15 | { 16 | "_key": "zeus", 17 | "name": "Zeus Carver", 18 | }, 19 | ], 20 | }, 21 | }, 22 | }, 23 | ] 24 | `; 25 | 26 | exports[`object arrays > add to end (single) 1`] = ` 27 | [ 28 | { 29 | "patch": { 30 | "id": "die-hard-iii", 31 | "insert": { 32 | "after": "characters[_key=="john"]", 33 | "items": [ 34 | { 35 | "_key": "simon", 36 | "name": "Simon Gruber", 37 | }, 38 | ], 39 | }, 40 | }, 41 | }, 42 | ] 43 | `; 44 | 45 | exports[`object arrays > change item 1`] = ` 46 | [ 47 | { 48 | "patch": { 49 | "diffMatchPatch": { 50 | "characters[_key=="simon"].name": "@@ -5,8 +5,9 @@ 51 | n Gr 52 | -u 53 | +%C3%BC 54 | ber 55 | ", 56 | }, 57 | "id": "die-hard-iii", 58 | }, 59 | }, 60 | ] 61 | `; 62 | 63 | exports[`object arrays > remove from end (multiple) 1`] = ` 64 | [ 65 | { 66 | "patch": { 67 | "id": "die-hard-iii", 68 | "unset": [ 69 | "characters[_key=="simon"]", 70 | "characters[_key=="zeus"]", 71 | ], 72 | }, 73 | }, 74 | ] 75 | `; 76 | 77 | exports[`object arrays > remove from end (single) 1`] = ` 78 | [ 79 | { 80 | "patch": { 81 | "id": "die-hard-iii", 82 | "unset": [ 83 | "characters[_key=="zeus"]", 84 | ], 85 | }, 86 | }, 87 | ] 88 | `; 89 | 90 | exports[`object arrays > remove from middle (single) 1`] = ` 91 | [ 92 | { 93 | "patch": { 94 | "id": "die-hard-iii", 95 | "unset": [ 96 | "characters[_key=="simon"]", 97 | ], 98 | }, 99 | }, 100 | ] 101 | `; 102 | 103 | exports[`object arrays > reorder (complete reverse) 1`] = ` 104 | [ 105 | { 106 | "patch": { 107 | "id": "die-hard-iii", 108 | "set": { 109 | "characters[_key=="john"]": { 110 | "_key": "__temp_reorder_john__", 111 | "name": "Zeus Carver", 112 | }, 113 | "characters[_key=="zeus"]": { 114 | "_key": "__temp_reorder_zeus__", 115 | "name": "John McClane", 116 | }, 117 | }, 118 | }, 119 | }, 120 | { 121 | "patch": { 122 | "id": "die-hard-iii", 123 | "set": { 124 | "characters[_key=="__temp_reorder_john__"]._key": "zeus", 125 | "characters[_key=="__temp_reorder_zeus__"]._key": "john", 126 | }, 127 | }, 128 | }, 129 | ] 130 | `; 131 | 132 | exports[`object arrays > reorder (simple swap) 1`] = ` 133 | [ 134 | { 135 | "patch": { 136 | "id": "die-hard-iii", 137 | "set": { 138 | "characters[_key=="john"]": { 139 | "_key": "__temp_reorder_john__", 140 | "name": "Simon Gruber", 141 | }, 142 | "characters[_key=="simon"]": { 143 | "_key": "__temp_reorder_simon__", 144 | "name": "John McClane", 145 | }, 146 | }, 147 | }, 148 | }, 149 | { 150 | "patch": { 151 | "id": "die-hard-iii", 152 | "set": { 153 | "characters[_key=="__temp_reorder_john__"]._key": "simon", 154 | "characters[_key=="__temp_reorder_simon__"]._key": "john", 155 | }, 156 | }, 157 | }, 158 | ] 159 | `; 160 | 161 | exports[`object arrays > reorder with content change 1`] = ` 162 | [ 163 | { 164 | "patch": { 165 | "id": "die-hard-iii", 166 | "set": { 167 | "characters[_key=="john"]": { 168 | "_key": "__temp_reorder_john__", 169 | "name": "Zeus Carver", 170 | }, 171 | "characters[_key=="simon"]": { 172 | "_key": "__temp_reorder_simon__", 173 | "name": "John McClane", 174 | }, 175 | "characters[_key=="zeus"]": { 176 | "_key": "__temp_reorder_zeus__", 177 | "name": "Simon Gruber", 178 | }, 179 | }, 180 | }, 181 | }, 182 | { 183 | "patch": { 184 | "id": "die-hard-iii", 185 | "set": { 186 | "characters[_key=="__temp_reorder_john__"]._key": "zeus", 187 | "characters[_key=="__temp_reorder_simon__"]._key": "john", 188 | "characters[_key=="__temp_reorder_zeus__"]._key": "simon", 189 | }, 190 | }, 191 | }, 192 | { 193 | "patch": { 194 | "diffMatchPatch": { 195 | "characters[_key=="zeus"].name": "@@ -4,8 +4,12 @@ 196 | s Carver 197 | + Jr. 198 | ", 199 | }, 200 | "id": "die-hard-iii", 201 | }, 202 | }, 203 | ] 204 | `; 205 | 206 | exports[`object arrays > reorder with insertion and deletion 1`] = ` 207 | [ 208 | { 209 | "patch": { 210 | "id": "die-hard-iii", 211 | "set": { 212 | "characters[_key=="john"]": { 213 | "_key": "__temp_reorder_john__", 214 | "name": "Zeus Carver", 215 | }, 216 | "characters[_key=="zeus"]": { 217 | "_key": "__temp_reorder_zeus__", 218 | "name": "John McClane", 219 | }, 220 | }, 221 | }, 222 | }, 223 | { 224 | "patch": { 225 | "id": "die-hard-iii", 226 | "set": { 227 | "characters[_key=="__temp_reorder_john__"]._key": "zeus", 228 | "characters[_key=="__temp_reorder_zeus__"]._key": "john", 229 | }, 230 | }, 231 | }, 232 | { 233 | "patch": { 234 | "id": "die-hard-iii", 235 | "unset": [ 236 | "characters[_key=="simon"]", 237 | ], 238 | }, 239 | }, 240 | { 241 | "patch": { 242 | "id": "die-hard-iii", 243 | "insert": { 244 | "after": "characters[_key=="zeus"]", 245 | "items": [ 246 | { 247 | "_key": "hans", 248 | "name": "Hans Gruber", 249 | }, 250 | ], 251 | }, 252 | }, 253 | }, 254 | ] 255 | `; 256 | 257 | exports[`object arrays > reorder with size change (multiple insertions) 1`] = ` 258 | [ 259 | { 260 | "patch": { 261 | "id": "die-hard-iii", 262 | "set": { 263 | "characters[_key=="john"]": { 264 | "_key": "__temp_reorder_john__", 265 | "name": "Zeus Carver", 266 | }, 267 | "characters[_key=="zeus"]": { 268 | "_key": "__temp_reorder_zeus__", 269 | "name": "John McClane", 270 | }, 271 | }, 272 | }, 273 | }, 274 | { 275 | "patch": { 276 | "id": "die-hard-iii", 277 | "set": { 278 | "characters[_key=="__temp_reorder_john__"]._key": "zeus", 279 | "characters[_key=="__temp_reorder_zeus__"]._key": "john", 280 | }, 281 | }, 282 | }, 283 | { 284 | "patch": { 285 | "id": "die-hard-iii", 286 | "unset": [ 287 | "characters[_key=="simon"]", 288 | ], 289 | }, 290 | }, 291 | { 292 | "patch": { 293 | "id": "die-hard-iii", 294 | "insert": { 295 | "after": "characters[_key=="john"]", 296 | "items": [ 297 | { 298 | "_key": "hans", 299 | "name": "Hans Gruber", 300 | }, 301 | { 302 | "_key": "karl", 303 | "name": "Karl Vreski", 304 | }, 305 | ], 306 | }, 307 | }, 308 | }, 309 | ] 310 | `; 311 | -------------------------------------------------------------------------------- /test/fixtures/integration/006.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": { 3 | "_id": "foo", 4 | "_type": "foo", 5 | "name": "South America", 6 | "summary": "South America (Spanish: America del Sur, Sudamerica or \nSuramerica; Portuguese: America do Sul; Quechua and Aymara: \nUrin Awya Yala; Guarani: Nembyamerika; Dutch: Zuid-Amerika; \nFrench: Amerique du Sud) is a continent situated in the \nWestern Hemisphere, mostly in the Southern Hemisphere, with \na relatively small portion in the Northern Hemisphere. \nThe continent is also considered a subcontinent of the \nAmericas.[2][3] It is bordered on the west by the Pacific \nOcean and on the north and east by the Atlantic Ocean; \nNorth America and the Caribbean Sea lie to the northwest. \nIt includes twelve countries: Argentina, Bolivia, Brazil, \nChile, Colombia, Ecuador, Guyana, Paraguay, Peru, Suriname, \nUruguay, and Venezuela. The South American nations that \nborder the Caribbean Sea—including Colombia, Venezuela, \nGuyana, Suriname, as well as French Guiana, which is an \noverseas region of France—are also known as Caribbean South \nAmerica. South America has an area of 17,840,000 square \nkilometers (6,890,000 sq mi). Its population as of 2005 \nhas been estimated at more than 371,090,000. South America \nranks fourth in area (after Asia, Africa, and North America) \nand fifth in population (after Asia, Africa, Europe, and \nNorth America). The word America was coined in 1507 by \ncartographers Martin Waldseemüller and Matthias Ringmann, \nafter Amerigo Vespucci, who was the first European to \nsuggest that the lands newly discovered by Europeans were \nnot India, but a New World unknown to Europeans.", 7 | "surface": 17840001, 8 | "timezone": [-4, 2], 9 | "demographics": { 10 | "largestCities": ["São Paulo", "Buenos Aires", "Rio de Janeiro", "Lima", "Bogotá"] 11 | }, 12 | "languages": [ 13 | "spanish", 14 | "portuguese", 15 | "dutch", 16 | "french", 17 | "quechua", 18 | "aimara", 19 | "mapudungun", 20 | "guaraní" 21 | ], 22 | "countries": [ 23 | { 24 | "name": "Bolivia", 25 | "capital": "La Paz", 26 | "independence": "1825-08-06T08:00:00.000Z", 27 | "unasur": true 28 | }, 29 | { 30 | "name": "Argentina", 31 | "capital": "Buenos Aires", 32 | "independence": "1816-07-09T07:00:00.000Z", 33 | "unasur": true 34 | }, 35 | { 36 | "name": "Brazil", 37 | "capital": "Brasilia", 38 | "independence": "1822-09-07T07:00:00.000Z", 39 | "unasur": true 40 | }, 41 | { 42 | "name": "Chile", 43 | "capital": "Santiago", 44 | "independence": "1818-02-12T08:00:00.000Z", 45 | "unasur": true 46 | }, 47 | { 48 | "name": "Colombia", 49 | "capital": "Bogotá", 50 | "independence": "1810-07-20T07:00:00.000Z", 51 | "unasur": true 52 | }, 53 | { 54 | "name": "Ecuador", 55 | "capital": "Quito", 56 | "independence": "1809-08-10T07:00:00.000Z", 57 | "unasur": true 58 | }, 59 | { 60 | "name": "Paraguay", 61 | "capital": "Asunción", 62 | "independence": "1811-05-14T07:00:00.000Z", 63 | "unasur": true 64 | }, 65 | { 66 | "name": "Peru", 67 | "capital": "Lima", 68 | "independence": "1821-07-28T07:00:00.000Z", 69 | "unasur": false 70 | }, 71 | { 72 | "name": "Suriname", 73 | "capital": "Paramaribo", 74 | "independence": "1975-11-25T08:00:00.000Z", 75 | "unasur": true 76 | }, 77 | { 78 | "name": "Uruguay", 79 | "capital": "Montevideo", 80 | "independence": "1825-08-25T07:00:00.000Z", 81 | "unasur": true 82 | }, 83 | { 84 | "name": "Venezuela", 85 | "capital": "Caracas", 86 | "independence": "1811-07-05T07:00:00.000Z", 87 | "unasur": true 88 | } 89 | ] 90 | }, 91 | "output": { 92 | "_id": "foo", 93 | "_type": "foo", 94 | "name": "South America", 95 | "summary": "South America (Spanish: América del Sur, Sudamérica or \nSuramérica; Portuguese: América do Sul; Quechua and Aymara: \nUrin Awya Yala; Guarani: Ñembyamérika; Dutch: Zuid-Amerika; \nFrench: Amérique du Sud) is a continent situated in the \nWestern Hemisphere, mostly in the Southern Hemisphere, with \na relatively small portion in the Northern Hemisphere. \nThe continent is also considered a subcontinent of the \nAmericas.[2][3] It is bordered on the west by the Pacific \nOcean and on the north and east by the Atlantic Ocean; \nNorth America and the Caribbean Sea lie to the northwest. \nIt includes twelve countries: Argentina, Bolivia, Brazil, \nChile, Colombia, Ecuador, Guyana, Paraguay, Peru, Suriname, \nUruguay, and Venezuela. The South American nations that \nborder the Caribbean Sea—including Colombia, Venezuela, \nGuyana, Suriname, as well as French Guiana, which is an \noverseas region of France—are also known as Caribbean South \nAmerica. South America has an area of 17,840,000 square \nkilometers (6,890,000 sq mi). Its population as of 2005 \nhas been estimated at more than 371,090,000. South America \nranks fourth in area (after Asia, Africa, and North America) \nand fifth in population (after Asia, Africa, Europe, and \nNorth America). The word America was coined in 1507 by \ncartographers Martin Waldseemüller and Matthias Ringmann, \nafter Amerigo Vespucci, who was the first European to \nsuggest that the lands newly discovered by Europeans were \nnot India, but a New World unknown to Europeans.", 96 | "surface": 17840000, 97 | "timezone": [-4, -2], 98 | "demographics": { 99 | "population": 385742554, 100 | "largestCities": ["São Paulo", "Buenos Aires", "Rio de Janeiro", "Lima", "Bogotá"] 101 | }, 102 | "languages": [ 103 | "spanish", 104 | "portuguese", 105 | "english", 106 | "dutch", 107 | "french", 108 | "quechua", 109 | "guaraní", 110 | "aimara", 111 | "mapudungun" 112 | ], 113 | "countries": [ 114 | { 115 | "name": "Argentina", 116 | "capital": "Buenos Aires", 117 | "independence": "1816-07-09T07:00:00.000Z", 118 | "unasur": true 119 | }, 120 | { 121 | "name": "Bolivia", 122 | "capital": "La Paz", 123 | "independence": "1825-08-06T07:00:00.000Z", 124 | "unasur": true 125 | }, 126 | { 127 | "name": "Brazil", 128 | "capital": "Brasilia", 129 | "independence": "1822-09-07T07:00:00.000Z", 130 | "unasur": true 131 | }, 132 | { 133 | "name": "Chile", 134 | "capital": "Santiago", 135 | "independence": "1818-02-12T08:00:00.000Z", 136 | "unasur": true 137 | }, 138 | { 139 | "name": "Colombia", 140 | "capital": "Bogotá", 141 | "independence": "1810-07-20T07:00:00.000Z", 142 | "unasur": true 143 | }, 144 | { 145 | "name": "Ecuador", 146 | "capital": "Quito", 147 | "independence": "1809-08-10T07:00:00.000Z", 148 | "unasur": true 149 | }, 150 | { 151 | "name": "Guyana", 152 | "capital": "Georgetown", 153 | "independence": "1966-05-26T07:00:00.000Z", 154 | "unasur": true 155 | }, 156 | { 157 | "name": "Paraguay", 158 | "capital": "Asunción", 159 | "independence": "1811-05-14T07:00:00.000Z", 160 | "unasur": true 161 | }, 162 | { 163 | "name": "Peru", 164 | "capital": "Lima", 165 | "independence": "1821-07-28T07:00:00.000Z", 166 | "unasur": true 167 | }, 168 | { 169 | "name": "Suriname", 170 | "capital": "Paramaribo", 171 | "independence": "1975-11-25T08:00:00.000Z", 172 | "unasur": true 173 | }, 174 | { 175 | "name": "Uruguay", 176 | "capital": "Montevideo", 177 | "independence": "1825-08-25T07:00:00.000Z", 178 | "unasur": true 179 | }, 180 | { 181 | "name": "Venezuela", 182 | "capital": "Caracas", 183 | "independence": "1811-07-05T07:00:00.000Z", 184 | "unasur": true 185 | } 186 | ] 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @sanity/diff-patch 2 | 3 | [![npm version](https://img.shields.io/npm/v/@sanity/diff-patch.svg?style=flat-square)](https://www.npmjs.com/package/@sanity/diff-patch)[![npm bundle size](https://img.shields.io/bundlephobia/minzip/@sanity/diff-patch?style=flat-square)](https://bundlephobia.com/result?p=@sanity/diff-patch)[![npm weekly downloads](https://img.shields.io/npm/dw/@sanity/diff-patch.svg?style=flat-square)](https://www.npmjs.com/package/@sanity/diff-patch) 4 | 5 | Generate Sanity patch mutations by comparing two documents or values. This library creates conflict-resistant patches designed for collaborative editing environments where multiple users may be editing the same document simultaneously. 6 | 7 | ## Objectives 8 | 9 | - **Conflict-resistant patches**: Generate operations that work well in 3-way merges and collaborative scenarios 10 | - **Performance**: Optimized for real-time, per-keystroke patch generation 11 | - **Intent preservation**: Capture the user's intended change rather than just the final state 12 | - **Reliability**: Consistent, well-tested behavior across different data types and editing patterns 13 | 14 | Used internally by the Sanity App SDK for its collaborative editing system. 15 | 16 | ## Installation 17 | 18 | ```bash 19 | npm install @sanity/diff-patch 20 | ``` 21 | 22 | ## API Reference 23 | 24 | ### `diffPatch(source, target, options?)` 25 | 26 | Generate patch mutations to transform a source document into a target document. 27 | 28 | **Parameters:** 29 | 30 | - `source: DocumentStub` - The original document 31 | - `target: DocumentStub` - The desired document state 32 | - `options?: PatchOptions` - Configuration options 33 | 34 | **Returns:** `SanityPatchMutation[]` - Array of patch mutations 35 | 36 | **Options:** 37 | 38 | ```typescript 39 | interface PatchOptions { 40 | id?: string // Document ID (extracted from _id if not provided) 41 | basePath?: Path // Base path for patches (default: []) 42 | ifRevisionID?: string | true // Revision lock for optimistic updates 43 | } 44 | ``` 45 | 46 | **Example:** 47 | 48 | ```js 49 | import {diffPatch} from '@sanity/diff-patch' 50 | 51 | const source = { 52 | _id: 'movie-123', 53 | _type: 'movie', 54 | _rev: 'abc', 55 | title: 'The Matrix', 56 | year: 1999, 57 | } 58 | 59 | const target = { 60 | _id: 'movie-123', 61 | _type: 'movie', 62 | title: 'The Matrix Reloaded', 63 | year: 2003, 64 | director: 'The Wachowskis', 65 | } 66 | 67 | const mutations = diffPatch(source, target, {ifRevisionID: true}) 68 | // [ 69 | // { 70 | // patch: { 71 | // id: 'movie-123', 72 | // ifRevisionID: 'abc', 73 | // set: { 74 | // title: 'The Matrix Reloaded', 75 | // year: 2003, 76 | // director: 'The Wachowskis' 77 | // } 78 | // } 79 | // } 80 | // ] 81 | ``` 82 | 83 | ### `diffValue(source, target, basePath?)` 84 | 85 | Generate patch operations for values without document wrapper. 86 | 87 | **Parameters:** 88 | 89 | - `source: unknown` - The original value 90 | - `target: unknown` - The desired value state 91 | - `basePath?: Path` - Base path to prefix operations (default: []) 92 | 93 | **Returns:** `SanityPatchOperations[]` - Array of patch operations 94 | 95 | **Example:** 96 | 97 | ```js 98 | import {diffValue} from '@sanity/diff-patch' 99 | 100 | const source = { 101 | name: 'John', 102 | tags: ['developer'], 103 | } 104 | 105 | const target = { 106 | name: 'John Doe', 107 | tags: ['developer', 'typescript'], 108 | active: true, 109 | } 110 | 111 | const operations = diffValue(source, target) 112 | // [ 113 | // { 114 | // set: { 115 | // name: 'John Doe', 116 | // 'tags[1]': 'typescript', 117 | // active: true 118 | // } 119 | // } 120 | // ] 121 | 122 | // With base path 123 | const operations = diffValue(source, target, ['user', 'profile']) 124 | // [ 125 | // { 126 | // set: { 127 | // 'user.profile.name': 'John Doe', 128 | // 'user.profile.tags[1]': 'typescript', 129 | // 'user.profile.active': true 130 | // } 131 | // } 132 | // ] 133 | ``` 134 | 135 | ## Collaborative Editing Example 136 | 137 | The library generates patches that preserve user intent and minimize conflicts in collaborative scenarios: 138 | 139 | ```js 140 | // Starting document 141 | const originalDoc = { 142 | _id: 'blog-post-123', 143 | _type: 'blogPost', 144 | title: 'Getting Started with Sanity', 145 | paragraphs: [ 146 | { 147 | _key: 'intro', 148 | _type: 'paragraph', 149 | text: 'Sanity is a complete content operating system for modern applications.', 150 | }, 151 | { 152 | _key: 'benefits', 153 | _type: 'paragraph', 154 | text: 'It offers real-time collaboration and gives developers controll over the entire stack.', 155 | }, 156 | { 157 | _key: 'conclusion', 158 | _type: 'paragraph', 159 | text: 'Learning Sanity will help you take control of your content workflow.', 160 | }, 161 | ], 162 | } 163 | 164 | // User A reorders paragraphs AND fixes a typo 165 | const userAChanges = { 166 | ...originalDoc, 167 | paragraphs: [ 168 | { 169 | _key: 'intro', 170 | _type: 'paragraph', 171 | text: 'Sanity is a complete content operating system for modern applications.', 172 | }, 173 | { 174 | _key: 'conclusion', // Moved conclusion before benefits 175 | _type: 'paragraph', 176 | text: 'Learning Sanity will help you take control of your content workflow.', 177 | }, 178 | { 179 | _key: 'benefits', 180 | _type: 'paragraph', 181 | text: 'It offers real-time collaboration and gives developers control over the entire stack.', // Fixed typo: "controll" → "control" 182 | }, 183 | ], 184 | } 185 | 186 | // User B simultaneously improves the intro text 187 | const userBChanges = { 188 | ...originalDoc, 189 | paragraphs: [ 190 | { 191 | _key: 'intro', 192 | _type: 'paragraph', 193 | text: 'Sanity is a complete content operating system that gives developers control over the entire stack.', // Added more specific language about developer control 194 | }, 195 | { 196 | _key: 'benefits', 197 | _type: 'paragraph', 198 | text: 'It offers real-time collaboration and gives developers control over the entire stack.', 199 | }, 200 | { 201 | _key: 'conclusion', 202 | _type: 'paragraph', 203 | text: 'Learning Sanity will help you take control of your content workflow.', 204 | }, 205 | ], 206 | } 207 | 208 | // Generate patches that capture each user's intent 209 | const patchA = diffPatch(originalDoc, userAChanges) 210 | const patchB = diffPatch(originalDoc, userBChanges) 211 | 212 | // Apply both patches - they merge successfully because they target different aspects 213 | // User A's reordering and typo fix + User B's content improvement both apply 214 | const finalMergedResult = { 215 | _id: 'blog-post-123', 216 | _type: 'blogPost', 217 | title: 'Getting Started with Sanity', 218 | paragraphs: [ 219 | { 220 | _key: 'intro', 221 | _type: 'paragraph', 222 | text: 'Sanity is a complete content operating system that gives developers control over the entire stack.', // ✅ User B's improvement 223 | }, 224 | { 225 | _key: 'conclusion', // ✅ User A's reordering 226 | _type: 'paragraph', 227 | text: 'Learning Sanity will help you take control of your content workflow.', 228 | }, 229 | { 230 | _key: 'benefits', 231 | _type: 'paragraph', 232 | text: 'It offers real-time collaboration and gives developers control over the entire stack.', // ✅ User A's typo fix 233 | }, 234 | ], 235 | } 236 | ``` 237 | 238 | ## Technical Details 239 | 240 | ### String Diffing with diff-match-patch 241 | 242 | When comparing strings, the library attempts to use [diff-match-patch](https://www.sanity.io/docs/http-patches#diffmatchpatch-aTbJhlAJ) to generate granular text patches instead of simple replacements. This preserves editing intent and enables better conflict resolution. 243 | 244 | **Automatic selection criteria:** 245 | 246 | - **String size limit**: Strings larger than 1MB use `set` operations 247 | - **Change ratio threshold**: If >40% of text changes (determined by simple string length difference), uses `set` (indicates replacement vs. editing) 248 | - **Small text optimization**: Strings <10KB will always use diff-match-patch 249 | - **System key protection**: Properties starting with `_` (e.g. `_type`, `_key`) always use `set` operations as these are not typically edited by users 250 | 251 | **Performance rationale:** 252 | 253 | These thresholds are based on performance testing of the underlying `@sanity/diff-match-patch` library on an M2 MacBook Pro: 254 | 255 | - **Keystroke editing**: 0ms for typical edits, sub-millisecond even on large strings 256 | - **Small insertions/pastes**: 0-10ms for content <50KB 257 | - **Large insertions/deletions**: 0-50ms for content >50KB 258 | - **Text replacements**: Can be 70ms-2s+ due to algorithm complexity 259 | 260 | The 40% change ratio threshold catches problematic replacement scenarios while allowing the algorithm to excel at insertions, deletions, and small edits. 261 | 262 | **Migration from v5:** 263 | 264 | Version 5 allowed configuring diff-match-patch behavior with `lengthThresholdAbsolute` and `lengthThresholdRelative` options. Version 6 removes these options in favor of tested defaults that provide consistent performance across real-world editing patterns. This allows us to change the behavior of this over time to better meet performance needs. 265 | 266 | ### Array Handling 267 | 268 | **Keyed arrays**: Arrays containing objects with `_key` properties are diffed by key rather than index, producing more stable patches for collaborative editing. 269 | 270 | **Index-based arrays**: Arrays without keys are diffed by index position. 271 | 272 | **Undefined values**: When `undefined` values are encountered in arrays, they are converted to `null`. This follows the same behavior as `JSON.stringify()` and ensures consistent serialization. To remove undefined values before diffing: 273 | 274 | ```js 275 | const cleanArray = array.filter((item) => typeof item !== 'undefined') 276 | ``` 277 | 278 | ### System Keys 279 | 280 | The following keys are ignored at the root of the document when diffing a document as they are managed by Sanity: 281 | 282 | - `_id` 283 | - `_type` 284 | - `_createdAt` 285 | - `_updatedAt` 286 | - `_rev` 287 | 288 | ### Error Handling 289 | 290 | - **Missing document ID**: Throws error if `_id` differs between documents and no explicit `id` option provided 291 | - **Immutable \_type**: Throws error if attempting to change `_type` at document root 292 | - **Multi-dimensional arrays**: Not supported, throws `DiffError` 293 | - **Invalid revision**: Throws error if `ifRevisionID: true` but no `_rev` in source document 294 | 295 | ## License 296 | 297 | MIT © [Sanity.io](https://sanity.io/) 298 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## [6.0.0](https://github.com/sanity-io/diff-patch/compare/v5.0.0...v6.0.0) (2025-06-13) 9 | 10 | ### ⚠ BREAKING CHANGES 11 | 12 | - API Rename and Visibility:\*\* 13 | - The `diffItem` function is no longer exported. Its functionality is now primarily internal. 14 | - A new function `diffValue(source: unknown, target: unknown, basePath?: Path): SanityPatchOperations[]` is introduced and exported. This function generates an array of `SanityPatchOperations` (which are plain objects like `{set: {...}}`, `{unset: [...]}`) based on the differences between `source` and `target` values. It does _not_ wrap these operations in the `SanityPatchMutation` structure. 15 | - The `diffPatch` function (which diffs documents and returns `SanityPatchMutation[]`) now internally calls `diffItem` and then uses the refactored `serializePatches` to construct the final mutations. The logic for adding `id` and `ifRevisionID` to the patch mutations now resides within `diffPatch`. 16 | - Patch Type Refinements:\*\* 17 | - Removed older, more generic patch types like `SetPatch`, `InsertAfterPatch`, `SanitySetPatch`, `SanityUnsetPatch`, `SanityInsertPatch`, and `SanityDiffMatchPatch` from the public API (some were previously exported from `patches.ts`). 18 | - Introduced new, more specific types for patch operations: 19 | - `SanitySetPatchOperation` (`{ set: Record }`) 20 | - `SanityUnsetPatchOperation` (`{ unset: string[] }`) 21 | - `SanityInsertPatchOperation` (`{ insert: { before/after/replace: string, items: unknown[] } }`) 22 | - `SanityDiffMatchPatchOperation` (`{ diffMatchPatch: Record }`) 23 | - The `SanityPatchOperations` type is now a `Partial` union of these new operation types, reflecting that a single patch object from `diffValue` will contain one or more of these operations. 24 | - The `SanityPatch` type (used within `SanityPatchMutation`) now `extends SanityPatchOperations` and includes `id` and optional `ifRevisionID`. 25 | - The internal `Patch` type (used by `diffItem`) remains but is now an internal detail. 26 | - **Refactored `serializePatches` Function:** 27 | - The `serializePatches` function now takes an array of internal `Patch` objects and returns an array of `SanityPatchOperation[]` (the raw operation objects like `{set: {...}}`). 28 | - It no longer handles adding `id` or `ifRevisionID`; this responsibility is moved to the `diffPatch` function. 29 | - The logic for grouping `set`, `unset`, `insert`, and `diffMatchPatch` operations into distinct objects in the output array has been improved for clarity. 30 | - **Refactored `diffPatch` Function:** 31 | - Now calls the internal `diffItem` to get the raw patch list. 32 | - Calls the refactored `serializePatches` to get `SanityPatchOperations[]`. 33 | - Maps over these operations to create `SanityPatchMutation[]`, adding the `id` to each and `ifRevisionID` _only to the first patch mutation in the array_. 34 | - **JSDoc Updates:** 35 | - Updated JSDoc for `diffValue` to clearly explain its purpose, parameters, and return type. 36 | - Updated JSDoc for `diffPatch` and internal types to reflect the changes. 37 | 38 | **Rationale:** 39 | 40 | - **Clearer Public API:** `diffValue` provides a more intuitive name for diffing arbitrary JavaScript values and returning the raw operations, distinct from `diffPatch` which is document-centric. 41 | - **Improved Type Safety & Granularity:** The new `Sanity...Operation` types are more precise and make it easier to work with the different kinds of patch operations programmatically. 42 | - **Correct `ifRevisionID` Handling:** Ensuring `ifRevisionID` is only on the first patch of a transaction is crucial for correct optimistic locking in Sanity. 43 | - **Better Separation of Concerns:** `diffItem` focuses on generating a flat list of diffs, `serializePatches` (as used by `diffValue`) groups them into operations, and `diffPatch` handles the document-specific concerns like `_id` and `ifRevisionID`. 44 | 45 | This refactor provides a cleaner and more robust API for generating patches, both for full documents and for arbitrary values. 46 | 47 | - remove undefined-to-null conversion warnings and simplify internal APIs (#38) 48 | 49 | --- 50 | 51 | * Removed the `diffMatchPatch` options (`enabled`, `lengthThresholdAbsolute`, `lengthThresholdRelative`) from `PatchOptions`. 52 | * Removed the `DiffMatchPatchOptions` and `DiffOptions` (which included `diffMatchPatch`) interfaces from the public API. 53 | * Removed the internal `mergeOptions` function and the DMP-specific parts of `defaultOptions`. 54 | 55 | - **New Performance-Based Heuristics for DMP:** 56 | - Introduced a new exported utility function `shouldUseDiffMatchPatch(source: string, target: string): boolean`. This function encapsulates the new logic for deciding whether to use DMP. 57 | - The decision is now based on: 58 | - **Document Size Limit:** Documents larger than 1MB (`DMP_MAX_DOCUMENT_SIZE`) will use `set` operations. 59 | - **Change Ratio Threshold:** If more than 40% (`DMP_MAX_CHANGE_RATIO`) of the text changes, `set` is used (indicates replacement vs. editing). 60 | - **Small Document Optimization:** Documents smaller than 10KB (`DMP_MIN_SIZE_FOR_RATIO_CHECK`) always use DMP, as performance is consistently high for these. 61 | - **System Key Protection:** Properties starting with `_` (system keys) continue to use `set` operations. 62 | - Added extensive JSDoc to `shouldUseDiffMatchPatch` detailing the heuristic rationale, performance characteristics (based on testing `@sanity/diff-match-patch` on an M2 MacBook Pro), algorithm details, and test methodology. 63 | - **Internal Simplification:** 64 | - The internal `getDiffMatchPatch` function now uses `shouldUseDiffMatchPatch` to make its decision and no longer accepts DMP-related options. 65 | - Simplified the call to the underlying `@sanity/diff-match-patch` library within `getDiffMatchPatch` to use `makePatches(source, target)` directly. This is more concise and leverages the internal optimizations of that library, with performance validated to be equivalent to the previous multi-step approach. 66 | - **Constants:** Introduced `SYSTEM_KEYS`, `DMP_MAX_DOCUMENT_SIZE`, `DMP_MAX_CHANGE_RATIO`, and `DMP_MIN_SIZE_FOR_RATIO_CHECK` to define these thresholds. 67 | - **Test Updates:** Snapshots have been updated to reflect the new DMP behavior based on these heuristics. 68 | 69 | **Rationale for Change:** 70 | 71 | The previous configurable thresholds for DMP were somewhat arbitrary and could lead to suboptimal performance or overly verbose patches in certain scenarios. This change is based on empirical performance testing of the `@sanity/diff-match-patch` library itself. The new heuristics are designed to: 72 | 73 | - **Optimize for common editing patterns:** Ensure fast performance for keystrokes and small pastes, which are the most frequent operations. 74 | - **Prevent performance degradation:** Avoid triggering complex and potentially slow DMP algorithm paths when users perform large text replacements (e.g., pasting entirely new content). 75 | - **Simplify the API:** Remove the burden of configuration from the user, providing sensible defaults. 76 | - **Maintain conflict-resistance:** Continue to leverage DMP's strengths for collaborative editing where appropriate. 77 | 78 | By hardcoding these well-tested heuristics, we aim for a more robust and performant string diffing strategy by default. 79 | 80 | ### Features 81 | 82 | - add key-based reordering support for keyed object arrays ([#41](https://github.com/sanity-io/diff-patch/issues/41)) ([27dcdc2](https://github.com/sanity-io/diff-patch/commit/27dcdc29c151c81705dba18348ff6f861fd4e264)) 83 | - remove undefined-to-null conversion warnings and simplify internal APIs ([#38](https://github.com/sanity-io/diff-patch/issues/38)) ([86cff6e](https://github.com/sanity-io/diff-patch/commit/86cff6ed5af1e715145bec6cfd97c72ecc2903ac)) 84 | - replace `diffItem` with `diffValue` ([#39](https://github.com/sanity-io/diff-patch/issues/39)) ([b8ad36a](https://github.com/sanity-io/diff-patch/commit/b8ad36a2cf6fd711ebc83bb78a1f8fa1014963b6)) 85 | - replace configurable DMP with perf-based heuristics ([#36](https://github.com/sanity-io/diff-patch/issues/36)) ([9577019](https://github.com/sanity-io/diff-patch/commit/95770191eb4f329c8c9591371435d435dfbb4646)) 86 | 87 | ### Bug Fixes 88 | 89 | - add repository field ([d85b998](https://github.com/sanity-io/diff-patch/commit/d85b998e0fe3d5c6534f4436ada76a628e7d43df)) 90 | 91 | ## [5.0.0](https://github.com/sanity-io/diff-patch/compare/v4.0.0...v5.0.0) (2025-02-05) 92 | 93 | ### ⚠ BREAKING CHANGES 94 | 95 | - Module name is now `@sanity/diff-patch` (from previous `sanity-diff-patch`). Update imports accordingly! 96 | 97 | ### Features 98 | 99 | - rename module to `@sanity/diff-patch` ([#33](https://github.com/sanity-io/diff-patch/issues/33)) ([891241f](https://github.com/sanity-io/diff-patch/commit/891241fcebe17de4af20322fd99fbdd7dd336a76)) 100 | 101 | ## [4.0.0](https://github.com/sanity-io/diff-patch/compare/v3.0.4...v4.0.0) (2024-10-15) 102 | 103 | ### ⚠ BREAKING CHANGES 104 | 105 | - We now require node 18 or higher to run this module 106 | 107 | ### Bug Fixes 108 | 109 | - apply unset operations first ([692f5d6](https://github.com/sanity-io/diff-patch/commit/692f5d6b6584f1fb2fb449273922d846ecbd2e34)) 110 | - require node 18.2 or higher ([dc2437b](https://github.com/sanity-io/diff-patch/commit/dc2437b3a8031f7cbbd10ccb3bc72a9a735ee98f)) 111 | 112 | ## [3.0.4](https://github.com/sanity-io/diff-patch/compare/v3.0.3...v3.0.4) (2024-10-15) 113 | 114 | ### Bug Fixes 115 | 116 | - use correct escaping for unsafe property names in paths ([53f84f8](https://github.com/sanity-io/diff-patch/commit/53f84f84da968f0689924cc1d8806d77be73f95f)) 117 | 118 | ## [3.0.3](https://github.com/sanity-io/diff-patch/compare/v3.0.2...v3.0.3) (2024-10-15) 119 | 120 | ### Bug Fixes 121 | 122 | - allow (non-leading) dashes in properties ([bce4d2f](https://github.com/sanity-io/diff-patch/commit/bce4d2f767faf7f2d8ba2705372dd8241f6364f1)), closes [#28](https://github.com/sanity-io/diff-patch/issues/28) 123 | 124 | ## [3.0.2](https://github.com/sanity-io/diff-patch/compare/v3.0.1...v3.0.2) (2023-04-28) 125 | 126 | ### Bug Fixes 127 | 128 | - upgrade diff-match-patch dependency ([166f5e6](https://github.com/sanity-io/diff-patch/commit/166f5e6fa2de02b56c131766b9c8c67a543e0edf)) 129 | 130 | ## [3.0.1](https://github.com/sanity-io/diff-patch/compare/v3.0.0...v3.0.1) (2023-04-25) 131 | 132 | ### Bug Fixes 133 | 134 | - bump dependencies ([674aa70](https://github.com/sanity-io/diff-patch/commit/674aa7032bbc2b28cffda5c27e2cb1e5f73319e2)) 135 | 136 | ## [3.0.0](https://github.com/sanity-io/diff-patch/compare/v2.0.3...v3.0.0) (2023-04-25) 137 | 138 | ### ⚠ BREAKING CHANGES 139 | 140 | - `validateDocument()` has been removed 141 | - `ifRevisionId` option must be written as `ifRevisionID` 142 | 143 | ### Features 144 | 145 | - remove internal APIs, modernize tooling ([474d6ff](https://github.com/sanity-io/diff-patch/commit/474d6ffa723cf834fcedb21b96c3b78dd03c12bf)) 146 | -------------------------------------------------------------------------------- /src/diffPatch.ts: -------------------------------------------------------------------------------- 1 | import {makePatches, stringifyPatches} from '@sanity/diff-match-patch' 2 | import {DiffError} from './diffError.js' 3 | import {isKeyedObject, type KeyedSanityObject, type Path, pathToString} from './paths.js' 4 | import {validateProperty} from './validate.js' 5 | import { 6 | type Patch, 7 | type UnsetPatch, 8 | type DiffMatchPatch, 9 | type SanityPatchMutation, 10 | type SanityPatchOperations, 11 | type SanitySetPatchOperation, 12 | type SanityUnsetPatchOperation, 13 | type SanityInsertPatchOperation, 14 | type SanityDiffMatchPatchOperation, 15 | } from './patches.js' 16 | import {difference, intersection} from './setOperations.js' 17 | 18 | /** 19 | * Document keys that are ignored during diff operations. 20 | * These are system-managed fields that should not be included in patches on 21 | * top-level documents and should not be diffed with diff-match-patch. 22 | */ 23 | const SYSTEM_KEYS = ['_id', '_type', '_createdAt', '_updatedAt', '_rev'] 24 | 25 | /** 26 | * Maximum size of strings to consider for diff-match-patch (1MB) 27 | * Based on testing showing consistently good performance up to this size 28 | */ 29 | const DMP_MAX_STRING_SIZE = 1_000_000 30 | 31 | /** 32 | * Maximum difference in string length before falling back to set operations (40%) 33 | * Above this threshold, likely indicates text replacement which can be slow 34 | */ 35 | const DMP_MAX_STRING_LENGTH_CHANGE_RATIO = 0.4 36 | 37 | /** 38 | * Minimum string size to apply change ratio check (10KB) 39 | * Small strings are always fast regardless of change ratio 40 | */ 41 | const DMP_MIN_SIZE_FOR_RATIO_CHECK = 10_000 42 | 43 | /** 44 | * An object (record) that _may_ have a `_key` property 45 | * 46 | * @internal 47 | */ 48 | export type SanityObject = KeyedSanityObject | Partial 49 | 50 | /** 51 | * Represents a partial Sanity document (eg a "stub"). 52 | * 53 | * @public 54 | */ 55 | export interface DocumentStub { 56 | _id?: string 57 | _type?: string 58 | _rev?: string 59 | _createdAt?: string 60 | _updatedAt?: string 61 | [key: string]: unknown 62 | } 63 | 64 | /** 65 | * Options for the patch generator 66 | * 67 | * @public 68 | */ 69 | export interface PatchOptions { 70 | /** 71 | * Document ID to apply the patch to. 72 | * 73 | * @defaultValue `undefined` - tries to extract `_id` from passed document 74 | */ 75 | id?: string 76 | 77 | /** 78 | * Base path to apply the patch to - useful if diffing sub-branches of a document. 79 | * 80 | * @defaultValue `[]` - eg root of the document 81 | */ 82 | basePath?: Path 83 | 84 | /** 85 | * Only apply the patch if the document revision matches this value. 86 | * If the property is the boolean value `true`, it will attempt to extract 87 | * the revision from the document `_rev` property. 88 | * 89 | * @defaultValue `undefined` (do not apply revision check) 90 | */ 91 | ifRevisionID?: string | true 92 | } 93 | 94 | /** 95 | * Generates an array of mutations for Sanity, based on the differences between 96 | * the two passed documents/trees. 97 | * 98 | * @param source - The first document/tree to compare 99 | * @param target - The second document/tree to compare 100 | * @param opts - Options for the diff generation 101 | * @returns Array of mutations 102 | * @public 103 | */ 104 | export function diffPatch( 105 | source: DocumentStub, 106 | target: DocumentStub, 107 | options: PatchOptions = {}, 108 | ): SanityPatchMutation[] { 109 | const id = options.id || (source._id === target._id && source._id) 110 | const revisionLocked = options.ifRevisionID 111 | const ifRevisionID = typeof revisionLocked === 'boolean' ? source._rev : revisionLocked 112 | const basePath = options.basePath || [] 113 | if (!id) { 114 | throw new Error( 115 | '_id on source and target not present or differs, specify document id the mutations should be applied to', 116 | ) 117 | } 118 | 119 | if (revisionLocked === true && !ifRevisionID) { 120 | throw new Error( 121 | '`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.', 122 | ) 123 | } 124 | 125 | if (basePath.length === 0 && source._type !== target._type) { 126 | throw new Error(`_type is immutable and cannot be changed (${source._type} => ${target._type})`) 127 | } 128 | 129 | const operations = diffItem(source, target, basePath, []) 130 | return serializePatches(operations).map((patchOperations, i) => ({ 131 | patch: { 132 | id, 133 | // only add `ifRevisionID` to the first patch 134 | ...(i === 0 && ifRevisionID && {ifRevisionID}), 135 | ...patchOperations, 136 | }, 137 | })) 138 | } 139 | 140 | /** 141 | * Generates an array of patch operation objects for Sanity, based on the 142 | * differences between the two passed values 143 | * 144 | * @param source - The source value to start off with 145 | * @param target - The target value that the patch operations will aim to create 146 | * @param basePath - An optional path that will be prefixed to all subsequent patch operations 147 | * @returns Array of mutations 148 | * @public 149 | */ 150 | export function diffValue( 151 | source: unknown, 152 | target: unknown, 153 | basePath: Path = [], 154 | ): SanityPatchOperations[] { 155 | return serializePatches(diffItem(source, target, basePath)) 156 | } 157 | 158 | function diffItem( 159 | source: unknown, 160 | target: unknown, 161 | path: Path = [], 162 | patches: Patch[] = [], 163 | ): Patch[] { 164 | if (source === target) { 165 | return patches 166 | } 167 | 168 | if (typeof source === 'string' && typeof target === 'string') { 169 | diffString(source, target, path, patches) 170 | return patches 171 | } 172 | 173 | if (Array.isArray(source) && Array.isArray(target)) { 174 | diffArray(source, target, path, patches) 175 | return patches 176 | } 177 | 178 | if (isRecord(source) && isRecord(target)) { 179 | diffObject(source, target, path, patches) 180 | return patches 181 | } 182 | 183 | if (target === undefined) { 184 | patches.push({op: 'unset', path}) 185 | return patches 186 | } 187 | 188 | patches.push({op: 'set', path, value: target}) 189 | return patches 190 | } 191 | 192 | function diffObject( 193 | source: Record, 194 | target: Record, 195 | path: Path, 196 | patches: Patch[], 197 | ) { 198 | const atRoot = path.length === 0 199 | const aKeys = Object.keys(source) 200 | .filter(atRoot ? isNotIgnoredKey : yes) 201 | .map((key) => validateProperty(key, source[key], path)) 202 | 203 | const aKeysLength = aKeys.length 204 | const bKeys = Object.keys(target) 205 | .filter(atRoot ? isNotIgnoredKey : yes) 206 | .map((key) => validateProperty(key, target[key], path)) 207 | 208 | const bKeysLength = bKeys.length 209 | 210 | // Check for deleted items 211 | for (let i = 0; i < aKeysLength; i++) { 212 | const key = aKeys[i] 213 | if (!(key in target)) { 214 | patches.push({op: 'unset', path: path.concat(key)}) 215 | } 216 | } 217 | 218 | // Check for changed items 219 | for (let i = 0; i < bKeysLength; i++) { 220 | const key = bKeys[i] 221 | diffItem(source[key], target[key], path.concat([key]), patches) 222 | } 223 | 224 | return patches 225 | } 226 | 227 | function diffArray(source: unknown[], target: unknown[], path: Path, patches: Patch[]) { 228 | if (isUniquelyKeyed(source) && isUniquelyKeyed(target)) { 229 | return diffArrayByKey(source, target, path, patches) 230 | } 231 | 232 | return diffArrayByIndex(source, target, path, patches) 233 | } 234 | 235 | function diffArrayByIndex(source: unknown[], target: unknown[], path: Path, patches: Patch[]) { 236 | // Check for new items 237 | if (target.length > source.length) { 238 | patches.push({ 239 | op: 'insert', 240 | position: 'after', 241 | path: path.concat([-1]), 242 | items: target.slice(source.length).map(nullifyUndefined), 243 | }) 244 | } 245 | 246 | // Check for deleted items 247 | if (target.length < source.length) { 248 | const isSingle = source.length - target.length === 1 249 | const unsetItems = source.slice(target.length) 250 | 251 | // If we have unique array keys, we'll want to unset by key, as this is 252 | // safer in a realtime, collaborative setting 253 | if (isUniquelyKeyed(unsetItems)) { 254 | patches.push( 255 | ...unsetItems.map( 256 | (item): UnsetPatch => ({op: 'unset', path: path.concat({_key: item._key})}), 257 | ), 258 | ) 259 | } else { 260 | patches.push({ 261 | op: 'unset', 262 | path: path.concat([isSingle ? target.length : [target.length, '']]), 263 | }) 264 | } 265 | } 266 | 267 | // Check for illegal array contents 268 | for (let i = 0; i < target.length; i++) { 269 | if (Array.isArray(target[i])) { 270 | throw new DiffError('Multi-dimensional arrays not supported', path.concat(i), target[i]) 271 | } 272 | } 273 | 274 | const overlapping = Math.min(source.length, target.length) 275 | const segmentA = source.slice(0, overlapping) 276 | const segmentB = target.slice(0, overlapping) 277 | 278 | for (let i = 0; i < segmentA.length; i++) { 279 | diffItem(segmentA[i], nullifyUndefined(segmentB[i]), path.concat(i), patches) 280 | } 281 | 282 | return patches 283 | } 284 | 285 | /** 286 | * Diffs two arrays of keyed objects by their `_key` properties. 287 | * 288 | * This approach is preferred over index-based diffing for collaborative editing scenarios 289 | * because it generates patches that are more resilient to concurrent modifications. 290 | * When multiple users edit the same array simultaneously, key-based patches have better 291 | * conflict resolution characteristics than index-based patches. 292 | * 293 | * The function handles three main operations: 294 | * 1. **Reordering**: When existing items change positions within the array 295 | * 2. **Content changes**: When the content of existing items is modified 296 | * 3. **Insertions/Deletions**: When items are added or removed from the array 297 | * 298 | * @param source - The original array with keyed objects 299 | * @param target - The target array with keyed objects 300 | * @param path - The path to this array within the document 301 | * @param patches - Array to accumulate generated patches 302 | * @returns The patches array with new patches appended 303 | */ 304 | function diffArrayByKey( 305 | source: KeyedSanityObject[], 306 | target: KeyedSanityObject[], 307 | path: Path, 308 | patches: Patch[], 309 | ) { 310 | // Create lookup maps for efficient key-based access to array items 311 | const sourceItemsByKey = new Map(source.map((item) => [item._key, item])) 312 | const targetItemsByKey = new Map(target.map((item) => [item._key, item])) 313 | 314 | // Categorize keys by their presence in source vs target arrays 315 | const sourceKeys = new Set(sourceItemsByKey.keys()) 316 | const targetKeys = new Set(targetItemsByKey.keys()) 317 | const keysRemovedFromSource = difference(sourceKeys, targetKeys) 318 | const keysAddedToTarget = difference(targetKeys, sourceKeys) 319 | const keysInBothArrays = intersection(sourceKeys, targetKeys) 320 | 321 | // Handle reordering of existing items within the array. 322 | // We detect reordering by comparing the relative positions of keys that exist in both arrays, 323 | // excluding keys that were added or removed (since they don't participate in reordering). 324 | const sourceKeysStillPresent = Array.from(difference(sourceKeys, keysRemovedFromSource)) 325 | const targetKeysAlreadyPresent = Array.from(difference(targetKeys, keysAddedToTarget)) 326 | 327 | // Track which keys need to be reordered by comparing their relative positions 328 | const keyReorderOperations: {sourceKey: string; targetKey: string}[] = [] 329 | 330 | for (let i = 0; i < keysInBothArrays.size; i++) { 331 | const keyAtPositionInSource = sourceKeysStillPresent[i] 332 | const keyAtPositionInTarget = targetKeysAlreadyPresent[i] 333 | 334 | // If different keys occupy the same relative position, a reorder is needed 335 | if (keyAtPositionInSource !== keyAtPositionInTarget) { 336 | keyReorderOperations.push({ 337 | sourceKey: keyAtPositionInSource, 338 | targetKey: keyAtPositionInTarget, 339 | }) 340 | } 341 | } 342 | 343 | // Generate reorder patch if any items changed positions 344 | if (keyReorderOperations.length) { 345 | patches.push({ 346 | op: 'reorder', 347 | path, 348 | snapshot: source, 349 | reorders: keyReorderOperations, 350 | }) 351 | } 352 | 353 | // Process content changes for items that exist in both arrays 354 | for (const key of keysInBothArrays) { 355 | diffItem(sourceItemsByKey.get(key), targetItemsByKey.get(key), [...path, {_key: key}], patches) 356 | } 357 | 358 | // Remove items that no longer exist in the target array 359 | for (const keyToRemove of keysRemovedFromSource) { 360 | patches.push({op: 'unset', path: [...path, {_key: keyToRemove}]}) 361 | } 362 | 363 | // Insert new items that were added to the target array 364 | // We batch consecutive insertions for efficiency and insert them at the correct positions 365 | if (keysAddedToTarget.size) { 366 | let insertionAnchorKey: string // The key after which we'll insert pending items 367 | let itemsPendingInsertion: unknown[] = [] 368 | 369 | const flushPendingInsertions = () => { 370 | if (itemsPendingInsertion.length) { 371 | patches.push({ 372 | op: 'insert', 373 | // Insert after the anchor key if we have one, otherwise insert at the beginning 374 | ...(insertionAnchorKey 375 | ? {position: 'after', path: [...path, {_key: insertionAnchorKey}]} 376 | : {position: 'before', path: [...path, 0]}), 377 | items: itemsPendingInsertion, 378 | }) 379 | } 380 | } 381 | 382 | // Walk through the target array to determine where new items should be inserted 383 | for (const key of targetKeys) { 384 | if (keysAddedToTarget.has(key)) { 385 | // This is a new item - add it to the pending insertion batch 386 | itemsPendingInsertion.push(targetItemsByKey.get(key)!) 387 | } else if (keysInBothArrays.has(key)) { 388 | // This is an existing item - flush any pending insertions before it 389 | flushPendingInsertions() 390 | insertionAnchorKey = key 391 | itemsPendingInsertion = [] 392 | } 393 | } 394 | 395 | // Flush any remaining insertions at the end 396 | flushPendingInsertions() 397 | } 398 | 399 | return patches 400 | } 401 | 402 | /** 403 | * Determines whether to use diff-match-patch or fallback to a `set` operation 404 | * when creating a patch to transform a `source` string to `target` string. 405 | * 406 | * `diffMatchPatch` patches are typically preferred to `set` operations because 407 | * they handle conflicts better (when multiple editors work simultaneously) by 408 | * preserving the user's intended and allowing for 3-way merges. 409 | * 410 | * **Heuristic rationale:** 411 | * 412 | * Perf analysis revealed that string length has minimal impact on small, 413 | * keystroke-level changes, but large text replacements (high change ratio) can 414 | * trigger worst-case algorithm behavior. The 40% change ratio threshold is a 415 | * simple heuristic that catches problematic replacement scenarios while 416 | * allowing the algorithm to excel at insertions and deletions. 417 | * 418 | * **Performance characteristics (tested on M2 MacBook Pro):** 419 | * 420 | * *Keystroke-level editing (most common use case):* 421 | * - Small strings (1KB-10KB): 0ms for 1-5 keystrokes, consistently sub-millisecond 422 | * - Medium strings (50KB-200KB): 0ms for 1-5 keystrokes, consistently sub-millisecond 423 | * - 10 simultaneous keystrokes: ~12ms on 100KB strings 424 | * 425 | * *Copy-paste operations (less frequent):* 426 | * - Small copy-paste operations (<50KB): 0-10ms regardless of string length 427 | * - Large insertions/deletions (50KB+): 0-50ms (excellent performance) 428 | * - Large text replacements (50KB+): 70ms-2s+ (can be slow due to algorithm complexity) 429 | * 430 | * **Algorithm details:** 431 | * Uses Myers' diff algorithm with O(ND) time complexity where N=text length and D=edit distance. 432 | * Includes optimizations: common prefix/suffix removal, line-mode processing, and timeout protection. 433 | * 434 | * 435 | * **Test methodology:** 436 | * - Generated realistic word-based text patterns 437 | * - Simulated actual editing behaviors (keystrokes vs copy-paste) 438 | * - Measured performance across string sizes from 1KB to 10MB 439 | * - Validated against edge cases including repetitive text and scattered changes 440 | * 441 | * @param source - The previous version of the text 442 | * @param target - The new version of the text 443 | * @returns true if diff-match-patch should be used, false if fallback to set operation 444 | * 445 | * @example 446 | * ```typescript 447 | * // Keystroke editing - always fast 448 | * shouldUseDiffMatchPatch(largeDoc, largeDocWithTypo) // true, ~0ms 449 | * 450 | * // Small paste - always fast 451 | * shouldUseDiffMatchPatch(doc, docWithSmallInsertion) // true, ~0ms 452 | * 453 | * // Large replacement - potentially slow 454 | * shouldUseDiffMatchPatch(article, completelyDifferentArticle) // false, use set 455 | * ``` 456 | * 457 | * Compatible with @sanity/diff-match-patch@3.2.0 458 | */ 459 | export function shouldUseDiffMatchPatch(source: string, target: string): boolean { 460 | const maxLength = Math.max(source.length, target.length) 461 | 462 | // Always reject strings larger than our tested size limit 463 | if (maxLength > DMP_MAX_STRING_SIZE) { 464 | return false 465 | } 466 | 467 | // For small strings, always use diff-match-patch regardless of change ratio 468 | // Performance testing showed these are always fast (<10ms) 469 | if (maxLength < DMP_MIN_SIZE_FOR_RATIO_CHECK) { 470 | return true 471 | } 472 | 473 | // Calculate the change ratio to detect large text replacements 474 | // High ratios indicate replacement scenarios which can trigger slow algorithm paths 475 | const lengthDifference = Math.abs(target.length - source.length) 476 | const changeRatio = lengthDifference / maxLength 477 | 478 | // If change ratio is high, likely a replacement operation that could be slow 479 | // Fall back to set operation for better user experience 480 | if (changeRatio > DMP_MAX_STRING_LENGTH_CHANGE_RATIO) { 481 | return false 482 | } 483 | 484 | // All other cases: use diff-match-patch 485 | // This covers keystroke editing and insertion/deletion scenarios which perform excellently 486 | return true 487 | } 488 | 489 | function getDiffMatchPatch(source: string, target: string, path: Path): DiffMatchPatch | undefined { 490 | const last = path.at(-1) 491 | // don't use diff-match-patch for system keys 492 | if (typeof last === 'string' && last.startsWith('_')) return undefined 493 | if (!shouldUseDiffMatchPatch(source, target)) return undefined 494 | 495 | try { 496 | // Using `makePatches(string, string)` directly instead of the multi-step approach e.g. 497 | // `stringifyPatches(makePatches(cleanupEfficiency(makeDiff(source, target))))`. 498 | // this is because `makePatches` internally handles diff generation and 499 | // automatically applies both `cleanupSemantic()` and `cleanupEfficiency()` 500 | // when beneficial, resulting in cleaner code with near identical performance and 501 | // better error handling. 502 | // [source](https://github.com/sanity-io/diff-match-patch/blob/v3.2.0/src/patch/make.ts#L67-L76) 503 | // 504 | // Performance validation (M2 MacBook Pro): 505 | // Both approaches measured at identical performance: 506 | // - 10KB strings: 0-1ms total processing time 507 | // - 100KB strings: 0-1ms total processing time 508 | // - Individual step breakdown: makeDiff(0ms) + cleanup(0ms) + makePatches(0ms) + stringify(~1ms) 509 | const strPatch = stringifyPatches(makePatches(source, target)) 510 | return {op: 'diffMatchPatch', path, value: strPatch} 511 | } catch (err) { 512 | // Fall back to using regular set patch 513 | return undefined 514 | } 515 | } 516 | 517 | function diffString(source: string, target: string, path: Path, patches: Patch[]) { 518 | const dmp = getDiffMatchPatch(source, target, path) 519 | patches.push(dmp ?? {op: 'set', path, value: target}) 520 | return patches 521 | } 522 | 523 | function isNotIgnoredKey(key: string) { 524 | return SYSTEM_KEYS.indexOf(key) === -1 525 | } 526 | 527 | // mutually exclusive operations 528 | type SanityPatchOperation = 529 | | SanitySetPatchOperation 530 | | SanityUnsetPatchOperation 531 | | SanityInsertPatchOperation 532 | | SanityDiffMatchPatchOperation 533 | 534 | function serializePatches(patches: Patch[], curr?: SanityPatchOperation): SanityPatchOperations[] { 535 | const [patch, ...rest] = patches 536 | if (!patch) return curr ? [curr] : [] 537 | 538 | switch (patch.op) { 539 | case 'set': 540 | case 'diffMatchPatch': { 541 | // TODO: reconfigure eslint to use @typescript-eslint/no-unused-vars 542 | // eslint-disable-next-line no-unused-vars 543 | type CurrentOp = Extract 544 | const emptyOp = {[patch.op]: {}} as CurrentOp 545 | 546 | if (!curr) return serializePatches(patches, emptyOp) 547 | if (!(patch.op in curr)) return [curr, ...serializePatches(patches, emptyOp)] 548 | 549 | Object.assign((curr as CurrentOp)[patch.op], {[pathToString(patch.path)]: patch.value}) 550 | return serializePatches(rest, curr) 551 | } 552 | case 'unset': { 553 | const emptyOp = {unset: []} 554 | if (!curr) return serializePatches(patches, emptyOp) 555 | if (!('unset' in curr)) return [curr, ...serializePatches(patches, emptyOp)] 556 | 557 | curr.unset.push(pathToString(patch.path)) 558 | return serializePatches(rest, curr) 559 | } 560 | case 'insert': { 561 | if (curr) return [curr, ...serializePatches(patches)] 562 | 563 | return [ 564 | { 565 | insert: { 566 | [patch.position]: pathToString(patch.path), 567 | items: patch.items, 568 | }, 569 | } as SanityInsertPatchOperation, 570 | ...serializePatches(rest), 571 | ] 572 | } 573 | case 'reorder': { 574 | if (curr) return [curr, ...serializePatches(patches)] 575 | 576 | // REORDER STRATEGY: Two-phase approach to avoid key collisions 577 | // 578 | // Problem: Direct key swaps can cause collisions. For example, swapping A↔B: 579 | // - Set A's content to B: ✓ 580 | // - Set B's content to A: ✗ (A's content was already overwritten) 581 | // 582 | // Solution: Use temporary keys as an intermediate step 583 | // Phase 1: Move all items to temporary keys with their final content 584 | // Phase 2: Update just the _key property to restore the final keys 585 | 586 | // Phase 1: Move items to collision-safe temporary keys 587 | const tempKeyOperations: SanityPatchOperations = {} 588 | tempKeyOperations.set = {} 589 | 590 | for (const {sourceKey, targetKey} of patch.reorders) { 591 | const temporaryKey = `__temp_reorder_${sourceKey}__` 592 | const finalContentForThisPosition = 593 | patch.snapshot[getIndexForKey(patch.snapshot, targetKey)] 594 | 595 | Object.assign(tempKeyOperations.set, { 596 | [pathToString([...patch.path, {_key: sourceKey}])]: { 597 | ...finalContentForThisPosition, 598 | _key: temporaryKey, 599 | }, 600 | }) 601 | } 602 | 603 | // Phase 2: Update _key properties to restore the intended final keys 604 | const finalKeyOperations: SanityPatchOperations = {} 605 | finalKeyOperations.set = {} 606 | 607 | for (const {sourceKey, targetKey} of patch.reorders) { 608 | const temporaryKey = `__temp_reorder_${sourceKey}__` 609 | 610 | Object.assign(finalKeyOperations.set, { 611 | [pathToString([...patch.path, {_key: temporaryKey}, '_key'])]: targetKey, 612 | }) 613 | } 614 | 615 | return [tempKeyOperations, finalKeyOperations, ...serializePatches(rest)] 616 | } 617 | default: { 618 | return [] 619 | } 620 | } 621 | } 622 | 623 | function isUniquelyKeyed(arr: unknown[]): arr is KeyedSanityObject[] { 624 | const seenKeys = new Set() 625 | 626 | for (const item of arr) { 627 | // Each item must be a keyed object with a _key property 628 | if (!isKeyedObject(item)) return false 629 | 630 | // Each _key must be unique within the array 631 | if (seenKeys.has(item._key)) return false 632 | 633 | seenKeys.add(item._key) 634 | } 635 | 636 | return true 637 | } 638 | 639 | // Cache to avoid recomputing key-to-index mappings for the same array 640 | const keyToIndexCache = new WeakMap>() 641 | 642 | function getIndexForKey(keyedArray: KeyedSanityObject[], targetKey: string) { 643 | const cachedMapping = keyToIndexCache.get(keyedArray) 644 | if (cachedMapping) return cachedMapping[targetKey] 645 | 646 | // Build a mapping from _key to array index 647 | const keyToIndexMapping = keyedArray.reduce>( 648 | (mapping, {_key}, arrayIndex) => { 649 | mapping[_key] = arrayIndex 650 | return mapping 651 | }, 652 | {}, 653 | ) 654 | 655 | keyToIndexCache.set(keyedArray, keyToIndexMapping) 656 | 657 | return keyToIndexMapping[targetKey] 658 | } 659 | 660 | function isRecord(value: unknown): value is Record { 661 | return typeof value === 'object' && !!value && !Array.isArray(value) 662 | } 663 | 664 | /** 665 | * Simplify returns `null` if the value given was `undefined`. This behavior 666 | * is the same as how `JSON.stringify` works so this is relatively expected 667 | * behavior. 668 | */ 669 | function nullifyUndefined(item: unknown) { 670 | if (item === undefined) return null 671 | return item 672 | } 673 | 674 | function yes(_: unknown) { 675 | return true 676 | } 677 | --------------------------------------------------------------------------------