patch-recorder 0.2.2 → 0.4.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 +94 -27
- package/dist/arrays.js +22 -10
- package/dist/arrays.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -3
- package/dist/index.js.map +1 -1
- package/dist/maps.d.ts.map +1 -1
- package/dist/maps.js +15 -7
- package/dist/maps.js.map +1 -1
- package/dist/optimizer.js +18 -5
- package/dist/optimizer.js.map +1 -1
- package/dist/patches.d.ts +38 -7
- package/dist/patches.d.ts.map +1 -1
- package/dist/patches.js +122 -49
- package/dist/patches.js.map +1 -1
- package/dist/proxy.d.ts +18 -0
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +158 -7
- package/dist/proxy.js.map +1 -1
- package/dist/sets.d.ts.map +1 -1
- package/dist/sets.js +10 -3
- package/dist/sets.js.map +1 -1
- package/dist/types.d.ts +77 -15
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +6 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
- package/src/arrays.ts +22 -10
- package/src/index.ts +1 -3
- package/src/maps.ts +17 -7
- package/src/optimizer.ts +20 -6
- package/src/patches.ts +142 -55
- package/src/proxy.ts +179 -8
- package/src/sets.ts +11 -3
- package/src/types.ts +82 -15
- package/src/utils.ts +7 -0
package/src/proxy.ts
CHANGED
|
@@ -1,10 +1,109 @@
|
|
|
1
|
-
import type {PatchPath, RecorderState} from './types.js';
|
|
2
|
-
import {
|
|
1
|
+
import type {GetItemIdConfig, PatchPath, RecorderState} from './types.js';
|
|
2
|
+
import {
|
|
3
|
+
generateSetPatch,
|
|
4
|
+
generateDeletePatch,
|
|
5
|
+
generateAddPatch,
|
|
6
|
+
generateReplacePatch,
|
|
7
|
+
} from './patches.js';
|
|
3
8
|
import {isArray, isMap, isSet} from './utils.js';
|
|
4
9
|
import {handleArrayGet} from './arrays.js';
|
|
5
10
|
import {handleMapGet} from './maps.js';
|
|
6
11
|
import {handleSetGet} from './sets.js';
|
|
7
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Find the array item context for getItemId extraction.
|
|
15
|
+
* Traverses the path and getItemId config in parallel to find the TRACKED array item,
|
|
16
|
+
* not just any array item. This ensures that nested arrays inside tracked items
|
|
17
|
+
* correctly return the tracked parent item.
|
|
18
|
+
*
|
|
19
|
+
* For path ['users', 0, 'posts', 0, 'title'] with config { users: (fn) => ... }:
|
|
20
|
+
* - Returns state.users[0] (the user), not state.users[0].posts[0] (the post)
|
|
21
|
+
* - pathIndex will be 2 (position after the user's numeric index)
|
|
22
|
+
*
|
|
23
|
+
* @param path - The full path to the property being modified
|
|
24
|
+
* @param state - The recorder state containing the root state object
|
|
25
|
+
* @returns Object with item and pathIndex, or undefined if not inside a tracked array item
|
|
26
|
+
*/
|
|
27
|
+
export function findArrayItemContext(
|
|
28
|
+
path: PatchPath,
|
|
29
|
+
state: RecorderState<any>,
|
|
30
|
+
): {item: unknown; pathIndex: number} | undefined {
|
|
31
|
+
const getItemIdConfig = state.options.getItemId;
|
|
32
|
+
|
|
33
|
+
// If no getItemId config, fall back to finding any numeric index from the end
|
|
34
|
+
// This maintains backward compatibility for cases without getItemId
|
|
35
|
+
if (!getItemIdConfig) {
|
|
36
|
+
return findDeepestArrayItem(path, state);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Traverse path and config in parallel
|
|
40
|
+
// Skip numeric indices in the path when traversing the config
|
|
41
|
+
let currentConfig: GetItemIdConfig | ((item: any) => any) | undefined = getItemIdConfig;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < path.length; i++) {
|
|
44
|
+
const pathKey = path[i];
|
|
45
|
+
|
|
46
|
+
// If we find a numeric index, check if we've found a getItemId function
|
|
47
|
+
if (typeof pathKey === 'number') {
|
|
48
|
+
// Check if the previous config level was a function
|
|
49
|
+
// This means this numeric index is for a tracked array
|
|
50
|
+
if (typeof currentConfig === 'function') {
|
|
51
|
+
// Found the tracked item! Navigate to it from root
|
|
52
|
+
let item: any = state.state;
|
|
53
|
+
for (let j = 0; j <= i; j++) {
|
|
54
|
+
const key = path[j] as string | number;
|
|
55
|
+
item = item[key];
|
|
56
|
+
if (item === undefined || item === null) return undefined;
|
|
57
|
+
}
|
|
58
|
+
// pathIndex is i + 1 (position after the numeric index)
|
|
59
|
+
return {item, pathIndex: i + 1};
|
|
60
|
+
}
|
|
61
|
+
// Not a tracked array, continue to next path segment
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// String key - traverse the config
|
|
66
|
+
if (typeof pathKey === 'string' && currentConfig && typeof currentConfig === 'object') {
|
|
67
|
+
currentConfig = currentConfig[pathKey];
|
|
68
|
+
} else if (typeof pathKey === 'string') {
|
|
69
|
+
// No config at this level, but we still might have a numeric index ahead
|
|
70
|
+
// Continue without config
|
|
71
|
+
currentConfig = undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fallback function that finds the deepest array item (original behavior).
|
|
80
|
+
* Used when no getItemId config is provided.
|
|
81
|
+
*/
|
|
82
|
+
function findDeepestArrayItem(
|
|
83
|
+
path: PatchPath,
|
|
84
|
+
state: RecorderState<any>,
|
|
85
|
+
): {item: unknown; pathIndex: number} | undefined {
|
|
86
|
+
for (let i = path.length - 1; i >= 1; i--) {
|
|
87
|
+
if (typeof path[i] === 'number') {
|
|
88
|
+
// Found a numeric index - the array item is the object at this position
|
|
89
|
+
// Navigate from the root state to get the array item
|
|
90
|
+
let current: any = state.state;
|
|
91
|
+
for (let j = 0; j <= i; j++) {
|
|
92
|
+
const pathKey = path[j] as string | number;
|
|
93
|
+
current = current[pathKey];
|
|
94
|
+
if (current === undefined || current === null) break;
|
|
95
|
+
}
|
|
96
|
+
if (current !== undefined && current !== null) {
|
|
97
|
+
// pathIndex is i + 1 (the position after the numeric index)
|
|
98
|
+
// This represents the length of the item path
|
|
99
|
+
return {item: current, pathIndex: i + 1};
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
8
107
|
export function createProxy<T extends object>(
|
|
9
108
|
target: T,
|
|
10
109
|
path: PatchPath,
|
|
@@ -78,17 +177,80 @@ export function createProxy<T extends object>(
|
|
|
78
177
|
return true;
|
|
79
178
|
}
|
|
80
179
|
|
|
180
|
+
// Special handling for array length with arrayLengthAssignment: false
|
|
181
|
+
// Must capture removed items BEFORE mutation
|
|
182
|
+
let removedItems: any[] | null = null;
|
|
183
|
+
if (isArrayType && prop === 'length' && state.options.arrayLengthAssignment === false) {
|
|
184
|
+
const arr = obj as any[];
|
|
185
|
+
const newLength = value as number;
|
|
186
|
+
if (newLength < oldValue) {
|
|
187
|
+
// Capture items that will be removed before mutation
|
|
188
|
+
removedItems = [];
|
|
189
|
+
for (let i = newLength; i < oldValue; i++) {
|
|
190
|
+
removedItems.push(arr[i]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
81
195
|
// Mutate original immediately
|
|
82
196
|
(obj as any)[prop] = value;
|
|
83
197
|
|
|
198
|
+
// Find array item context for getItemId
|
|
199
|
+
const itemContext = findArrayItemContext(path, state);
|
|
200
|
+
|
|
84
201
|
// Generate patch - use pre-mutation property existence check
|
|
85
202
|
if (!hadProperty) {
|
|
86
|
-
|
|
203
|
+
// Check if we're adding a field to an array item (should include id)
|
|
204
|
+
// vs adding a new item to an array (should NOT include id)
|
|
205
|
+
if (itemContext) {
|
|
206
|
+
// Adding a field to an existing array item - include the item id
|
|
207
|
+
generateAddPatch(state, propPath, value, itemContext.item, itemContext.pathIndex);
|
|
208
|
+
} else {
|
|
209
|
+
// Adding a new item to an array or a regular property - no id
|
|
210
|
+
generateAddPatch(state, propPath, value);
|
|
211
|
+
}
|
|
87
212
|
} else if (isArrayType && prop === 'length') {
|
|
88
|
-
|
|
89
|
-
|
|
213
|
+
if (state.options.arrayLengthAssignment === false) {
|
|
214
|
+
// When arrayLengthAssignment is false, generate individual remove patches
|
|
215
|
+
// for each removed item (in reverse order)
|
|
216
|
+
const newLength = value as number;
|
|
217
|
+
|
|
218
|
+
if (removedItems) {
|
|
219
|
+
// Array was shrinking - generate remove patches for removed items
|
|
220
|
+
// Iterate in reverse to generate patches from end to start
|
|
221
|
+
for (let i = removedItems.length - 1; i >= 0; i--) {
|
|
222
|
+
const index = newLength + i;
|
|
223
|
+
generateDeletePatch(state, [...path, index], removedItems[i]);
|
|
224
|
+
}
|
|
225
|
+
} else if (newLength > oldValue) {
|
|
226
|
+
// Array is growing - generate add patches for new undefined slots
|
|
227
|
+
for (let i = oldValue; i < newLength; i++) {
|
|
228
|
+
generateAddPatch(state, [...path, i], undefined);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// Use generateReplacePatch for array length to include oldValue
|
|
233
|
+
generateReplacePatch(state, propPath, value, oldValue);
|
|
234
|
+
}
|
|
235
|
+
} else if (isArrayType && typeof propForPath === 'number') {
|
|
236
|
+
// Array element replacement - could be:
|
|
237
|
+
// 1. Top-level tracked array item (state.items[0] = newItem) → NO id
|
|
238
|
+
// 2. Nested array inside tracked item (state.items[0].tags[1] = 'new') → INCLUDE parent item id
|
|
239
|
+
if (itemContext) {
|
|
240
|
+
// Nested array inside a tracked item - include the parent item's id
|
|
241
|
+
generateSetPatch(state, propPath, value, itemContext.item, itemContext.pathIndex);
|
|
242
|
+
} else {
|
|
243
|
+
// Top-level tracked array item replacement - no id
|
|
244
|
+
generateSetPatch(state, propPath, value);
|
|
245
|
+
}
|
|
246
|
+
} else if (itemContext) {
|
|
247
|
+
// Modifying a field inside an array item (e.g., state.items[0].name = 'new')
|
|
248
|
+
// or deeply nested (e.g., state.items[0].data.nested.value = 'new')
|
|
249
|
+
// Pass the array item for id extraction and itemPathIndex
|
|
250
|
+
generateSetPatch(state, propPath, value, itemContext.item, itemContext.pathIndex);
|
|
90
251
|
} else {
|
|
91
|
-
|
|
252
|
+
// Regular property modification
|
|
253
|
+
generateSetPatch(state, propPath, value);
|
|
92
254
|
}
|
|
93
255
|
|
|
94
256
|
return true;
|
|
@@ -111,8 +273,17 @@ export function createProxy<T extends object>(
|
|
|
111
273
|
if (oldValue !== undefined || Object.prototype.hasOwnProperty.call(obj, prop)) {
|
|
112
274
|
delete (obj as any)[prop];
|
|
113
275
|
|
|
114
|
-
//
|
|
115
|
-
|
|
276
|
+
// Find array item context for getItemId
|
|
277
|
+
const itemContext = findArrayItemContext(path, state);
|
|
278
|
+
|
|
279
|
+
// Generate patch with item context if we're inside an array item
|
|
280
|
+
generateDeletePatch(
|
|
281
|
+
state,
|
|
282
|
+
propPath,
|
|
283
|
+
oldValue,
|
|
284
|
+
itemContext?.item,
|
|
285
|
+
itemContext?.pathIndex,
|
|
286
|
+
);
|
|
116
287
|
}
|
|
117
288
|
|
|
118
289
|
return true;
|
package/src/sets.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {PatchPath, RecorderState} from './types.js';
|
|
2
|
+
import {findArrayItemContext} from './proxy.js';
|
|
2
3
|
import {generateAddPatch, generateDeletePatch} from './patches.js';
|
|
3
4
|
import {cloneIfNeeded} from './utils.js';
|
|
4
5
|
|
|
@@ -28,7 +29,9 @@ export function handleSetGet(
|
|
|
28
29
|
// Generate patch only if value didn't exist
|
|
29
30
|
if (!existed) {
|
|
30
31
|
const itemPath = [...path, value as any];
|
|
31
|
-
|
|
32
|
+
// Find parent item context if this Set is inside a tracked array item
|
|
33
|
+
const itemContext = findArrayItemContext(path, state);
|
|
34
|
+
generateAddPatch(state, itemPath, cloneIfNeeded(value), itemContext?.item, itemContext?.pathIndex);
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
return result;
|
|
@@ -43,7 +46,9 @@ export function handleSetGet(
|
|
|
43
46
|
// Generate patch only if value existed
|
|
44
47
|
if (existed) {
|
|
45
48
|
const itemPath = [...path, value as any];
|
|
46
|
-
|
|
49
|
+
// Find parent item context if this Set is inside a tracked array item
|
|
50
|
+
const itemContext = findArrayItemContext(path, state);
|
|
51
|
+
generateDeletePatch(state, itemPath, cloneIfNeeded(value), itemContext?.item, itemContext?.pathIndex);
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
return result;
|
|
@@ -55,10 +60,13 @@ export function handleSetGet(
|
|
|
55
60
|
const values = Array.from(obj.values());
|
|
56
61
|
obj.clear();
|
|
57
62
|
|
|
63
|
+
// Find parent item context if this Set is inside a tracked array item
|
|
64
|
+
const itemContext = findArrayItemContext(path, state);
|
|
65
|
+
|
|
58
66
|
// Generate remove patches for all items
|
|
59
67
|
values.forEach((value) => {
|
|
60
68
|
const itemPath = [...path, value as any];
|
|
61
|
-
generateDeletePatch(state, itemPath, cloneIfNeeded(value));
|
|
69
|
+
generateDeletePatch(state, itemPath, cloneIfNeeded(value), itemContext?.item, itemContext?.pathIndex);
|
|
62
70
|
});
|
|
63
71
|
};
|
|
64
72
|
}
|
package/src/types.ts
CHANGED
|
@@ -20,39 +20,106 @@ export type GetItemIdConfig = {
|
|
|
20
20
|
|
|
21
21
|
export type PatchPath = (string | number | symbol | object)[];
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Item identity information.
|
|
25
|
+
* Always includes both id and pathIndex together when present.
|
|
26
|
+
* Use `patch.path.slice(0, patch.pathIndex)` to get the path to the item.
|
|
27
|
+
*/
|
|
28
|
+
interface PatchWithItemId {
|
|
29
|
+
/**
|
|
30
|
+
* ID of the item being modified.
|
|
31
|
+
* Populated when getItemId option is configured and a field inside an array item is modified.
|
|
32
|
+
*/
|
|
33
|
+
id: string | number;
|
|
34
|
+
/**
|
|
35
|
+
* Index indicating where in the path the item that `id` refers to ends.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* // For path ['items', 0, 'data', 'nested', 'value'] with pathIndex 2
|
|
39
|
+
* // The item path is ['items', 0]
|
|
40
|
+
*/
|
|
41
|
+
pathIndex: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Patch without item identity information.
|
|
46
|
+
*/
|
|
47
|
+
interface PatchWithoutItemId {
|
|
48
|
+
id?: undefined;
|
|
49
|
+
pathIndex?: undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Base patch structure shared by all operations
|
|
54
|
+
*/
|
|
55
|
+
interface BasePatch {
|
|
24
56
|
path: PatchPath;
|
|
25
|
-
|
|
26
|
-
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Add operation - adding a new value.
|
|
61
|
+
* Includes item identity when adding a field TO an existing array item (the item is being modified).
|
|
62
|
+
* Does NOT include item identity when adding a new item to an array.
|
|
63
|
+
*/
|
|
64
|
+
export type AddPatch = BasePatch & {
|
|
65
|
+
op: typeof Operation.Add;
|
|
66
|
+
value: any;
|
|
67
|
+
} & (PatchWithItemId | PatchWithoutItemId);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Remove operation - removing a value.
|
|
71
|
+
* Includes item identity when removing a field FROM an array item (not when removing the item itself).
|
|
72
|
+
*/
|
|
73
|
+
export type RemovePatch = BasePatch & {
|
|
74
|
+
op: typeof Operation.Remove;
|
|
75
|
+
} & (PatchWithItemId | PatchWithoutItemId);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Replace operation - replacing a value.
|
|
79
|
+
* Includes oldValue for array length changes.
|
|
80
|
+
* Includes item identity when modifying a field inside an array item.
|
|
81
|
+
*/
|
|
82
|
+
export type ReplacePatch = BasePatch & {
|
|
83
|
+
op: typeof Operation.Replace;
|
|
84
|
+
value: any;
|
|
27
85
|
/**
|
|
28
|
-
*
|
|
86
|
+
* Previous value for replace operations on array length.
|
|
29
87
|
* Enables consumers to detect how many elements were removed without pre-snapshotting state.
|
|
30
88
|
*/
|
|
31
89
|
oldValue?: unknown;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
90
|
+
} & (PatchWithItemId | PatchWithoutItemId);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Union of all patch types.
|
|
94
|
+
* - AddPatch: Adding new values (no item identity)
|
|
95
|
+
* - RemovePatch: Removing values (has item identity when removing a field from an item)
|
|
96
|
+
* - ReplacePatch: Replacing values (has item identity when modifying a field inside an item)
|
|
97
|
+
*/
|
|
98
|
+
export type Patch = AddPatch | RemovePatch | ReplacePatch;
|
|
38
99
|
|
|
39
100
|
export type Patches = Patch[];
|
|
40
101
|
|
|
41
102
|
export type NonPrimitive = object | Array<unknown>;
|
|
42
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Configuration options for recordPatches.
|
|
106
|
+
*/
|
|
43
107
|
export interface RecordPatchesOptions {
|
|
44
|
-
/**
|
|
45
|
-
* Include array length in patches (default: true)
|
|
46
|
-
*/
|
|
47
|
-
arrayLengthAssignment?: boolean;
|
|
48
108
|
/**
|
|
49
109
|
* Compress patches by merging redundant operations (default: true)
|
|
50
110
|
*/
|
|
51
111
|
compressPatches?: boolean;
|
|
52
112
|
/**
|
|
53
|
-
*
|
|
113
|
+
* Include array length in patches (default: true)
|
|
114
|
+
*/
|
|
115
|
+
arrayLengthAssignment?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Configuration for extracting item IDs for replace patches on individual items.
|
|
54
118
|
* Maps paths to functions that extract IDs from item values.
|
|
55
119
|
*
|
|
120
|
+
* Note: Item IDs are only included when an item itself is modified (replaced),
|
|
121
|
+
* not when items are removed or the array length changes.
|
|
122
|
+
*
|
|
56
123
|
* @example
|
|
57
124
|
* ```typescript
|
|
58
125
|
* recordPatches(state, mutate, {
|
package/src/utils.ts
CHANGED
|
@@ -173,6 +173,13 @@ export function findGetItemIdFn(
|
|
|
173
173
|
continue;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
// If we already found a function, return it immediately
|
|
177
|
+
// This handles nested paths like ['items', 0, 'data', 'nested', 'value']
|
|
178
|
+
// where the config is { items: (item) => item.id }
|
|
179
|
+
if (typeof current === 'function') {
|
|
180
|
+
return current as GetItemIdFunction;
|
|
181
|
+
}
|
|
182
|
+
|
|
176
183
|
if (current === undefined || typeof current !== 'object') {
|
|
177
184
|
return undefined;
|
|
178
185
|
}
|