@sanity/diff-patch 5.0.0 → 7.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
@@ -1,143 +1,303 @@
1
1
  # @sanity/diff-patch
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@sanity/diff-patch.svg?style=flat-square)](https://www.npmjs.com/package/@sanity/diff-patch)[![npm bundle size](https://img.shields.io/bundlephobia/minzip/@sanity/diff-patch?style=flat-square)](https://bundlephobia.com/result?p=@sanity/diff-patch)[![npm weekly downloads](https://img.shields.io/npm/dw/@sanity/diff-patch.svg?style=flat-square)](https://www.npmjs.com/package/@sanity/diff-patch)
3
+ [![Latest version](https://npmx.dev/api/registry/badge/version/@sanity/diff-patch?color=69E3EE)](https://npmx.dev/package/@sanity/diff-patch)
4
+ [![Number of dependencies](https://npmx.dev/api/registry/badge/dependencies/@sanity/diff-patch?color=69E3EE)](https://npmx.dev/package/@sanity/diff-patch)
5
+ [![Supported node versions](https://npmx.dev/api/registry/badge/engines/@sanity/diff-patch?color=69E3EE)](https://npmx.dev/package/@sanity/diff-patch)
6
+ [![Downloads per month](https://npmx.dev/api/registry/badge/downloads/@sanity/diff-patch?color=69E3EE)](https://npmx.dev/package/@sanity/diff-patch)
4
7
 
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.
8
+ 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
9
 
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)).
10
+ ## Objectives
8
11
 
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.
12
+ - **Conflict-resistant patches**: Generate operations that work well in 3-way merges and collaborative scenarios
13
+ - **Performance**: Optimized for real-time, per-keystroke patch generation
14
+ - **Intent preservation**: Capture the user's intended change rather than just the final state
15
+ - **Reliability**: Consistent, well-tested behavior across different data types and editing patterns
10
16
 
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.
17
+ Used internally by the Sanity App SDK for its collaborative editing system.
12
18
 
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).
19
+ ## Installation
14
20
 
15
- ## Getting started
21
+ Requires Node.js 22.12.0 or later. This package ships ES modules only.
16
22
 
17
- npm install --save @sanity/diff-patch
23
+ To work on this repository, use Node.js 24 (`nvm use`) and the pnpm version specified in `package.json`.
18
24
 
19
- ## Usage
25
+ ```bash
26
+ npm install @sanity/diff-patch
27
+ ```
20
28
 
21
- ```js
22
- import {diffPatch} from '@sanity/diff-patch'
29
+ ## API Reference
30
+
31
+ ### `diffPatch(source, target, options?)`
32
+
33
+ Generate patch mutations to transform a source document into a target document.
34
+
35
+ **Parameters:**
36
+
37
+ - `source: DocumentStub` - The original document
38
+ - `target: DocumentStub` - The desired document state
39
+ - `options?: PatchOptions` - Configuration options
40
+
41
+ **Returns:** `SanityPatchMutation[]` - Array of patch mutations
42
+
43
+ **Options:**
23
44
 
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
- */
45
+ ```typescript
46
+ interface PatchOptions {
47
+ id?: string // Document ID (extracted from _id if not provided)
48
+ basePath?: Path // Base path for patches (default: [])
49
+ ifRevisionID?: string | true // Revision lock for optimistic updates
50
+ }
32
51
  ```
33
52
 
34
- ## Usage with mutations
53
+ **Example:**
35
54
 
36
55
  ```js
37
56
  import {diffPatch} from '@sanity/diff-patch'
38
- import sanityClient from './myConfiguredSanityClient'
39
57
 
40
- const itemA = {
41
- _id: 'die-hard-iii',
58
+ const source = {
59
+ _id: 'movie-123',
60
+ _type: 'movie',
61
+ _rev: 'abc',
62
+ title: 'The Matrix',
63
+ year: 1999,
64
+ }
65
+
66
+ const target = {
67
+ _id: 'movie-123',
42
68
  _type: 'movie',
43
- _rev: 'k0k0s',
44
- name: 'Die Hard 3',
45
- year: 1995,
46
- characters: [
69
+ title: 'The Matrix Reloaded',
70
+ year: 2003,
71
+ director: 'The Wachowskis',
72
+ }
73
+
74
+ const mutations = diffPatch(source, target, {ifRevisionID: true})
75
+ // [
76
+ // {
77
+ // patch: {
78
+ // id: 'movie-123',
79
+ // ifRevisionID: 'abc',
80
+ // set: {
81
+ // title: 'The Matrix Reloaded',
82
+ // year: 2003,
83
+ // director: 'The Wachowskis'
84
+ // }
85
+ // }
86
+ // }
87
+ // ]
88
+ ```
89
+
90
+ ### `diffValue(source, target, basePath?)`
91
+
92
+ Generate patch operations for values without document wrapper.
93
+
94
+ **Parameters:**
95
+
96
+ - `source: unknown` - The original value
97
+ - `target: unknown` - The desired value state
98
+ - `basePath?: Path` - Base path to prefix operations (default: [])
99
+
100
+ **Returns:** `SanityPatchOperations[]` - Array of patch operations
101
+
102
+ **Example:**
103
+
104
+ ```js
105
+ import {diffValue} from '@sanity/diff-patch'
106
+
107
+ const source = {
108
+ name: 'John',
109
+ tags: ['developer'],
110
+ }
111
+
112
+ const target = {
113
+ name: 'John Doe',
114
+ tags: ['developer', 'typescript'],
115
+ active: true,
116
+ }
117
+
118
+ const operations = diffValue(source, target)
119
+ // [
120
+ // {
121
+ // set: {
122
+ // name: 'John Doe',
123
+ // 'tags[1]': 'typescript',
124
+ // active: true
125
+ // }
126
+ // }
127
+ // ]
128
+
129
+ // With base path
130
+ const operations = diffValue(source, target, ['user', 'profile'])
131
+ // [
132
+ // {
133
+ // set: {
134
+ // 'user.profile.name': 'John Doe',
135
+ // 'user.profile.tags[1]': 'typescript',
136
+ // 'user.profile.active': true
137
+ // }
138
+ // }
139
+ // ]
140
+ ```
141
+
142
+ ## Collaborative Editing Example
143
+
144
+ The library generates patches that preserve user intent and minimize conflicts in collaborative scenarios:
145
+
146
+ ```js
147
+ // Starting document
148
+ const originalDoc = {
149
+ _id: 'blog-post-123',
150
+ _type: 'blogPost',
151
+ title: 'Getting Started with Sanity',
152
+ paragraphs: [
47
153
  {
48
- _key: 'ma4sg31',
49
- name: 'John McClane'
154
+ _key: 'intro',
155
+ _type: 'paragraph',
156
+ text: 'Sanity is a complete content operating system for modern applications.',
50
157
  },
51
158
  {
52
- _key: 'l13ma92',
53
- name: 'Simon Gruber'
54
- }
55
- ]
159
+ _key: 'benefits',
160
+ _type: 'paragraph',
161
+ text: 'It offers real-time collaboration and gives developers controll over the entire stack.',
162
+ },
163
+ {
164
+ _key: 'conclusion',
165
+ _type: 'paragraph',
166
+ text: 'Learning Sanity will help you take control of your content workflow.',
167
+ },
168
+ ],
56
169
  }
57
170
 
58
- const itemB = {
59
- _id: 'drafts.die-hard-iii',
60
- _type: 'movie',
61
- name: 'Die Hard with a Vengeance',
62
- characters: [
171
+ // User A reorders paragraphs AND fixes a typo
172
+ const userAChanges = {
173
+ ...originalDoc,
174
+ paragraphs: [
63
175
  {
64
- _key: 'ma4sg31',
65
- name: 'John McClane'
176
+ _key: 'intro',
177
+ _type: 'paragraph',
178
+ text: 'Sanity is a complete content operating system for modern applications.',
66
179
  },
67
180
  {
68
- _key: 'l13ma92',
69
- name: 'Simon Grüber'
70
- }
71
- ]
181
+ _key: 'conclusion', // Moved conclusion before benefits
182
+ _type: 'paragraph',
183
+ text: 'Learning Sanity will help you take control of your content workflow.',
184
+ },
185
+ {
186
+ _key: 'benefits',
187
+ _type: 'paragraph',
188
+ text: 'It offers real-time collaboration and gives developers control over the entire stack.', // Fixed typo: "controll" → "control"
189
+ },
190
+ ],
72
191
  }
73
192
 
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
- }
193
+ // User B simultaneously improves the intro text
194
+ const userBChanges = {
195
+ ...originalDoc,
196
+ paragraphs: [
197
+ {
198
+ _key: 'intro',
199
+ _type: 'paragraph',
200
+ text: 'Sanity is a complete content operating system that gives developers control over the entire stack.', // Added more specific language about developer control
201
+ },
202
+ {
203
+ _key: 'benefits',
204
+ _type: 'paragraph',
205
+ text: 'It offers real-time collaboration and gives developers control over the entire stack.',
206
+ },
207
+ {
208
+ _key: 'conclusion',
209
+ _type: 'paragraph',
210
+ text: 'Learning Sanity will help you take control of your content workflow.',
211
+ },
212
+ ],
213
+ }
214
+
215
+ // Generate patches that capture each user's intent
216
+ const patchA = diffPatch(originalDoc, userAChanges)
217
+ const patchB = diffPatch(originalDoc, userBChanges)
218
+
219
+ // Apply both patches - they merge successfully because they target different aspects
220
+ // User A's reordering and typo fix + User B's content improvement both apply
221
+ const finalMergedResult = {
222
+ _id: 'blog-post-123',
223
+ _type: 'blogPost',
224
+ title: 'Getting Started with Sanity',
225
+ paragraphs: [
226
+ {
227
+ _key: 'intro',
228
+ _type: 'paragraph',
229
+ text: 'Sanity is a complete content operating system that gives developers control over the entire stack.', // ✅ User B's improvement
230
+ },
231
+ {
232
+ _key: 'conclusion', // ✅ User A's reordering
233
+ _type: 'paragraph',
234
+ text: 'Learning Sanity will help you take control of your content workflow.',
235
+ },
236
+ {
237
+ _key: 'benefits',
238
+ _type: 'paragraph',
239
+ text: 'It offers real-time collaboration and gives developers control over the entire stack.', // ✅ User A's typo fix
240
+ },
241
+ ],
96
242
  }
97
243
  ```
98
244
 
99
- ## diff-match-patch
245
+ ## Technical Details
100
246
 
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.
247
+ ### String Diffing with diff-match-patch
102
248
 
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.
249
+ 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
250
 
105
- The default rules says:
251
+ **Automatic selection criteria:**
106
252
 
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
253
+ - **String size limit**: Strings larger than 1MB use `set` operations
254
+ - **Change ratio threshold**: If >40% of text changes (determined by simple string length difference), uses `set` (indicates replacement vs. editing)
255
+ - **Small text optimization**: Strings <10KB will always use diff-match-patch
256
+ - **System key protection**: Properties starting with `_` (e.g. `_type`, `_key`) always use `set` operations as these are not typically edited by users
109
257
 
110
- To tune these rules, you can pass options to the differ:
258
+ **Performance rationale:**
111
259
 
112
- ```js
113
- import {diffPatch} from '@sanity/diff-patch'
260
+ These thresholds are based on performance testing of the underlying `@sanity/diff-match-patch` library on an M2 MacBook Pro:
261
+
262
+ - **Keystroke editing**: 0ms for typical edits, sub-millisecond even on large strings
263
+ - **Small insertions/pastes**: 0-10ms for content <50KB
264
+ - **Large insertions/deletions**: 0-50ms for content >50KB
265
+ - **Text replacements**: Can be 70ms-2s+ due to algorithm complexity
266
+
267
+ The 40% change ratio threshold catches problematic replacement scenarios while allowing the algorithm to excel at insertions, deletions, and small edits.
114
268
 
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
- })
269
+ **Migration from v5:**
270
+
271
+ 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.
272
+
273
+ ### Array Handling
274
+
275
+ **Keyed arrays**: Arrays containing objects with `_key` properties are diffed by key rather than index, producing more stable patches for collaborative editing.
276
+
277
+ **Index-based arrays**: Arrays without keys are diffed by index position.
278
+
279
+ **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:
280
+
281
+ ```js
282
+ const cleanArray = array.filter((item) => typeof item !== 'undefined')
130
283
  ```
131
284
 
132
- In addition to these rules, there are certain cases where it will never use diff-match-patch:
285
+ ### System Keys
286
+
287
+ The following keys are ignored at the root of the document when diffing a document as they are managed by Sanity:
133
288
 
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
289
+ - `_id`
290
+ - `_type`
291
+ - `_createdAt`
292
+ - `_updatedAt`
293
+ - `_rev`
136
294
 
137
- ## Needs improvement
295
+ ### Error Handling
138
296
 
139
- - Improve patch on array item move
140
- - Improve patch on array item delete
297
+ - **Missing document ID**: Throws error if `_id` differs between documents and no explicit `id` option provided
298
+ - **Immutable \_type**: Throws error if attempting to change `_type` at document root
299
+ - **Multi-dimensional arrays**: Not supported, throws `DiffError`
300
+ - **Invalid revision**: Throws error if `ifRevisionID: true` but no `_rev` in source document
141
301
 
142
302
  ## License
143
303