patch-recorder 0.0.1 → 0.1.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 +103 -16
- package/dist/arrays.d.ts.map +1 -1
- package/dist/arrays.js +72 -72
- package/dist/arrays.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/maps.d.ts.map +1 -1
- package/dist/maps.js +5 -7
- package/dist/maps.js.map +1 -1
- package/dist/optimizer.d.ts.map +1 -1
- package/dist/optimizer.js +109 -55
- package/dist/optimizer.js.map +1 -1
- package/dist/patches.d.ts +1 -1
- package/dist/patches.d.ts.map +1 -1
- package/dist/patches.js +26 -2
- package/dist/patches.js.map +1 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +19 -3
- package/dist/proxy.js.map +1 -1
- package/dist/types.d.ts +35 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +11 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +48 -9
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
- package/src/arrays.ts +78 -90
- package/src/index.ts +1 -0
- package/src/maps.ts +4 -6
- package/src/optimizer.ts +139 -57
- package/src/patches.ts +32 -4
- package/src/proxy.ts +17 -3
- package/src/types.ts +37 -2
- package/src/utils.ts +62 -10
package/src/arrays.ts
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
import type {RecorderState} from './types.js';
|
|
2
|
-
import {Operation} from './types.js';
|
|
3
2
|
import {generateAddPatch, generateDeletePatch, generateReplacePatch} from './patches.js';
|
|
4
3
|
import {createProxy} from './proxy.js';
|
|
5
4
|
|
|
5
|
+
// Module-level Sets for O(1) lookup instead of O(n) array includes
|
|
6
|
+
const MUTATING_METHODS = new Set([
|
|
7
|
+
'push',
|
|
8
|
+
'pop',
|
|
9
|
+
'shift',
|
|
10
|
+
'unshift',
|
|
11
|
+
'splice',
|
|
12
|
+
'sort',
|
|
13
|
+
'reverse',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const NON_MUTATING_METHODS = new Set([
|
|
17
|
+
'map',
|
|
18
|
+
'filter',
|
|
19
|
+
'reduce',
|
|
20
|
+
'reduceRight',
|
|
21
|
+
'forEach',
|
|
22
|
+
'find',
|
|
23
|
+
'findIndex',
|
|
24
|
+
'some',
|
|
25
|
+
'every',
|
|
26
|
+
'includes',
|
|
27
|
+
'indexOf',
|
|
28
|
+
'lastIndexOf',
|
|
29
|
+
'slice',
|
|
30
|
+
'concat',
|
|
31
|
+
'join',
|
|
32
|
+
'flat',
|
|
33
|
+
'flatMap',
|
|
34
|
+
'at',
|
|
35
|
+
]);
|
|
36
|
+
|
|
6
37
|
/**
|
|
7
38
|
* Handle array method calls and property access
|
|
8
39
|
*/
|
|
@@ -13,43 +44,28 @@ export function handleArrayGet(
|
|
|
13
44
|
state: RecorderState<any>,
|
|
14
45
|
): any {
|
|
15
46
|
// Mutating methods
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (mutatingMethods.includes(prop)) {
|
|
47
|
+
if (MUTATING_METHODS.has(prop)) {
|
|
19
48
|
return (...args: any[]) => {
|
|
20
|
-
|
|
49
|
+
// Optimized: only copy what's needed for each method
|
|
50
|
+
const oldLength = obj.length;
|
|
51
|
+
let oldValue: any[] | null = null;
|
|
52
|
+
|
|
53
|
+
// Only create full copy for sort/reverse which need the entire old array
|
|
54
|
+
if (prop === 'sort' || prop === 'reverse') {
|
|
55
|
+
oldValue = [...obj];
|
|
56
|
+
}
|
|
57
|
+
|
|
21
58
|
const result = (Array.prototype as any)[prop].apply(obj, args);
|
|
22
59
|
|
|
23
60
|
// Generate patches based on the method
|
|
24
|
-
generateArrayPatches(state, obj, prop, args, result, path, oldValue);
|
|
61
|
+
generateArrayPatches(state, obj, prop, args, result, path, oldValue, oldLength);
|
|
25
62
|
|
|
26
63
|
return result;
|
|
27
64
|
};
|
|
28
65
|
}
|
|
29
66
|
|
|
30
67
|
// Non-mutating methods - just return them bound to the array
|
|
31
|
-
|
|
32
|
-
'map',
|
|
33
|
-
'filter',
|
|
34
|
-
'reduce',
|
|
35
|
-
'reduceRight',
|
|
36
|
-
'forEach',
|
|
37
|
-
'find',
|
|
38
|
-
'findIndex',
|
|
39
|
-
'some',
|
|
40
|
-
'every',
|
|
41
|
-
'includes',
|
|
42
|
-
'indexOf',
|
|
43
|
-
'lastIndexOf',
|
|
44
|
-
'slice',
|
|
45
|
-
'concat',
|
|
46
|
-
'join',
|
|
47
|
-
'flat',
|
|
48
|
-
'flatMap',
|
|
49
|
-
'at',
|
|
50
|
-
];
|
|
51
|
-
|
|
52
|
-
if (nonMutatingMethods.includes(prop)) {
|
|
68
|
+
if (NON_MUTATING_METHODS.has(prop)) {
|
|
53
69
|
return (Array.prototype as any)[prop].bind(obj);
|
|
54
70
|
}
|
|
55
71
|
|
|
@@ -82,100 +98,71 @@ function generateArrayPatches(
|
|
|
82
98
|
args: any[],
|
|
83
99
|
result: any,
|
|
84
100
|
path: (string | number)[],
|
|
85
|
-
oldValue: any[],
|
|
101
|
+
oldValue: any[] | null,
|
|
102
|
+
oldLength: number,
|
|
86
103
|
) {
|
|
87
104
|
switch (method) {
|
|
88
105
|
case 'push': {
|
|
89
106
|
// Generate add patches for each new element
|
|
90
|
-
|
|
107
|
+
// oldLength is the starting index before push
|
|
91
108
|
args.forEach((value, i) => {
|
|
92
|
-
const index =
|
|
109
|
+
const index = oldLength + i;
|
|
93
110
|
generateAddPatch(state, [...path, index], value);
|
|
94
111
|
});
|
|
95
|
-
|
|
96
|
-
// Generate length patch if option is enabled
|
|
97
|
-
if (state.options.arrayLengthAssignment !== false) {
|
|
98
|
-
generateReplacePatch(state, [...path, 'length'], obj.length);
|
|
99
|
-
}
|
|
112
|
+
// No length patch when array grows (aligned with mutative)
|
|
100
113
|
break;
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
case 'pop': {
|
|
104
|
-
// Generate remove patch for the removed element
|
|
105
|
-
const removedIndex = oldValue.length - 1;
|
|
106
|
-
generateDeletePatch(state, [...path, removedIndex], result);
|
|
107
|
-
|
|
108
|
-
// Generate length patch if option is enabled
|
|
109
117
|
if (state.options.arrayLengthAssignment !== false) {
|
|
110
|
-
|
|
118
|
+
// Generate length replace patch (mutative uses this instead of remove)
|
|
119
|
+
generateReplacePatch(state, [...path, 'length'], obj.length, oldLength);
|
|
120
|
+
} else {
|
|
121
|
+
// When arrayLengthAssignment is false, generate remove patch for last element
|
|
122
|
+
generateDeletePatch(state, [...path, oldLength - 1], result);
|
|
111
123
|
}
|
|
112
124
|
break;
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
case 'shift': {
|
|
116
|
-
//
|
|
128
|
+
// Remove first element (shifted elements are handled automatically by JSON Patch spec)
|
|
129
|
+
// We don't have oldValue here, but the result of shift() is the removed element
|
|
117
130
|
generateDeletePatch(state, [...path, 0], result);
|
|
118
|
-
|
|
119
|
-
// Shift is complex - we need to update all remaining elements
|
|
120
|
-
// Update all shifted elements (after the shift, each element moves to index - 1)
|
|
121
|
-
for (let i = 0; i < obj.length; i++) {
|
|
122
|
-
generateReplacePatch(state, [...path, i], oldValue[i + 1]);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Generate length patch if option is enabled
|
|
126
|
-
if (state.options.arrayLengthAssignment !== false) {
|
|
127
|
-
generateReplacePatch(state, [...path, 'length'], obj.length);
|
|
128
|
-
}
|
|
129
131
|
break;
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
case 'unshift': {
|
|
133
|
-
// Add new elements at the beginning
|
|
135
|
+
// Add new elements at the beginning (shifted elements are handled automatically by JSON Patch spec)
|
|
134
136
|
args.forEach((value, i) => {
|
|
135
137
|
generateAddPatch(state, [...path, i], value);
|
|
136
138
|
});
|
|
137
|
-
|
|
138
|
-
// Update all existing elements
|
|
139
|
-
|
|
140
|
-
for (let i = 0; i < oldValue.length; i++) {
|
|
141
|
-
generateReplacePatch(state, [...path, i + args.length], oldValue[i]);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Generate length patch if option is enabled
|
|
145
|
-
if (state.options.arrayLengthAssignment !== false) {
|
|
146
|
-
generateReplacePatch(state, [...path, 'length'], obj.length);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
139
|
break;
|
|
150
140
|
}
|
|
151
141
|
|
|
152
142
|
case 'splice': {
|
|
153
|
-
const [start, deleteCount, ...addItems] = args;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
143
|
+
const [start, deleteCount = 0, ...addItems] = args;
|
|
144
|
+
const actualStart = start < 0 ? Math.max(oldLength + start, 0) : Math.min(start, oldLength);
|
|
145
|
+
const actualDeleteCount = Math.min(deleteCount, oldLength - actualStart);
|
|
146
|
+
const minCount = Math.min(actualDeleteCount, addItems.length);
|
|
147
|
+
|
|
148
|
+
// For splice, we need the old values for delete operations
|
|
149
|
+
// Since we don't have oldValue, we need to track what was deleted
|
|
150
|
+
// The result of splice() is the array of deleted elements
|
|
151
|
+
const deletedElements = result as any[];
|
|
152
|
+
|
|
153
|
+
// First minCount elements: replace (overlap between add and delete)
|
|
154
|
+
for (let i = 0; i < minCount; i++) {
|
|
155
|
+
generateReplacePatch(state, [...path, actualStart + i], addItems[i], deletedElements[i]);
|
|
158
156
|
}
|
|
159
157
|
|
|
160
|
-
//
|
|
161
|
-
addItems.
|
|
162
|
-
generateAddPatch(state, [...path,
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// If there are both deletions and additions, update the shifted elements
|
|
166
|
-
|
|
167
|
-
const itemsToShift = oldValue.length - start - deleteCount;
|
|
168
|
-
for (let i = 0; i < itemsToShift; i++) {
|
|
169
|
-
generateReplacePatch(
|
|
170
|
-
state,
|
|
171
|
-
[...path, start + addItems.length + i],
|
|
172
|
-
oldValue[start + deleteCount + i],
|
|
173
|
-
);
|
|
158
|
+
// Remaining add items: add
|
|
159
|
+
for (let i = minCount; i < addItems.length; i++) {
|
|
160
|
+
generateAddPatch(state, [...path, actualStart + i], addItems[i]);
|
|
174
161
|
}
|
|
175
162
|
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
163
|
+
// Remaining delete items: remove (generate in reverse order)
|
|
164
|
+
for (let i = actualDeleteCount - 1; i >= minCount; i--) {
|
|
165
|
+
generateDeletePatch(state, [...path, actualStart + i], deletedElements[i]);
|
|
179
166
|
}
|
|
180
167
|
|
|
181
168
|
break;
|
|
@@ -184,7 +171,8 @@ function generateArrayPatches(
|
|
|
184
171
|
case 'sort':
|
|
185
172
|
case 'reverse': {
|
|
186
173
|
// These reorder the entire array - generate full replace
|
|
187
|
-
|
|
174
|
+
// oldValue contains the array before the mutation
|
|
175
|
+
generateReplacePatch(state, path, [...obj], oldValue);
|
|
188
176
|
break;
|
|
189
177
|
}
|
|
190
178
|
}
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,7 @@ export function recordPatches<T extends NonPrimitive>(
|
|
|
60
60
|
return recorderState.patches as Patches<true>;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
|
|
63
64
|
/**
|
|
64
65
|
* Mutative-compatible API for easy switching between mutative and patch-recorder.
|
|
65
66
|
* Returns [state, patches] tuple like mutative does.
|
package/src/maps.ts
CHANGED
|
@@ -31,8 +31,8 @@ export function handleMapGet<K = any, V = any>(
|
|
|
31
31
|
const itemPath = [...path, key as any];
|
|
32
32
|
|
|
33
33
|
if (existed) {
|
|
34
|
-
// Key exists - replace
|
|
35
|
-
generateReplacePatch(state, itemPath, cloneIfNeeded(value));
|
|
34
|
+
// Key exists - replace (pass oldValue for getItemId)
|
|
35
|
+
generateReplacePatch(state, itemPath, cloneIfNeeded(value), oldValue);
|
|
36
36
|
} else {
|
|
37
37
|
// Key doesn't exist - add
|
|
38
38
|
generateAddPatch(state, itemPath, cloneIfNeeded(value));
|
|
@@ -74,11 +74,9 @@ export function handleMapGet<K = any, V = any>(
|
|
|
74
74
|
return (key: K) => {
|
|
75
75
|
const value = obj.get(key);
|
|
76
76
|
|
|
77
|
-
// If the value is
|
|
77
|
+
// If the value is an object, return a proxy for nested mutation tracking
|
|
78
78
|
if (value != null && typeof value === 'object') {
|
|
79
|
-
|
|
80
|
-
return createProxy(value, [...path, key as any], state);
|
|
81
|
-
}
|
|
79
|
+
return createProxy(value, [...path, key as any], state);
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
return value;
|
package/src/optimizer.ts
CHANGED
|
@@ -20,27 +20,139 @@ export function compressPatches(patches: Patches<true>): Patches<true> {
|
|
|
20
20
|
if (!existing) {
|
|
21
21
|
// First operation on this path
|
|
22
22
|
pathMap.set(pathKey, patch);
|
|
23
|
+
} else {
|
|
24
|
+
// Merge with existing operation based on operation types
|
|
25
|
+
const merged = mergePatches(existing, patch);
|
|
26
|
+
// Check for undefined specifically (null means canceled, which is a valid result)
|
|
27
|
+
if (merged !== undefined) {
|
|
28
|
+
// Update with merged result (or null if they cancel out)
|
|
29
|
+
if (merged !== null) {
|
|
30
|
+
pathMap.set(pathKey, merged);
|
|
31
|
+
} else {
|
|
32
|
+
// Operations canceled each other out
|
|
33
|
+
pathMap.delete(pathKey);
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
// Can't merge, keep the new operation
|
|
37
|
+
pathMap.set(pathKey, patch);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Convert Map to array for final processing
|
|
43
|
+
let finalPatches = Array.from(pathMap.values());
|
|
44
|
+
|
|
45
|
+
// Handle array push + pop cancellation
|
|
46
|
+
// Only cancel when push is at the last index and pop reduces length
|
|
47
|
+
finalPatches = cancelArrayPushPop(finalPatches);
|
|
48
|
+
|
|
49
|
+
// Cancel patches that are beyond array bounds after final length update
|
|
50
|
+
finalPatches = cancelOutOfBoundsPatches(finalPatches);
|
|
51
|
+
|
|
52
|
+
return finalPatches as Patches<true>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Cancel array push + pop operations
|
|
57
|
+
* Only cancels when push is at the last index and pop reduces length
|
|
58
|
+
*/
|
|
59
|
+
function cancelArrayPushPop(patches: Patches<true>): Patches<true> {
|
|
60
|
+
// Group patches by parent array path
|
|
61
|
+
const arrayGroups = new Map<string, Patches<true>>();
|
|
62
|
+
|
|
63
|
+
for (const patch of patches) {
|
|
64
|
+
if (!Array.isArray(patch.path) || patch.path.length < 2) {
|
|
23
65
|
continue;
|
|
24
66
|
}
|
|
25
67
|
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
68
|
+
const parentPath = patch.path.slice(0, -1);
|
|
69
|
+
const parentKey = JSON.stringify(parentPath);
|
|
70
|
+
|
|
71
|
+
if (!arrayGroups.has(parentKey)) {
|
|
72
|
+
arrayGroups.set(parentKey, []);
|
|
73
|
+
}
|
|
74
|
+
arrayGroups.get(parentKey)!.push(patch);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cancelablePatches = new Set<string>();
|
|
78
|
+
|
|
79
|
+
for (const [, groupPatches] of arrayGroups.entries()) {
|
|
80
|
+
// Find push patches (add at highest indices)
|
|
81
|
+
const pushPatches = groupPatches
|
|
82
|
+
.filter((p) => p.op === 'add' && typeof p.path[p.path.length - 1] === 'number')
|
|
83
|
+
.sort(
|
|
84
|
+
(a, b) =>
|
|
85
|
+
(b.path[b.path.length - 1] as number) - (a.path[a.path.length - 1] as number),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Find pop patches (length reduction)
|
|
89
|
+
const popPatches = groupPatches.filter(
|
|
90
|
+
(p) => p.op === 'replace' && p.path[p.path.length - 1] === 'length',
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Cancel pushes and pops that match (push at highest index, pop reduces length)
|
|
94
|
+
const cancelCount = Math.min(pushPatches.length, popPatches.length);
|
|
95
|
+
for (let i = 0; i < cancelCount; i++) {
|
|
96
|
+
const pushPatch = pushPatches[i];
|
|
97
|
+
const popPatch = popPatches[i];
|
|
98
|
+
|
|
99
|
+
// Check if the push index matches the pop target
|
|
100
|
+
const pushIndex = pushPatch.path[pushPatch.path.length - 1] as number;
|
|
101
|
+
const popLength = popPatch.value as number;
|
|
102
|
+
|
|
103
|
+
// If push added at index pushIndex and pop reduced to popLength, they cancel
|
|
104
|
+
// This is a heuristic: push adds at end, pop removes from end
|
|
105
|
+
if (pushIndex >= popLength) {
|
|
106
|
+
cancelablePatches.add(JSON.stringify(pushPatch.path));
|
|
107
|
+
cancelablePatches.add(JSON.stringify(popPatch.path));
|
|
35
108
|
}
|
|
36
|
-
} else {
|
|
37
|
-
// Can't merge, keep the new operation
|
|
38
|
-
pathMap.set(pathKey, patch);
|
|
39
109
|
}
|
|
40
110
|
}
|
|
41
111
|
|
|
42
|
-
|
|
43
|
-
|
|
112
|
+
return patches.filter(
|
|
113
|
+
(patch) => !cancelablePatches.has(JSON.stringify(patch.path)),
|
|
114
|
+
) as Patches<true>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Cancel patches that are beyond array bounds after final length update
|
|
119
|
+
*/
|
|
120
|
+
function cancelOutOfBoundsPatches(patches: Patches<true>): Patches<true> {
|
|
121
|
+
// Find the final length for each array
|
|
122
|
+
const arrayLengths = new Map<string, number>();
|
|
123
|
+
const canceledPatches = new Set<string>();
|
|
124
|
+
|
|
125
|
+
for (const patch of patches) {
|
|
126
|
+
if (
|
|
127
|
+
Array.isArray(patch.path) &&
|
|
128
|
+
patch.path.length >= 2 &&
|
|
129
|
+
patch.path[patch.path.length - 1] === 'length'
|
|
130
|
+
) {
|
|
131
|
+
const parentPath = JSON.stringify(patch.path.slice(0, -1));
|
|
132
|
+
arrayLengths.set(parentPath, patch.value as number);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Cancel patches at indices >= final length
|
|
137
|
+
for (const patch of patches) {
|
|
138
|
+
if (!Array.isArray(patch.path) || patch.path.length < 2) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lastPath = patch.path[patch.path.length - 1];
|
|
143
|
+
const parentPath = JSON.stringify(patch.path.slice(0, -1));
|
|
144
|
+
|
|
145
|
+
if (typeof lastPath === 'number' && arrayLengths.has(parentPath)) {
|
|
146
|
+
const length = arrayLengths.get(parentPath)!;
|
|
147
|
+
if (lastPath >= length) {
|
|
148
|
+
canceledPatches.add(JSON.stringify(patch.path));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return patches.filter(
|
|
154
|
+
(patch) => !canceledPatches.has(JSON.stringify(patch.path)),
|
|
155
|
+
) as Patches<true>;
|
|
44
156
|
}
|
|
45
157
|
|
|
46
158
|
/**
|
|
@@ -55,19 +167,19 @@ function mergePatches(patch1: any, patch2: any): any | null | undefined {
|
|
|
55
167
|
if (op1 === op2) {
|
|
56
168
|
// For replace operations, keep the latest value
|
|
57
169
|
if (op1 === 'replace') {
|
|
58
|
-
// Skip if same
|
|
59
|
-
if (
|
|
170
|
+
// Skip if same reference (no-op)
|
|
171
|
+
if (patch1.value === patch2.value) {
|
|
60
172
|
return patch1;
|
|
61
173
|
}
|
|
62
174
|
return patch2;
|
|
63
175
|
}
|
|
64
|
-
// For add operations, if adding
|
|
65
|
-
if (op1 === 'add' &&
|
|
176
|
+
// For add operations, if adding same reference, it's a no-op
|
|
177
|
+
if (op1 === 'add' && patch1.value === patch2.value) {
|
|
66
178
|
return patch1;
|
|
67
179
|
}
|
|
68
|
-
// For remove operations,
|
|
180
|
+
// For remove operations, don't merge (sequential removes should never be merged)
|
|
69
181
|
if (op1 === 'remove') {
|
|
70
|
-
return
|
|
182
|
+
return undefined;
|
|
71
183
|
}
|
|
72
184
|
}
|
|
73
185
|
|
|
@@ -93,44 +205,14 @@ function mergePatches(patch1: any, patch2: any): any | null | undefined {
|
|
|
93
205
|
}
|
|
94
206
|
|
|
95
207
|
if (op1 === 'remove' && op2 === 'add') {
|
|
96
|
-
// Remove then add -
|
|
97
|
-
return
|
|
208
|
+
// Remove then add - this is a replace operation
|
|
209
|
+
return {
|
|
210
|
+
op: 'replace',
|
|
211
|
+
path: patch1.path,
|
|
212
|
+
value: patch2.value,
|
|
213
|
+
};
|
|
98
214
|
}
|
|
99
215
|
|
|
100
216
|
// Can't merge these operations
|
|
101
217
|
return undefined;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Check if two values are equal (deep comparison)
|
|
106
|
-
*/
|
|
107
|
-
function valuesEqual(val1: any, val2: any): boolean {
|
|
108
|
-
if (val1 === val2) return true;
|
|
109
|
-
|
|
110
|
-
// Handle null/undefined
|
|
111
|
-
if (val1 == null || val2 == null) return val1 === val2;
|
|
112
|
-
|
|
113
|
-
// Handle arrays
|
|
114
|
-
if (Array.isArray(val1) && Array.isArray(val2)) {
|
|
115
|
-
if (val1.length !== val2.length) return false;
|
|
116
|
-
for (let i = 0; i < val1.length; i++) {
|
|
117
|
-
if (!valuesEqual(val1[i], val2[i])) return false;
|
|
118
|
-
}
|
|
119
|
-
return true;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Handle objects
|
|
123
|
-
if (typeof val1 === 'object' && typeof val2 === 'object') {
|
|
124
|
-
const keys1 = Object.keys(val1);
|
|
125
|
-
const keys2 = Object.keys(val2);
|
|
126
|
-
|
|
127
|
-
if (keys1.length !== keys2.length) return false;
|
|
128
|
-
|
|
129
|
-
for (const key of keys1) {
|
|
130
|
-
if (!valuesEqual(val1[key], val2[key])) return false;
|
|
131
|
-
}
|
|
132
|
-
return true;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
218
|
+
}
|
package/src/patches.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {RecorderState} from './types.js';
|
|
2
2
|
import {Operation} from './types.js';
|
|
3
|
-
import {formatPath, cloneIfNeeded} from './utils.js';
|
|
3
|
+
import {formatPath, cloneIfNeeded, findGetItemIdFn} from './utils.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Generate a replace patch for property changes
|
|
@@ -11,12 +11,21 @@ export function generateSetPatch(
|
|
|
11
11
|
oldValue: any,
|
|
12
12
|
newValue: any,
|
|
13
13
|
) {
|
|
14
|
-
const patch = {
|
|
14
|
+
const patch: any = {
|
|
15
15
|
op: Operation.Replace,
|
|
16
16
|
path: formatPath(path, state.options),
|
|
17
17
|
value: cloneIfNeeded(newValue),
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
// Add id if getItemId is configured for this path
|
|
21
|
+
const getItemIdFn = findGetItemIdFn(path, state.options.getItemId);
|
|
22
|
+
if (getItemIdFn && oldValue !== undefined) {
|
|
23
|
+
const id = getItemIdFn(oldValue);
|
|
24
|
+
if (id !== undefined && id !== null) {
|
|
25
|
+
patch.id = id;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
state.patches.push(patch);
|
|
21
30
|
}
|
|
22
31
|
|
|
@@ -28,11 +37,20 @@ export function generateDeletePatch(
|
|
|
28
37
|
path: (string | number)[],
|
|
29
38
|
oldValue: any,
|
|
30
39
|
) {
|
|
31
|
-
const patch = {
|
|
40
|
+
const patch: any = {
|
|
32
41
|
op: Operation.Remove,
|
|
33
42
|
path: formatPath(path, state.options),
|
|
34
43
|
};
|
|
35
44
|
|
|
45
|
+
// Add id if getItemId is configured for this path
|
|
46
|
+
const getItemIdFn = findGetItemIdFn(path, state.options.getItemId);
|
|
47
|
+
if (getItemIdFn && oldValue !== undefined) {
|
|
48
|
+
const id = getItemIdFn(oldValue);
|
|
49
|
+
if (id !== undefined && id !== null) {
|
|
50
|
+
patch.id = id;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
state.patches.push(patch);
|
|
37
55
|
}
|
|
38
56
|
|
|
@@ -56,12 +74,22 @@ export function generateReplacePatch(
|
|
|
56
74
|
state: RecorderState<any>,
|
|
57
75
|
path: (string | number)[],
|
|
58
76
|
value: any,
|
|
77
|
+
oldValue?: any,
|
|
59
78
|
) {
|
|
60
|
-
const patch = {
|
|
79
|
+
const patch: any = {
|
|
61
80
|
op: Operation.Replace,
|
|
62
81
|
path: formatPath(path, state.options),
|
|
63
82
|
value: cloneIfNeeded(value),
|
|
64
83
|
};
|
|
65
84
|
|
|
85
|
+
// Add id if getItemId is configured for this path
|
|
86
|
+
const getItemIdFn = findGetItemIdFn(path, state.options.getItemId);
|
|
87
|
+
if (getItemIdFn && oldValue !== undefined) {
|
|
88
|
+
const id = getItemIdFn(oldValue);
|
|
89
|
+
if (id !== undefined && id !== null) {
|
|
90
|
+
patch.id = id;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
66
94
|
state.patches.push(patch);
|
|
67
95
|
}
|
package/src/proxy.ts
CHANGED
|
@@ -66,8 +66,9 @@ export function createProxy<T extends object>(
|
|
|
66
66
|
const propPath = [...path, propForPath];
|
|
67
67
|
|
|
68
68
|
// Skip if no actual change (handle undefined as a valid value)
|
|
69
|
+
// Use Object.is to correctly handle NaN (NaN !== NaN, but Object.is(NaN, NaN) === true)
|
|
69
70
|
if (
|
|
70
|
-
oldValue
|
|
71
|
+
Object.is(oldValue, value) &&
|
|
71
72
|
(value !== undefined || Object.prototype.hasOwnProperty.call(obj, prop))
|
|
72
73
|
) {
|
|
73
74
|
return true;
|
|
@@ -87,8 +88,21 @@ export function createProxy<T extends object>(
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
if (currentOriginal && currentOriginal !== undefined && currentOriginal !== null) {
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
// For arrays, check if the index is within the array length (handles sparse arrays correctly)
|
|
92
|
+
if (Array.isArray(currentOriginal)) {
|
|
93
|
+
// Convert prop to number if it's a numeric string
|
|
94
|
+
const index = typeof prop === 'string' && !isNaN(Number(prop)) ? Number(prop) : prop;
|
|
95
|
+
if (typeof index === 'number') {
|
|
96
|
+
originalHasProperty = index >= 0 && index < currentOriginal.length;
|
|
97
|
+
originalValue = (currentOriginal as any)[index];
|
|
98
|
+
} else {
|
|
99
|
+
originalHasProperty = Object.prototype.hasOwnProperty.call(currentOriginal, prop);
|
|
100
|
+
originalValue = (currentOriginal as any)[prop];
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
originalHasProperty = Object.prototype.hasOwnProperty.call(currentOriginal, prop);
|
|
104
|
+
originalValue = (currentOriginal as any)[prop];
|
|
105
|
+
}
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
// Mutate original immediately
|
package/src/types.ts
CHANGED
|
@@ -19,12 +19,29 @@ export type PatchesOptions =
|
|
|
19
19
|
arrayLengthAssignment?: boolean;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Function that extracts an ID from an item value
|
|
24
|
+
*/
|
|
25
|
+
export type GetItemIdFunction = (value: any) => string | number | undefined | null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Recursive configuration for getItemId - can be a function or nested object
|
|
29
|
+
*/
|
|
30
|
+
export type GetItemIdConfig = {
|
|
31
|
+
[key: string]: GetItemIdFunction | GetItemIdConfig;
|
|
32
|
+
};
|
|
33
|
+
|
|
22
34
|
export interface IPatch {
|
|
23
35
|
op: PatchOp;
|
|
24
36
|
value?: any;
|
|
37
|
+
/**
|
|
38
|
+
* Optional ID of the item being removed or replaced.
|
|
39
|
+
* Populated when getItemId option is configured for the item's parent path.
|
|
40
|
+
*/
|
|
41
|
+
id?: string | number;
|
|
25
42
|
}
|
|
26
43
|
|
|
27
|
-
export type Patch<P extends PatchesOptions =
|
|
44
|
+
export type Patch<P extends PatchesOptions = true> = P extends {
|
|
28
45
|
pathAsArray: false;
|
|
29
46
|
}
|
|
30
47
|
? IPatch & {
|
|
@@ -38,7 +55,7 @@ export type Patch<P extends PatchesOptions = any> = P extends {
|
|
|
38
55
|
path: string | (string | number)[];
|
|
39
56
|
};
|
|
40
57
|
|
|
41
|
-
export type Patches<P extends PatchesOptions =
|
|
58
|
+
export type Patches<P extends PatchesOptions = true> = Patch<P>[];
|
|
42
59
|
|
|
43
60
|
export type NonPrimitive = object | Array<unknown>;
|
|
44
61
|
|
|
@@ -55,6 +72,24 @@ export interface RecordPatchesOptions {
|
|
|
55
72
|
* Compress patches by merging redundant operations (default: true)
|
|
56
73
|
*/
|
|
57
74
|
compressPatches?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Configuration for extracting item IDs for remove/replace patches.
|
|
77
|
+
* Maps paths to functions that extract IDs from item values.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* recordPatches(state, mutate, {
|
|
82
|
+
* getItemId: {
|
|
83
|
+
* items: (item) => item.id,
|
|
84
|
+
* users: (user) => user.userId,
|
|
85
|
+
* nested: {
|
|
86
|
+
* array: (item) => item._id
|
|
87
|
+
* }
|
|
88
|
+
* }
|
|
89
|
+
* });
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
getItemId?: GetItemIdConfig;
|
|
58
93
|
}
|
|
59
94
|
|
|
60
95
|
export type Draft<T> = T;
|