patch-recorder 0.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/LICENSE +21 -0
- package/README.md +337 -0
- package/dist/arrays.d.ts +6 -0
- package/dist/arrays.d.ts.map +1 -0
- package/dist/arrays.js +143 -0
- package/dist/arrays.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/maps.d.ts +7 -0
- package/dist/maps.d.ts.map +1 -0
- package/dist/maps.js +96 -0
- package/dist/maps.js.map +1 -0
- package/dist/optimizer.d.ts +7 -0
- package/dist/optimizer.d.ts.map +1 -0
- package/dist/optimizer.js +123 -0
- package/dist/optimizer.js.map +1 -0
- package/dist/patches.d.ts +18 -0
- package/dist/patches.d.ts.map +1 -0
- package/dist/patches.js +46 -0
- package/dist/patches.js.map +1 -0
- package/dist/proxy.d.ts +3 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +127 -0
- package/dist/proxy.js.map +1 -0
- package/dist/sets.d.ts +7 -0
- package/dist/sets.d.ts.map +1 -0
- package/dist/sets.js +78 -0
- package/dist/sets.js.map +1 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +33 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +132 -0
- package/dist/utils.js.map +1 -0
- package/package.json +60 -0
- package/src/arrays.ts +191 -0
- package/src/index.ts +71 -0
- package/src/maps.ts +120 -0
- package/src/optimizer.ts +136 -0
- package/src/patches.ts +67 -0
- package/src/proxy.ts +163 -0
- package/src/sets.ts +100 -0
- package/src/types.ts +67 -0
- package/src/utils.ts +150 -0
package/src/optimizer.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type {Patches} from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compress patches by merging redundant operations
|
|
5
|
+
* This handles both consecutive and interleaved operations on the same path
|
|
6
|
+
*/
|
|
7
|
+
export function compressPatches(patches: Patches<true>): Patches<true> {
|
|
8
|
+
if (patches.length === 0) {
|
|
9
|
+
return patches;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Use a Map to track the latest operation for each path
|
|
13
|
+
// Key: JSON stringified path, Value: the latest patch for that path
|
|
14
|
+
const pathMap = new Map<string, any>();
|
|
15
|
+
|
|
16
|
+
for (const patch of patches) {
|
|
17
|
+
const pathKey = JSON.stringify(patch.path);
|
|
18
|
+
const existing = pathMap.get(pathKey);
|
|
19
|
+
|
|
20
|
+
if (!existing) {
|
|
21
|
+
// First operation on this path
|
|
22
|
+
pathMap.set(pathKey, patch);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Merge with existing operation based on operation types
|
|
27
|
+
const merged = mergePatches(existing, patch);
|
|
28
|
+
if (merged) {
|
|
29
|
+
// Update with merged result (or null if they cancel out)
|
|
30
|
+
if (merged !== null) {
|
|
31
|
+
pathMap.set(pathKey, merged);
|
|
32
|
+
} else {
|
|
33
|
+
// Operations canceled each other out
|
|
34
|
+
pathMap.delete(pathKey);
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
// Can't merge, keep the new operation
|
|
38
|
+
pathMap.set(pathKey, patch);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Convert Map back to array
|
|
43
|
+
return Array.from(pathMap.values()) as Patches<true>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Merge two patches on the same path
|
|
48
|
+
* Returns the merged patch, or null if they cancel out, or undefined if they can't be merged
|
|
49
|
+
*/
|
|
50
|
+
function mergePatches(patch1: any, patch2: any): any | null | undefined {
|
|
51
|
+
const op1 = patch1.op;
|
|
52
|
+
const op2 = patch2.op;
|
|
53
|
+
|
|
54
|
+
// Same operations - keep the latest one
|
|
55
|
+
if (op1 === op2) {
|
|
56
|
+
// For replace operations, keep the latest value
|
|
57
|
+
if (op1 === 'replace') {
|
|
58
|
+
// Skip if same value (no-op)
|
|
59
|
+
if (valuesEqual(patch1.value, patch2.value)) {
|
|
60
|
+
return patch1;
|
|
61
|
+
}
|
|
62
|
+
return patch2;
|
|
63
|
+
}
|
|
64
|
+
// For add operations, if adding the same value, it's a no-op
|
|
65
|
+
if (op1 === 'add' && valuesEqual(patch1.value, patch2.value)) {
|
|
66
|
+
return patch1;
|
|
67
|
+
}
|
|
68
|
+
// For remove operations, keep the latest
|
|
69
|
+
if (op1 === 'remove') {
|
|
70
|
+
return patch2;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Different operations
|
|
75
|
+
if (op1 === 'add' && op2 === 'replace') {
|
|
76
|
+
// Add then replace - just keep the replace
|
|
77
|
+
return patch2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (op1 === 'replace' && op2 === 'replace') {
|
|
81
|
+
// Replace then replace - keep the latest
|
|
82
|
+
return patch2;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (op1 === 'replace' && op2 === 'remove') {
|
|
86
|
+
// Replace then delete - just keep the delete
|
|
87
|
+
return patch2;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (op1 === 'add' && op2 === 'remove') {
|
|
91
|
+
// Add then remove - they cancel out
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (op1 === 'remove' && op2 === 'add') {
|
|
96
|
+
// Remove then add - keep the add
|
|
97
|
+
return patch2;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Can't merge these operations
|
|
101
|
+
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
|
+
}
|
package/src/patches.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type {RecorderState} from './types.js';
|
|
2
|
+
import {Operation} from './types.js';
|
|
3
|
+
import {formatPath, cloneIfNeeded} from './utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate a replace patch for property changes
|
|
7
|
+
*/
|
|
8
|
+
export function generateSetPatch(
|
|
9
|
+
state: RecorderState<any>,
|
|
10
|
+
path: (string | number)[],
|
|
11
|
+
oldValue: any,
|
|
12
|
+
newValue: any,
|
|
13
|
+
) {
|
|
14
|
+
const patch = {
|
|
15
|
+
op: Operation.Replace,
|
|
16
|
+
path: formatPath(path, state.options),
|
|
17
|
+
value: cloneIfNeeded(newValue),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
state.patches.push(patch);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate a remove patch for property deletions
|
|
25
|
+
*/
|
|
26
|
+
export function generateDeletePatch(
|
|
27
|
+
state: RecorderState<any>,
|
|
28
|
+
path: (string | number)[],
|
|
29
|
+
oldValue: any,
|
|
30
|
+
) {
|
|
31
|
+
const patch = {
|
|
32
|
+
op: Operation.Remove,
|
|
33
|
+
path: formatPath(path, state.options),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
state.patches.push(patch);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate an add patch for new properties
|
|
41
|
+
*/
|
|
42
|
+
export function generateAddPatch(state: RecorderState<any>, path: (string | number)[], value: any) {
|
|
43
|
+
const patch = {
|
|
44
|
+
op: Operation.Add,
|
|
45
|
+
path: formatPath(path, state.options),
|
|
46
|
+
value: cloneIfNeeded(value),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
state.patches.push(patch);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate a replace patch for full object/array replacement
|
|
54
|
+
*/
|
|
55
|
+
export function generateReplacePatch(
|
|
56
|
+
state: RecorderState<any>,
|
|
57
|
+
path: (string | number)[],
|
|
58
|
+
value: any,
|
|
59
|
+
) {
|
|
60
|
+
const patch = {
|
|
61
|
+
op: Operation.Replace,
|
|
62
|
+
path: formatPath(path, state.options),
|
|
63
|
+
value: cloneIfNeeded(value),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
state.patches.push(patch);
|
|
67
|
+
}
|
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type {RecorderState} from './types.js';
|
|
2
|
+
import {generateSetPatch, generateDeletePatch, generateAddPatch} from './patches.js';
|
|
3
|
+
import {isArray, isMap, isSet} from './utils.js';
|
|
4
|
+
import {handleArrayGet} from './arrays.js';
|
|
5
|
+
import {handleMapGet} from './maps.js';
|
|
6
|
+
import {handleSetGet} from './sets.js';
|
|
7
|
+
|
|
8
|
+
export function createProxy<T extends object>(
|
|
9
|
+
target: T,
|
|
10
|
+
path: (string | number)[],
|
|
11
|
+
state: RecorderState<any>,
|
|
12
|
+
): T {
|
|
13
|
+
const isArrayType = isArray(target);
|
|
14
|
+
const isMapType = isMap(target);
|
|
15
|
+
const isSetType = isSet(target);
|
|
16
|
+
|
|
17
|
+
const handler: ProxyHandler<T> = {
|
|
18
|
+
get(obj, prop) {
|
|
19
|
+
// Handle array methods
|
|
20
|
+
if (isArrayType && typeof prop === 'string') {
|
|
21
|
+
return handleArrayGet(obj as any[], prop, path, state);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Handle Map methods
|
|
25
|
+
if (isMapType) {
|
|
26
|
+
return handleMapGet(obj as Map<any, any>, prop, path, state);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Handle Set methods
|
|
30
|
+
if (isSetType) {
|
|
31
|
+
return handleSetGet(obj as Set<any>, prop, path, state);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Handle property access
|
|
35
|
+
const value = (obj as any)[prop];
|
|
36
|
+
|
|
37
|
+
// Skip creating proxies for primitive values and special cases
|
|
38
|
+
if (typeof value !== 'object' || value === null) {
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Create nested proxy for draftable values
|
|
43
|
+
// Only include string | number in path, skip symbols
|
|
44
|
+
if (typeof prop === 'string' || typeof prop === 'number') {
|
|
45
|
+
return createProxy(value, [...path, prop], state);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
set(obj, prop, value) {
|
|
51
|
+
// Map and Set don't support direct property assignment
|
|
52
|
+
if (isMapType || isSetType) {
|
|
53
|
+
throw new Error('Map/Set draft does not support any property assignment.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const oldValue = (obj as any)[prop];
|
|
57
|
+
|
|
58
|
+
// Only create path for string | number props, skip symbols
|
|
59
|
+
if (typeof prop !== 'string' && typeof prop !== 'number') {
|
|
60
|
+
(obj as any)[prop] = value;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Convert numeric string props to numbers for array indices
|
|
65
|
+
const propForPath = typeof prop === 'string' && !isNaN(Number(prop)) ? Number(prop) : prop;
|
|
66
|
+
const propPath = [...path, propForPath];
|
|
67
|
+
|
|
68
|
+
// Skip if no actual change (handle undefined as a valid value)
|
|
69
|
+
if (
|
|
70
|
+
oldValue === value &&
|
|
71
|
+
(value !== undefined || Object.prototype.hasOwnProperty.call(obj, prop))
|
|
72
|
+
) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Determine if this is an add or replace operation by checking the original state
|
|
77
|
+
let originalHasProperty = false;
|
|
78
|
+
let originalValue = undefined;
|
|
79
|
+
|
|
80
|
+
// Navigate to the original object at this path
|
|
81
|
+
let currentOriginal = state.original as any;
|
|
82
|
+
for (let i = 0; i < path.length; i++) {
|
|
83
|
+
currentOriginal = currentOriginal[path[i]];
|
|
84
|
+
if (currentOriginal === undefined || currentOriginal === null) {
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (currentOriginal && currentOriginal !== undefined && currentOriginal !== null) {
|
|
90
|
+
originalHasProperty = Object.prototype.hasOwnProperty.call(currentOriginal, prop);
|
|
91
|
+
originalValue = currentOriginal[prop];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Mutate original immediately
|
|
95
|
+
(obj as any)[prop] = value;
|
|
96
|
+
|
|
97
|
+
// Generate patch
|
|
98
|
+
if (!originalHasProperty) {
|
|
99
|
+
generateAddPatch(state, propPath, value);
|
|
100
|
+
} else {
|
|
101
|
+
generateSetPatch(state, propPath, originalValue, value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
deleteProperty(obj, prop) {
|
|
108
|
+
if (isArrayType) {
|
|
109
|
+
// For arrays, delete is equivalent to setting to undefined
|
|
110
|
+
return handler.set!(obj, prop, undefined, obj);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Map and Set don't support deleteProperty
|
|
114
|
+
if (isMapType || isSetType) {
|
|
115
|
+
throw new Error('Map/Set draft does not support deleteProperty.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const oldValue = (obj as any)[prop];
|
|
119
|
+
|
|
120
|
+
// Only create path for string | number props, skip symbols
|
|
121
|
+
if (typeof prop !== 'string' && typeof prop !== 'number') {
|
|
122
|
+
delete (obj as any)[prop];
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const propPath = [...path, prop];
|
|
127
|
+
|
|
128
|
+
if (oldValue !== undefined || Object.prototype.hasOwnProperty.call(obj, prop)) {
|
|
129
|
+
delete (obj as any)[prop];
|
|
130
|
+
|
|
131
|
+
// Generate patch
|
|
132
|
+
generateDeletePatch(state, propPath, oldValue);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return true;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
has(obj, prop) {
|
|
139
|
+
return prop in obj;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
ownKeys(obj) {
|
|
143
|
+
return Reflect.ownKeys(obj);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
getOwnPropertyDescriptor(obj, prop) {
|
|
147
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(obj, prop);
|
|
148
|
+
if (!descriptor) return descriptor;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
...descriptor,
|
|
152
|
+
writable: true,
|
|
153
|
+
configurable: true,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
getPrototypeOf(obj) {
|
|
158
|
+
return Reflect.getPrototypeOf(obj);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return new Proxy(target, handler);
|
|
163
|
+
}
|
package/src/sets.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type {RecorderState} from './types.js';
|
|
2
|
+
import {createProxy} from './proxy.js';
|
|
3
|
+
import {Operation} from './types.js';
|
|
4
|
+
import {generateAddPatch, generateDeletePatch} from './patches.js';
|
|
5
|
+
import {cloneIfNeeded, isSet, isArray} from './utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handle property access on Set objects
|
|
9
|
+
* Wraps mutating methods (add, delete, clear) to generate patches
|
|
10
|
+
*/
|
|
11
|
+
export function handleSetGet<T = any>(
|
|
12
|
+
obj: Set<T>,
|
|
13
|
+
prop: string | symbol,
|
|
14
|
+
path: (string | number)[],
|
|
15
|
+
state: RecorderState<any>,
|
|
16
|
+
): any {
|
|
17
|
+
// Skip symbol properties
|
|
18
|
+
if (typeof prop === 'symbol') {
|
|
19
|
+
return (obj as any)[prop];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Mutating methods
|
|
23
|
+
if (prop === 'add') {
|
|
24
|
+
return (value: T) => {
|
|
25
|
+
// Check if value existed BEFORE mutation
|
|
26
|
+
const existed = valueExistsInOriginal(state.original, path, value);
|
|
27
|
+
const result = obj.add(value);
|
|
28
|
+
|
|
29
|
+
// Generate patch only if value didn't exist
|
|
30
|
+
if (!existed) {
|
|
31
|
+
const itemPath = [...path, value as any];
|
|
32
|
+
generateAddPatch(state, itemPath, cloneIfNeeded(value));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (prop === 'delete') {
|
|
40
|
+
return (value: T) => {
|
|
41
|
+
const existed = obj.has(value);
|
|
42
|
+
const result = obj.delete(value);
|
|
43
|
+
|
|
44
|
+
// Generate patch only if value existed
|
|
45
|
+
if (existed) {
|
|
46
|
+
const itemPath = [...path, value as any];
|
|
47
|
+
generateDeletePatch(state, itemPath, cloneIfNeeded(value));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (prop === 'clear') {
|
|
55
|
+
return () => {
|
|
56
|
+
const values = Array.from(obj.values());
|
|
57
|
+
obj.clear();
|
|
58
|
+
|
|
59
|
+
// Generate remove patches for all items
|
|
60
|
+
values.forEach((value) => {
|
|
61
|
+
const itemPath = [...path, value as any];
|
|
62
|
+
generateDeletePatch(state, itemPath, cloneIfNeeded(value));
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Non-mutating methods
|
|
68
|
+
const nonMutatingMethods = ['has', 'keys', 'values', 'entries', 'forEach'];
|
|
69
|
+
|
|
70
|
+
if (nonMutatingMethods.includes(prop)) {
|
|
71
|
+
return (obj as any)[prop].bind(obj);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Size property
|
|
75
|
+
if (prop === 'size') {
|
|
76
|
+
return obj.size;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Return any other property
|
|
80
|
+
return (obj as any)[prop];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Navigate to the original Set at the given path and check if a value exists
|
|
85
|
+
* This is needed to check if a value existed before mutations
|
|
86
|
+
*/
|
|
87
|
+
function valueExistsInOriginal(original: any, path: (string | number)[], value: any): boolean {
|
|
88
|
+
let current = original;
|
|
89
|
+
for (const part of path) {
|
|
90
|
+
if (current == null) return false;
|
|
91
|
+
current = current[part];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// If we reached a Set, check if the value exists
|
|
95
|
+
if (current instanceof Set) {
|
|
96
|
+
return current.has(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return false;
|
|
100
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const Operation = {
|
|
2
|
+
Remove: 'remove',
|
|
3
|
+
Replace: 'replace',
|
|
4
|
+
Add: 'add',
|
|
5
|
+
} as const;
|
|
6
|
+
|
|
7
|
+
export type PatchOp = (typeof Operation)[keyof typeof Operation];
|
|
8
|
+
|
|
9
|
+
export type PatchesOptions =
|
|
10
|
+
| boolean
|
|
11
|
+
| {
|
|
12
|
+
/**
|
|
13
|
+
* The default value is `true`. If it's `true`, the path will be an array, otherwise it is a string.
|
|
14
|
+
*/
|
|
15
|
+
pathAsArray?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The default value is `true`. If it's `true`, the array length will be included in the patches, otherwise no include array length.
|
|
18
|
+
*/
|
|
19
|
+
arrayLengthAssignment?: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface IPatch {
|
|
23
|
+
op: PatchOp;
|
|
24
|
+
value?: any;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Patch<P extends PatchesOptions = any> = P extends {
|
|
28
|
+
pathAsArray: false;
|
|
29
|
+
}
|
|
30
|
+
? IPatch & {
|
|
31
|
+
path: string;
|
|
32
|
+
}
|
|
33
|
+
: P extends true | object
|
|
34
|
+
? IPatch & {
|
|
35
|
+
path: (string | number)[];
|
|
36
|
+
}
|
|
37
|
+
: IPatch & {
|
|
38
|
+
path: string | (string | number)[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type Patches<P extends PatchesOptions = any> = Patch<P>[];
|
|
42
|
+
|
|
43
|
+
export type NonPrimitive = object | Array<unknown>;
|
|
44
|
+
|
|
45
|
+
export interface RecordPatchesOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Return paths as arrays (default: true) or strings
|
|
48
|
+
*/
|
|
49
|
+
pathAsArray?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Include array length in patches (default: true)
|
|
52
|
+
*/
|
|
53
|
+
arrayLengthAssignment?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Optimize patches by merging redundant operations (default: false)
|
|
56
|
+
*/
|
|
57
|
+
optimize?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type Draft<T> = T;
|
|
61
|
+
|
|
62
|
+
export interface RecorderState<T> {
|
|
63
|
+
original: T;
|
|
64
|
+
patches: Patches<any>;
|
|
65
|
+
basePath: (string | number)[];
|
|
66
|
+
options: RecordPatchesOptions & {internalPatchesOptions: PatchesOptions};
|
|
67
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type {PatchesOptions} from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type guard to check if a value is a plain object (not null, not array, not Map/Set)
|
|
5
|
+
*/
|
|
6
|
+
export function isObject(value: unknown): value is Record<string, unknown> {
|
|
7
|
+
return (
|
|
8
|
+
typeof value === 'object' &&
|
|
9
|
+
value !== null &&
|
|
10
|
+
!Array.isArray(value) &&
|
|
11
|
+
!(value instanceof Map) &&
|
|
12
|
+
!(value instanceof Set)
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Type guard to check if a value is an array
|
|
18
|
+
*/
|
|
19
|
+
export function isArray(value: unknown): value is unknown[] {
|
|
20
|
+
return Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Type guard to check if a value is a Map
|
|
25
|
+
*/
|
|
26
|
+
export function isMap(value: unknown): value is Map<unknown, unknown> {
|
|
27
|
+
return value instanceof Map;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Type guard to check if a value is a Set
|
|
32
|
+
*/
|
|
33
|
+
export function isSet(value: unknown): value is Set<unknown> {
|
|
34
|
+
return value instanceof Set;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Format a path array to either array format or JSON Pointer string format
|
|
39
|
+
*/
|
|
40
|
+
export function formatPath(
|
|
41
|
+
path: (string | number)[],
|
|
42
|
+
options: {internalPatchesOptions: PatchesOptions},
|
|
43
|
+
): string | (string | number)[] {
|
|
44
|
+
if (
|
|
45
|
+
options.internalPatchesOptions &&
|
|
46
|
+
typeof options.internalPatchesOptions === 'object' &&
|
|
47
|
+
options.internalPatchesOptions.pathAsArray === false
|
|
48
|
+
) {
|
|
49
|
+
// Convert to JSON Pointer string format
|
|
50
|
+
return path
|
|
51
|
+
.map((part) => {
|
|
52
|
+
if (typeof part === 'number') {
|
|
53
|
+
return String(part);
|
|
54
|
+
}
|
|
55
|
+
return '/' + String(part).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
56
|
+
})
|
|
57
|
+
.join('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return path;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if two values are deeply equal
|
|
65
|
+
*/
|
|
66
|
+
export function isEqual(a: unknown, b: unknown): boolean {
|
|
67
|
+
if (a === b) return true;
|
|
68
|
+
if (a == null || b == null) return a === b;
|
|
69
|
+
if (typeof a !== typeof b) return false;
|
|
70
|
+
|
|
71
|
+
if (typeof a === 'object') {
|
|
72
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
73
|
+
if (a.length !== b.length) return false;
|
|
74
|
+
for (let i = 0; i < a.length; i++) {
|
|
75
|
+
if (!isEqual(a[i], b[i])) return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (a instanceof Map && b instanceof Map) {
|
|
81
|
+
if (a.size !== b.size) return false;
|
|
82
|
+
for (const [key, value] of a) {
|
|
83
|
+
if (!b.has(key) || !isEqual(value, b.get(key))) return false;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (a instanceof Set && b instanceof Set) {
|
|
89
|
+
if (a.size !== b.size) return false;
|
|
90
|
+
for (const value of a) {
|
|
91
|
+
if (!b.has(value)) return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (Object.keys(a).length !== Object.keys(b).length) return false;
|
|
97
|
+
for (const key in a) {
|
|
98
|
+
if (
|
|
99
|
+
!Object.prototype.hasOwnProperty.call(b, key) ||
|
|
100
|
+
!isEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])
|
|
101
|
+
) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Deep clone a value if it's an object/array, otherwise return as-is
|
|
113
|
+
* This is needed for patch values to avoid reference issues
|
|
114
|
+
*/
|
|
115
|
+
export function cloneIfNeeded<T>(value: T): T {
|
|
116
|
+
if (value === null || typeof value !== 'object') {
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
return value.map((item) => cloneIfNeeded(item)) as T;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (value instanceof Map) {
|
|
125
|
+
const clonedMap = new Map();
|
|
126
|
+
for (const [key, val] of value) {
|
|
127
|
+
clonedMap.set(cloneIfNeeded(key), cloneIfNeeded(val));
|
|
128
|
+
}
|
|
129
|
+
return clonedMap as T;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (value instanceof Set) {
|
|
133
|
+
const clonedSet = new Set();
|
|
134
|
+
for (const item of value) {
|
|
135
|
+
clonedSet.add(cloneIfNeeded(item));
|
|
136
|
+
}
|
|
137
|
+
return clonedSet as T;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Plain object
|
|
141
|
+
const cloned = {} as T;
|
|
142
|
+
for (const key in value) {
|
|
143
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
144
|
+
(cloned as Record<string, unknown>)[key] = cloneIfNeeded(
|
|
145
|
+
(value as Record<string, unknown>)[key],
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return cloned;
|
|
150
|
+
}
|