@sanity/diff-patch 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,142 +2,295 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@sanity/diff-patch.svg?style=flat-square)](https://www.npmjs.com/package/@sanity/diff-patch)[![npm bundle size](https://img.shields.io/bundlephobia/minzip/@sanity/diff-patch?style=flat-square)](https://bundlephobia.com/result?p=@sanity/diff-patch)[![npm weekly downloads](https://img.shields.io/npm/dw/@sanity/diff-patch.svg?style=flat-square)](https://www.npmjs.com/package/@sanity/diff-patch)
4
4
 
5
- Generates a set of [Sanity](https://www.sanity.io/) patch mutations needed to change an item (usually a document) from one shape to another.
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
6
 
7
- Most values will become simple `set`, `unset` or `insert` operations, but it will also (by default) try to use [diff-match-patch](https://www.sanity.io/docs/http-patches#diffmatchpatch-aTbJhlAJ) for strings ([read more](#diff-match-patch)).
7
+ ## Objectives
8
8
 
9
- An `ifRevisionID` constraint can be given to generate patches that include this revision as a safeguard, to prevent modifying documents that has changed since the diff was generated.
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
10
13
 
11
- The document ID used in the patches is either extracted from item A and item B (`_id` property), or can be set explicitly by using the `id` option. This is _required_ if the `_id` property differs in the two items, to prevent patching the wrong document.
14
+ Used internally by the Sanity App SDK for its collaborative editing system.
12
15
 
13
- If encountering `undefined` values within an array, they will be converted to `null` values and print a warning to the console. Should you want to remove undefined values from arrays, manually remove them from the array prior to diffing (`array.filter(item => typeof item === 'undefined')` is your friend).
16
+ ## Installation
14
17
 
15
- ## Getting started
18
+ ```bash
19
+ npm install @sanity/diff-patch
20
+ ```
16
21
 
17
- npm install --save @sanity/diff-patch
22
+ ## API Reference
18
23
 
19
- ## Usage
24
+ ### `diffPatch(source, target, options?)`
20
25
 
21
- ```js
22
- import {diffPatch} from '@sanity/diff-patch'
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
23
33
 
24
- const patch = diffPatch(itemA, itemB)
25
- /*
26
- [
27
- {patch: {id: 'docId', set: {...}}},
28
- {patch: {id: 'docId', unset: [...]}},
29
- {patch: {id: 'docId', insert: {...}}}
30
- ]
31
- */
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
+ }
32
44
  ```
33
45
 
34
- ## Usage with mutations
46
+ **Example:**
35
47
 
36
48
  ```js
37
49
  import {diffPatch} from '@sanity/diff-patch'
38
- import sanityClient from './myConfiguredSanityClient'
39
50
 
40
- const itemA = {
41
- _id: 'die-hard-iii',
51
+ const source = {
52
+ _id: 'movie-123',
42
53
  _type: 'movie',
43
- _rev: 'k0k0s',
44
- name: 'Die Hard 3',
45
- year: 1995,
46
- characters: [
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: [
47
146
  {
48
- _key: 'ma4sg31',
49
- name: 'John McClane'
147
+ _key: 'intro',
148
+ _type: 'paragraph',
149
+ text: 'Sanity is a complete content operating system for modern applications.',
50
150
  },
51
151
  {
52
- _key: 'l13ma92',
53
- name: 'Simon Gruber'
54
- }
55
- ]
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
+ ],
56
162
  }
57
163
 
58
- const itemB = {
59
- _id: 'drafts.die-hard-iii',
60
- _type: 'movie',
61
- name: 'Die Hard with a Vengeance',
62
- characters: [
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
+ },
63
195
  {
64
- _key: 'ma4sg31',
65
- name: 'John McClane'
196
+ _key: 'benefits',
197
+ _type: 'paragraph',
198
+ text: 'It offers real-time collaboration and gives developers control over the entire stack.',
66
199
  },
67
200
  {
68
- _key: 'l13ma92',
69
- name: 'Simon Grüber'
70
- }
71
- ]
201
+ _key: 'conclusion',
202
+ _type: 'paragraph',
203
+ text: 'Learning Sanity will help you take control of your content workflow.',
204
+ },
205
+ ],
72
206
  }
73
207
 
74
- // Specify id if the two documents do not match
75
- const operations = diffPatch(itemA, itemB, {id: itemA._id, ifRevisionID: itemA._rev})
76
- await sanityClient.transaction(operations).commit()
77
-
78
- // Patches generated:
79
- const generatedPatches = [
80
- {
81
- patch: {
82
- id: 'die-hard-iii',
83
- ifRevisionID: 'k0k0s',
84
- set: {
85
- 'name': 'Die Hard with a Vengeance',
86
- 'characters[_key=="l13ma92"].name': 'Simon Grüber'
87
- },
88
- }
89
- },
90
- {
91
- patch: {
92
- id: 'die-hard-iii',
93
- unset: ['year']
94
- }
95
- }
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
+ ],
96
235
  }
97
236
  ```
98
237
 
99
- ## diff-match-patch
238
+ ## Technical Details
100
239
 
101
- When encountering two strings to compare, this module will (by default) attempt to use [diff-match-patch (DMP)](https://www.sanity.io/docs/http-patches#diffmatchpatch-aTbJhlAJ) to transition the string from one state to the next.
240
+ ### String Diffing with diff-match-patch
102
241
 
103
- While these patches are extremely helpful (especially in a real-time, collaborative environment), they sometimes grow quite large and can be hard for humans to read. There are a few options you can use to tweak _when_ this module will use these patches, and when to fall back to a regular `set`-patch instead.
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.
104
243
 
105
- The default rules says:
244
+ **Automatic selection criteria:**
106
245
 
107
- - If the target string is below 30 characters long, don't use DMP
108
- - If the generated DMP is greater than 1.2 times larger than the target string, don't use DMP
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
109
250
 
110
- To tune these rules, you can pass options to the differ:
251
+ **Performance rationale:**
111
252
 
112
- ```js
113
- import {diffPatch} from '@sanity/diff-patch'
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.
114
261
 
115
- diffPatch(itemA, itemB, {
116
- diffMatchPatch: {
117
- // Default is true, set to false to _always_ use `set`-patches
118
- enabled: true,
119
-
120
- // Only use diff-match-patch if target string is longer than this threshold
121
- lengthThresholdAbsolute: 30,
122
-
123
- // Only use generated diff-match-patch if the patch length is less than or equal to
124
- // (targetString * relative). Example: A 100 character target with a relative factor
125
- // of 1.2 will allow a 120 character diff-match-patch. If larger than this number,
126
- // it will fall back to a regular `set` patch.
127
- lengthThresholdRelative: 1.2,
128
- },
129
- })
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')
130
276
  ```
131
277
 
132
- In addition to these rules, there are certain cases where it will never use diff-match-patch:
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:
133
281
 
134
- - If the patched key starts with an underscore (eg `_type`, `_key`, `_ref`)
135
- - If the diff-match-patch implementation cannot generate a legal patch due to unicode issues
282
+ - `_id`
283
+ - `_type`
284
+ - `_createdAt`
285
+ - `_updatedAt`
286
+ - `_rev`
136
287
 
137
- ## Needs improvement
288
+ ### Error Handling
138
289
 
139
- - Improve patch on array item move
140
- - Improve patch on array item delete
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
141
294
 
142
295
  ## License
143
296