├── .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 | [](https://www.npmjs.com/package/@sanity/diff-patch)[](https://bundlephobia.com/result?p=@sanity/diff-patch)[](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 |
--------------------------------------------------------------------------------