patch-recorder 0.3.0 → 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/src/proxy.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type {PatchPath, RecorderState} from './types.js';
1
+ import type {GetItemIdConfig, PatchPath, RecorderState} from './types.js';
2
2
  import {
3
3
  generateSetPatch,
4
4
  generateDeletePatch,
@@ -10,6 +10,100 @@ import {handleArrayGet} from './arrays.js';
10
10
  import {handleMapGet} from './maps.js';
11
11
  import {handleSetGet} from './sets.js';
12
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
+
13
107
  export function createProxy<T extends object>(
14
108
  target: T,
15
109
  path: PatchPath,
@@ -101,9 +195,20 @@ export function createProxy<T extends object>(
101
195
  // Mutate original immediately
102
196
  (obj as any)[prop] = value;
103
197
 
198
+ // Find array item context for getItemId
199
+ const itemContext = findArrayItemContext(path, state);
200
+
104
201
  // Generate patch - use pre-mutation property existence check
105
202
  if (!hadProperty) {
106
- generateAddPatch(state, propPath, value);
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
+ }
107
212
  } else if (isArrayType && prop === 'length') {
108
213
  if (state.options.arrayLengthAssignment === false) {
109
214
  // When arrayLengthAssignment is false, generate individual remove patches
@@ -127,8 +232,25 @@ export function createProxy<T extends object>(
127
232
  // Use generateReplacePatch for array length to include oldValue
128
233
  generateReplacePatch(state, propPath, value, oldValue);
129
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);
130
251
  } else {
131
- generateSetPatch(state, propPath, oldValue, value);
252
+ // Regular property modification
253
+ generateSetPatch(state, propPath, value);
132
254
  }
133
255
 
134
256
  return true;
@@ -151,8 +273,17 @@ export function createProxy<T extends object>(
151
273
  if (oldValue !== undefined || Object.prototype.hasOwnProperty.call(obj, prop)) {
152
274
  delete (obj as any)[prop];
153
275
 
154
- // Generate patch
155
- generateDeletePatch(state, propPath, oldValue);
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
+ );
156
287
  }
157
288
 
158
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
- generateAddPatch(state, itemPath, cloneIfNeeded(value));
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
- generateDeletePatch(state, itemPath, cloneIfNeeded(value));
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,54 +20,109 @@ export type GetItemIdConfig = {
20
20
 
21
21
  export type PatchPath = (string | number | symbol | object)[];
22
22
 
23
- export type Patch = {
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
- op: PatchOp;
26
- value?: any;
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
- * Optional previous value for replace operations on array length.
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
- * Optional ID of the item being removed or replaced.
34
- * Populated when getItemId option is configured for the item's parent path.
35
- */
36
- id?: string | number;
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
 
43
104
  /**
44
- * Base options shared by all configurations
105
+ * Configuration options for recordPatches.
45
106
  */
46
- interface BaseRecordPatchesOptions {
107
+ export interface RecordPatchesOptions {
47
108
  /**
48
109
  * Compress patches by merging redundant operations (default: true)
49
110
  */
50
111
  compressPatches?: boolean;
51
- }
52
-
53
- /**
54
- * Options when using getItemId - requires arrayLengthAssignment to be false
55
- * because length patches cannot include individual item IDs.
56
- */
57
- interface RecordPatchesOptionsWithItemId extends BaseRecordPatchesOptions {
58
112
  /**
59
- * Must be false when using getItemId.
60
- * Length patches cannot include individual item IDs.
113
+ * Include array length in patches (default: true)
61
114
  */
62
- arrayLengthAssignment: false;
115
+ arrayLengthAssignment?: boolean;
63
116
  /**
64
- * Configuration for extracting item IDs for remove/replace patches.
117
+ * Configuration for extracting item IDs for replace patches on individual items.
65
118
  * Maps paths to functions that extract IDs from item values.
66
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
+ *
67
123
  * @example
68
124
  * ```typescript
69
125
  * recordPatches(state, mutate, {
70
- * arrayLengthAssignment: false,
71
126
  * getItemId: {
72
127
  * items: (item) => item.id,
73
128
  * users: (user) => user.userId,
@@ -78,34 +133,9 @@ interface RecordPatchesOptionsWithItemId extends BaseRecordPatchesOptions {
78
133
  * });
79
134
  * ```
80
135
  */
81
- getItemId: GetItemIdConfig;
136
+ getItemId?: GetItemIdConfig;
82
137
  }
83
138
 
84
- /**
85
- * Options when not using getItemId - arrayLengthAssignment can be any value
86
- */
87
- interface RecordPatchesOptionsWithoutItemId extends BaseRecordPatchesOptions {
88
- /**
89
- * Include array length in patches (default: true)
90
- */
91
- arrayLengthAssignment?: boolean;
92
- /**
93
- * Not available unless arrayLengthAssignment is false
94
- */
95
- getItemId?: undefined;
96
- }
97
-
98
- /**
99
- * Configuration options for recordPatches.
100
- *
101
- * Note: getItemId requires arrayLengthAssignment: false because length patches
102
- * (e.g., { op: 'replace', path: ['arr', 'length'], value: 2, oldValue: 3 })
103
- * cannot include the IDs of individual items that were removed.
104
- */
105
- export type RecordPatchesOptions =
106
- | RecordPatchesOptionsWithItemId
107
- | RecordPatchesOptionsWithoutItemId;
108
-
109
139
  export interface RecorderState<T extends NonPrimitive> {
110
140
  state: T;
111
141
  patches: Patches;
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
  }