@iodev/patch-and-resolve 1.0.6

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 ADDED
@@ -0,0 +1,402 @@
1
+ # Patch and Resolve
2
+
3
+ A library for merging conflicting JSON patches with automatic conflict detection and resolution UI.
4
+
5
+ ## Overview
6
+
7
+ This library helps resolve conflicts when two patches modify the same document. Common use case: a user has a document open on multiple devices (desktop and mobile), makes different changes on each, and both try to save at the same time.
8
+
9
+ **Key Features:**
10
+ - Automatically merges non-conflicting patches
11
+ - Detects and reports conflicts when patches modify the same fields
12
+ - Provides a modal UI for manual conflict resolution
13
+ - Completely agnostic to your storage/API layer
14
+
15
+ ## Demo
16
+
17
+ The demo application shows how to merge multiple remote patches into your local changes:
18
+
19
+ **Step 1: Start with local and remote patches**
20
+
21
+ ![Patch Merge Demo](media/patch-merge-demo.png)
22
+
23
+ **Step 2: When conflicts are detected, navigate through them one at a time**
24
+
25
+ ![Patch Conflict Resolution](media/patch-conflict-resolution.png)
26
+
27
+ **Step 3: Select a value to enable the Next button**
28
+
29
+ ![Patch Conflict Resolution Selected](media/patch-conflict-resolution-selected.png)
30
+
31
+ **Step 4: After resolving all conflicts, see the merged result**
32
+
33
+ ![Patch Merge Demo Merged](media/patch-merge-demo-merged-patches.png)
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ npm install
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ### Basic Example
44
+
45
+ ```typescript
46
+ import { ConflictResolver } from 'patch-and-resolve';
47
+
48
+ const resolver = new ConflictResolver();
49
+
50
+ // Two patches from different sources
51
+ const desktopPatch = { message: 'Updated on desktop', x: 100 };
52
+ const mobilePatch = { imageId: 'img123', y: 200 };
53
+
54
+ // Try to merge them
55
+ const result = resolver.mergePatches(desktopPatch, mobilePatch);
56
+
57
+ if (result.success) {
58
+ // No conflicts - patches modified different fields
59
+ console.log('Merged:', result.merged);
60
+ // { message: 'Updated on desktop', x: 100, imageId: 'img123', y: 200 }
61
+ } else {
62
+ // Conflicts detected - show modal to user
63
+ console.log('Conflicts:', result.conflicts);
64
+ // Manually resolve
65
+ const resolved = resolver.resolveConflict('use-first', desktopPatch, mobilePatch);
66
+ }
67
+ ```
68
+
69
+ ### React Hook
70
+
71
+ ```typescript
72
+ import { usePatchMerger } from 'patch-and-resolve';
73
+
74
+ function MyComponent() {
75
+ const { mergePatches, resolveConflict, mergeResult, hasConflict } = usePatchMerger({
76
+ onMergeSuccess: (merged) => {
77
+ // Save the merged patch
78
+ saveToDB(merged);
79
+ },
80
+ onConflict: (result) => {
81
+ // Show conflict UI
82
+ setShowConflictModal(true);
83
+ }
84
+ });
85
+
86
+ // Merge two patches
87
+ mergePatches(patch1, patch2);
88
+ }
89
+ ```
90
+
91
+ ### React Component
92
+
93
+ The library includes a complete demo component:
94
+
95
+ ```typescript
96
+ import { PatchManager } from 'patch-and-resolve';
97
+
98
+ <PatchManager
99
+ onMergeComplete={(merged) => {
100
+ // Your app handles saving
101
+ await myApiClient.savePatch(merged);
102
+ }}
103
+ />
104
+ ```
105
+
106
+ ## Advanced Features
107
+
108
+ ### Multiple Remote Patches
109
+
110
+ The library now supports merging an array of remote patches into your local changes. This is useful when you need to catch up with multiple versions from a server:
111
+
112
+ ```typescript
113
+ import { ConflictResolver } from 'patch-and-resolve';
114
+
115
+ const resolver = new ConflictResolver();
116
+
117
+ // Desktop at version 15, server has versions 16-20
118
+ const localPatch = { message: 'Local changes', version: 15 };
119
+ const remotePatches = [
120
+ { message: 'Server v16', version: 16 },
121
+ { message: 'Server v17', version: 17 },
122
+ { x: 100, version: 18 },
123
+ { y: 200, version: 19 },
124
+ { color: 'blue', version: 20 }
125
+ ];
126
+
127
+ const result = resolver.mergePatches(localPatch, remotePatches);
128
+ ```
129
+
130
+ Remote patches are merged sequentially, and later patches can overwrite earlier ones without conflict. Conflicts only occur between your local changes and the remote patches.
131
+
132
+ ### Nested Object Support
133
+
134
+ The library supports merging patches with nested object structures. This method handles complex nested data while following simple conflict rules:
135
+
136
+ **Conflict Rules:**
137
+ - ✅ Conflicts occur **only when the exact same path** is modified by both patches
138
+ - ✅ Different paths merge automatically, even under the same parent object
139
+ - ✅ Arrays are treated as single values (conflict if entire array differs)
140
+
141
+ ```typescript
142
+ import { ConflictResolver } from 'patch-and-resolve';
143
+
144
+ const resolver = new ConflictResolver();
145
+
146
+ const localPatch = {
147
+ user: {
148
+ name: 'Alice',
149
+ email: 'alice@example.com',
150
+ },
151
+ title: 'My Project',
152
+ };
153
+
154
+ const remotePatch = {
155
+ user: {
156
+ phone: '555-1234', // Different path: user.phone
157
+ address: { // Different path: user.address.city
158
+ city: 'NYC',
159
+ },
160
+ },
161
+ description: 'Updated', // Different path: description
162
+ };
163
+
164
+ const result = resolver.mergePatches(localPatch, [remotePatch]);
165
+
166
+ // Result: Success! All paths are different, so no conflicts
167
+ // Merged: {
168
+ // user: {
169
+ // name: 'Alice', // from local
170
+ // email: 'alice@...', // from local
171
+ // phone: '555-1234', // from remote
172
+ // address: { city: 'NYC' } // from remote
173
+ // },
174
+ // title: 'My Project', // from local
175
+ // description: 'Updated' // from remote
176
+ // }
177
+ ```
178
+
179
+ **Conflict Example:**
180
+
181
+ ```typescript
182
+ const localPatch = {
183
+ user: {
184
+ name: 'Alice', // This path: user.name
185
+ },
186
+ };
187
+
188
+ const remotePatch = {
189
+ user: {
190
+ name: 'Bob', // Same path: user.name → CONFLICT!
191
+ },
192
+ };
193
+
194
+ const result = resolver.mergePatches(localPatch, [remotePatch]);
195
+
196
+ // Result: Conflict detected
197
+ // result.conflicts[0] = {
198
+ // path: 'user.name',
199
+ // localValue: 'Alice',
200
+ // remoteValue: 'Bob',
201
+ // remotePatchIndex: 0
202
+ // }
203
+ ```
204
+
205
+ **Resolving Conflicts:**
206
+
207
+ ```typescript
208
+ // After user makes choices in the UI
209
+ const resolutions = [
210
+ { conflictIndex: 0, strategy: 'use-local' }, // Keep 'Alice'
211
+ ];
212
+
213
+ const resolved = resolver.applyResolutions(localPatch, [remotePatch], resolutions);
214
+ // resolved.resolved contains the final merged patch
215
+ ```
216
+
217
+ **Path Format:**
218
+ - Paths use dot notation: `"user.name"`, `"user.address.city"`
219
+ - Arrays are identified by their parent path: `"tags"`, `"pages"`
220
+ - Version fields are automatically handled and excluded from conflict detection
221
+
222
+ ### Conflict Navigation
223
+
224
+ When multiple conflicts are detected, the UI provides Previous/Next/Finish buttons to step through them one at a time:
225
+
226
+ ```typescript
227
+ import { usePatchManager } from 'patch-and-resolve';
228
+
229
+ const { mergePatches, applyResolutions } = usePatchManager({
230
+ onMergeSuccess: (merged) => saveToDB(merged)
231
+ });
232
+
233
+ // Merge and get conflicts
234
+ mergePatches(localPatch, remotePatches);
235
+
236
+ // Resolve conflicts one by one
237
+ const resolutions = [
238
+ { conflictIndex: 0, strategy: 'use-local' },
239
+ { conflictIndex: 1, strategy: 'use-remote' },
240
+ { conflictIndex: 2, strategy: 'use-local' }
241
+ ];
242
+
243
+ applyResolutions(resolutions);
244
+ ```
245
+
246
+ ### Custom Conflict Visualization
247
+
248
+ Use the `renderConflictValue` prop to customize how conflict values are displayed. This is powerful for showing semantic previews instead of raw JSON:
249
+
250
+ ```typescript
251
+ import { ConflictModal, ConflictValueRenderContext } from 'patch-and-resolve';
252
+
253
+ <ConflictModal
254
+ conflicts={conflicts}
255
+ onResolve={handleResolve}
256
+ onCancel={handleCancel}
257
+ renderConflictValue={(value, context: ConflictValueRenderContext) => {
258
+ const { conflict, side, isSelected } = context;
259
+
260
+ // Show a visual preview for your specific data structure
261
+ if (value.x !== undefined && value.y !== undefined) {
262
+ return (
263
+ <div>
264
+ <div
265
+ style={{
266
+ position: 'relative',
267
+ width: 200,
268
+ height: 150,
269
+ border: '1px solid #ccc',
270
+ margin: '8px 0'
271
+ }}
272
+ >
273
+ <div style={{
274
+ position: 'absolute',
275
+ left: value.x,
276
+ top: value.y,
277
+ padding: '4px 8px',
278
+ background: isSelected ? '#4CAF50' : '#2196F3',
279
+ color: 'white',
280
+ borderRadius: '4px'
281
+ }}>
282
+ {value.message}
283
+ </div>
284
+ </div>
285
+
286
+ {/* Also show the raw JSON */}
287
+ <pre>{JSON.stringify(value, null, 2)}</pre>
288
+ </div>
289
+ );
290
+ }
291
+
292
+ // Default rendering for other types
293
+ return <pre>{JSON.stringify(value, null, 2)}</pre>;
294
+ }}
295
+ />
296
+ ```
297
+
298
+ The `ConflictValueRenderContext` provides:
299
+ - `conflict`: The full conflict object with path, localValue, remoteValue
300
+ - `side`: Either `'local'` or `'remote'`
301
+ - `isSelected`: Boolean indicating if this value is currently selected
302
+
303
+ This allows you to create rich, context-aware visualizations that help users make informed decisions about which value to keep.
304
+
305
+ ### Integration with JSON Patch (RFC 6902)
306
+
307
+ If your application uses JSON Patch format (RFC 6902) with libraries like `fast-json-patch`, you can convert between JSON Patch operations and the simple diff format used by this library:
308
+
309
+ ```typescript
310
+ import { jsonPatchToDiff, diffToJsonPatch, ConflictResolver } from 'patch-and-resolve';
311
+
312
+ // Backend sends JSON Patch operations
313
+ const localOps = [
314
+ { op: "replace", path: "/message", value: "Local edit" },
315
+ { op: "add", path: "/x", value: 100 }
316
+ ];
317
+
318
+ const remoteOps = [
319
+ { op: "replace", path: "/message", value: "Remote edit" },
320
+ { op: "add", path: "/y", value: 200 }
321
+ ];
322
+
323
+ // Convert to diffs for conflict resolution
324
+ const localDiff = jsonPatchToDiff(localOps); // { message: "Local edit", x: 100 }
325
+ const remoteDiff = jsonPatchToDiff(remoteOps); // { message: "Remote edit", y: 200 }
326
+
327
+ // Merge with conflict detection
328
+ const resolver = new ConflictResolver();
329
+ const result = resolver.mergePatches(localDiff, [remoteDiff]);
330
+
331
+ if (result.success) {
332
+ // Convert back to JSON Patch if needed
333
+ const patchOps = diffToJsonPatch(result.merged);
334
+ // Result: [
335
+ // { op: "replace", path: "/message", value: "Local edit" },
336
+ // { op: "replace", path: "/x", value: 100 },
337
+ // { op: "replace", path: "/y", value: 200 }
338
+ // ]
339
+
340
+ // Apply to your document with fast-json-patch
341
+ applyPatch(document, patchOps);
342
+ } else {
343
+ // Show conflict UI to user
344
+ // After user resolves conflicts:
345
+ const resolved = resolver.applyResolutions(localDiff, [remoteDiff], resolutions);
346
+
347
+ // Convert resolved patch back to JSON Patch format
348
+ const resolvedOps = diffToJsonPatch(resolved.resolved);
349
+ // Send back to server or apply locally
350
+ applyPatch(document, resolvedOps);
351
+ }
352
+ ```
353
+
354
+ **Supported JSON Patch Operations:**
355
+ - ✅ `add` and `replace` - Converted to field updates
356
+ - ❌ `remove`, `move`, `copy`, `test` - Ignored (don't map to simple diffs)
357
+ - ⚠️ Only top-level paths supported (e.g., `/message` works, `/user/name` ignored)
358
+
359
+ This makes the library compatible with standard JSON Patch workflows while providing an intuitive UI for conflict resolution.
360
+
361
+ ## Development
362
+
363
+ ```bash
364
+ npm run dev # Start demo app
365
+ npm test # Run unit tests
366
+ npm run test:e2e # Run end-to-end tests in headless Chromium
367
+ npm run test:all # Run all tests (unit + e2e)
368
+ npm run build # Build library
369
+ ```
370
+
371
+ ### End-to-End Testing
372
+
373
+ The project includes comprehensive Playwright tests that verify the conflict modal works correctly in a real browser environment:
374
+
375
+ ```bash
376
+ npm run test:e2e # Run e2e tests (headless)
377
+ npm run test:e2e:headed # Run e2e tests with visible browser
378
+ npm run test:e2e:ui # Run e2e tests with Playwright UI
379
+ npx playwright show-report # View last test report
380
+ ```
381
+
382
+ The e2e tests cover:
383
+ - ✓ Merging non-conflicting patches
384
+ - ✓ Detecting conflicts
385
+ - ✓ Displaying the conflict modal
386
+ - ✓ Resolving conflicts with user choice (use patch 1 vs patch 2)
387
+ - ✓ Version number handling (automatically uses higher version)
388
+ - ✓ Canceling conflict resolution
389
+
390
+ ## Commit Convention
391
+
392
+ This project uses [Conventional Commits](https://www.conventionalcommits.org/):
393
+
394
+ - `feat:` - New feature (triggers minor version bump)
395
+ - `fix:` - Bug fix (triggers patch version bump)
396
+ - `feat!:` or `fix!:` - Breaking change (triggers major version bump)
397
+ - `chore:`, `docs:`, `style:`, `refactor:`, `test:` - No version bump
398
+
399
+ ## Versioning
400
+
401
+ Versions are automatically managed by semantic-release based on commit messages.
402
+
@@ -0,0 +1,23 @@
1
+ import { default as React } from 'react';
2
+ import { Conflict, ConflictResolution } from '../types';
3
+ export interface ConflictValueRenderContext {
4
+ conflict: Conflict;
5
+ side: 'local' | 'remote';
6
+ isSelected: boolean;
7
+ }
8
+ interface ConflictModalProps {
9
+ conflicts: Conflict[];
10
+ onResolveAll: (resolutions: ConflictResolution[]) => void;
11
+ onClose: () => void;
12
+ /**
13
+ * Optional custom renderer for conflict values.
14
+ * Use this to show semantic previews instead of raw JSON.
15
+ * If not provided, displays values as formatted JSON.
16
+ */
17
+ renderConflictValue?: (value: any, context: ConflictValueRenderContext) => React.ReactNode;
18
+ }
19
+ /**
20
+ * Modal component for stepping through and resolving patch conflicts one at a time
21
+ */
22
+ export declare const ConflictModal: React.FC<ConflictModalProps>;
23
+ export {};
@@ -0,0 +1,10 @@
1
+ import { default as React } from 'react';
2
+ import { Patch } from '../types';
3
+ interface PatchManagerProps {
4
+ onMergeComplete?: (mergedPatch: Patch) => void;
5
+ }
6
+ /**
7
+ * Demo component showing how to merge multiple patches and handle conflicts
8
+ */
9
+ export declare const PatchManager: React.FC<PatchManagerProps>;
10
+ export {};
@@ -0,0 +1,17 @@
1
+ import { Patch, MergeResult, ConflictResolution } from '../types';
2
+ interface UsePatchMergerOptions {
3
+ onMergeSuccess?: (mergedPatch: Patch) => void;
4
+ onConflict?: (result: MergeResult) => void;
5
+ }
6
+ /**
7
+ * Custom hook for merging multiple patches and handling conflicts
8
+ */
9
+ export declare function usePatchMerger(options?: UsePatchMergerOptions): {
10
+ merging: boolean;
11
+ mergeResult: MergeResult | null;
12
+ hasConflict: boolean | null;
13
+ mergePatches: (local: Patch, remote: Patch[]) => MergeResult;
14
+ applyResolutions: (resolutions: ConflictResolution[]) => Patch | null;
15
+ clearConflict: () => void;
16
+ };
17
+ export {};
@@ -0,0 +1,9 @@
1
+ export { PatchManager } from './components/PatchManager';
2
+ export { ConflictModal } from './components/ConflictModal';
3
+ export type { ConflictValueRenderContext } from './components/ConflictModal';
4
+ export { usePatchMerger } from './hooks/usePatchManager';
5
+ export { ConflictResolver } from './services/ConflictResolver';
6
+ export type { Patch, Conflict, MergeResult, ResolveStrategy, ConflictResolution, ResolveResult, } from './types';
7
+ export { jsonPatchToDiff, diffToJsonPatch, } from './utils/jsonPatchAdapter';
8
+ export type { JsonPatchOperation } from './utils/jsonPatchAdapter';
9
+ export { getNestedValue, setNestedValue, hasNestedPath, getAllPaths, deepEqual, } from './utils/deepPath';