@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 +248 -95
- package/dist/index.cjs +180 -160
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +47 -151
- package/dist/index.d.ts +47 -151
- package/dist/index.js +182 -162
- package/dist/index.js.map +1 -1
- package/package.json +5 -1
- package/src/diffPatch.ts +458 -357
- package/src/index.ts +3 -15
- package/src/patches.ts +53 -32
- package/src/paths.ts +12 -4
- package/src/setOperations.ts +29 -0
package/README.md
CHANGED
|
@@ -2,142 +2,295 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+
## Objectives
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
14
|
+
Used internally by the Sanity App SDK for its collaborative editing system.
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
## Installation
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
```bash
|
|
19
|
+
npm install @sanity/diff-patch
|
|
20
|
+
```
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
## API Reference
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
### `diffPatch(source, target, options?)`
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
46
|
+
**Example:**
|
|
35
47
|
|
|
36
48
|
```js
|
|
37
49
|
import {diffPatch} from '@sanity/diff-patch'
|
|
38
|
-
import sanityClient from './myConfiguredSanityClient'
|
|
39
50
|
|
|
40
|
-
const
|
|
41
|
-
_id: '
|
|
51
|
+
const source = {
|
|
52
|
+
_id: 'movie-123',
|
|
42
53
|
_type: 'movie',
|
|
43
|
-
_rev: '
|
|
44
|
-
|
|
45
|
-
year:
|
|
46
|
-
|
|
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: '
|
|
49
|
-
|
|
147
|
+
_key: 'intro',
|
|
148
|
+
_type: 'paragraph',
|
|
149
|
+
text: 'Sanity is a complete content operating system for modern applications.',
|
|
50
150
|
},
|
|
51
151
|
{
|
|
52
|
-
_key: '
|
|
53
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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: '
|
|
65
|
-
|
|
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: '
|
|
69
|
-
|
|
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
|
-
//
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
##
|
|
238
|
+
## Technical Details
|
|
100
239
|
|
|
101
|
-
|
|
240
|
+
### String Diffing with diff-match-patch
|
|
102
241
|
|
|
103
|
-
|
|
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
|
-
|
|
244
|
+
**Automatic selection criteria:**
|
|
106
245
|
|
|
107
|
-
-
|
|
108
|
-
-
|
|
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
|
-
|
|
251
|
+
**Performance rationale:**
|
|
111
252
|
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
135
|
-
-
|
|
282
|
+
- `_id`
|
|
283
|
+
- `_type`
|
|
284
|
+
- `_createdAt`
|
|
285
|
+
- `_updatedAt`
|
|
286
|
+
- `_rev`
|
|
136
287
|
|
|
137
|
-
|
|
288
|
+
### Error Handling
|
|
138
289
|
|
|
139
|
-
-
|
|
140
|
-
-
|
|
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
|
|