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/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
- const mutatingMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
17
-
18
- if (mutatingMethods.includes(prop)) {
47
+ if (MUTATING_METHODS.has(prop)) {
19
48
  return (...args: any[]) => {
20
- const oldValue = [...obj]; // Snapshot before mutation
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
- const nonMutatingMethods = [
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
- const startIndex = oldValue.length;
107
+ // oldLength is the starting index before push
91
108
  args.forEach((value, i) => {
92
- const index = startIndex + i;
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
- generateReplacePatch(state, [...path, 'length'], obj.length);
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
- // Generate remove patch for the removed element
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
- // Generate remove patches for deleted items
156
- for (let i = 0; i < deleteCount; i++) {
157
- generateDeletePatch(state, [...path, start], oldValue[start]);
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
- // Generate add patches for new items
161
- addItems.forEach((item, i) => {
162
- generateAddPatch(state, [...path, start + i], item);
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
- // Generate length patch if option is enabled
177
- if (state.options.arrayLengthAssignment !== false) {
178
- generateReplacePatch(state, [...path, 'length'], obj.length);
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
- generateReplacePatch(state, path, [...obj]);
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 a Map, Array, or object, return a proxy
77
+ // If the value is an object, return a proxy for nested mutation tracking
78
78
  if (value != null && typeof value === 'object') {
79
- if (isMap(value) || isArray(value)) {
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
- // 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);
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
- // Convert Map back to array
43
- return Array.from(pathMap.values()) as Patches<true>;
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 value (no-op)
59
- if (valuesEqual(patch1.value, patch2.value)) {
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 the same value, it's a no-op
65
- if (op1 === 'add' && valuesEqual(patch1.value, patch2.value)) {
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, keep the latest
180
+ // For remove operations, don't merge (sequential removes should never be merged)
69
181
  if (op1 === 'remove') {
70
- return patch2;
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 - keep the add
97
- return patch2;
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 === value &&
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
- originalHasProperty = Object.prototype.hasOwnProperty.call(currentOriginal, prop);
91
- originalValue = currentOriginal[prop];
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 = any> = P extends {
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 = any> = Patch<P>[];
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;