patch-recorder 0.0.1 → 0.2.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,64 +1,72 @@
1
- import type {RecorderState} from './types.js';
2
- import {Operation} from './types.js';
1
+ import type {NonPrimitive, PatchPath, RecorderState} 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(['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']);
7
+
8
+ const NON_MUTATING_METHODS = new Set([
9
+ 'map',
10
+ 'filter',
11
+ 'reduce',
12
+ 'reduceRight',
13
+ 'forEach',
14
+ 'find',
15
+ 'findIndex',
16
+ 'some',
17
+ 'every',
18
+ 'includes',
19
+ 'indexOf',
20
+ 'lastIndexOf',
21
+ 'slice',
22
+ 'concat',
23
+ 'join',
24
+ 'flat',
25
+ 'flatMap',
26
+ 'at',
27
+ ]);
28
+
6
29
  /**
7
30
  * Handle array method calls and property access
8
31
  */
9
32
  export function handleArrayGet(
10
- obj: any[],
33
+ array: unknown[],
11
34
  prop: string,
12
- path: (string | number)[],
13
- state: RecorderState<any>,
35
+ path: PatchPath,
36
+ state: RecorderState<NonPrimitive>,
14
37
  ): any {
15
38
  // Mutating methods
16
- const mutatingMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
39
+ if (MUTATING_METHODS.has(prop)) {
40
+ return (...args: unknown[]) => {
41
+ // Optimized: only copy what's needed for each method
42
+ const oldLength = array.length;
43
+ let oldValue: unknown[] | null = null;
44
+
45
+ // Only create full copy for sort/reverse which need the entire old array
46
+ if (prop === 'sort' || prop === 'reverse') {
47
+ oldValue = [...array];
48
+ }
17
49
 
18
- if (mutatingMethods.includes(prop)) {
19
- return (...args: any[]) => {
20
- const oldValue = [...obj]; // Snapshot before mutation
21
- const result = (Array.prototype as any)[prop].apply(obj, args);
50
+ const result = (Array.prototype as any)[prop].apply(array, args);
22
51
 
23
52
  // Generate patches based on the method
24
- generateArrayPatches(state, obj, prop, args, result, path, oldValue);
53
+ generateArrayPatches(state, array, prop, args, result, path, oldValue, oldLength);
25
54
 
26
55
  return result;
27
56
  };
28
57
  }
29
58
 
30
59
  // 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)) {
53
- return (Array.prototype as any)[prop].bind(obj);
60
+ if (NON_MUTATING_METHODS.has(prop)) {
61
+ return (Array.prototype as any)[prop].bind(array);
54
62
  }
55
63
 
56
64
  // Property access
57
65
  if (prop === 'length') {
58
- return obj.length;
66
+ return array.length;
59
67
  }
60
68
 
61
- const value = obj[prop as any];
69
+ const value = array[prop as any];
62
70
 
63
71
  // For numeric properties (array indices), check if the value is an object/array
64
72
  // If so, return a proxy to enable nested mutation tracking
@@ -76,106 +84,77 @@ export function handleArrayGet(
76
84
  * Generate patches for array mutations
77
85
  */
78
86
  function generateArrayPatches(
79
- state: RecorderState<any>,
80
- obj: any[],
87
+ state: RecorderState<NonPrimitive>,
88
+ array: unknown[],
81
89
  method: string,
82
- args: any[],
90
+ args: unknown[],
83
91
  result: any,
84
- path: (string | number)[],
85
- oldValue: any[],
92
+ path: PatchPath,
93
+ oldArray: unknown[] | null,
94
+ oldLength: number,
86
95
  ) {
87
96
  switch (method) {
88
97
  case 'push': {
89
98
  // Generate add patches for each new element
90
- const startIndex = oldValue.length;
99
+ // oldLength is the starting index before push
91
100
  args.forEach((value, i) => {
92
- const index = startIndex + i;
101
+ const index = oldLength + i;
93
102
  generateAddPatch(state, [...path, index], value);
94
103
  });
95
-
96
- // Generate length patch if option is enabled
97
- if (state.options.arrayLengthAssignment !== false) {
98
- generateReplacePatch(state, [...path, 'length'], obj.length);
99
- }
104
+ // No length patch when array grows (aligned with mutative)
100
105
  break;
101
106
  }
102
107
 
103
108
  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
109
  if (state.options.arrayLengthAssignment !== false) {
110
- generateReplacePatch(state, [...path, 'length'], obj.length);
110
+ // Generate length replace patch (mutative uses this instead of remove)
111
+ generateReplacePatch(state, [...path, 'length'], array.length, oldLength);
112
+ } else {
113
+ // When arrayLengthAssignment is false, generate remove patch for last element
114
+ generateDeletePatch(state, [...path, oldLength - 1], result);
111
115
  }
112
116
  break;
113
117
  }
114
118
 
115
119
  case 'shift': {
116
- // Generate remove patch for the removed element
120
+ // Remove first element (shifted elements are handled automatically by JSON Patch spec)
121
+ // We don't have oldValue here, but the result of shift() is the removed element
117
122
  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
123
  break;
130
124
  }
131
125
 
132
126
  case 'unshift': {
133
- // Add new elements at the beginning
127
+ // Add new elements at the beginning (shifted elements are handled automatically by JSON Patch spec)
134
128
  args.forEach((value, i) => {
135
129
  generateAddPatch(state, [...path, i], value);
136
130
  });
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
131
  break;
150
132
  }
151
133
 
152
134
  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]);
135
+ const [start, deleteCount = 0, ...addItems] = args as number[];
136
+ const actualStart = start < 0 ? Math.max(oldLength + start, 0) : Math.min(start, oldLength);
137
+ const actualDeleteCount = Math.min(deleteCount, oldLength - actualStart);
138
+ const minCount = Math.min(actualDeleteCount, addItems.length);
139
+
140
+ // For splice, we need the old values for delete operations
141
+ // Since we don't have oldValue, we need to track what was deleted
142
+ // The result of splice() is the array of deleted elements
143
+ const deletedElements = result as any[];
144
+
145
+ // First minCount elements: replace (overlap between add and delete)
146
+ for (let i = 0; i < minCount; i++) {
147
+ generateReplacePatch(state, [...path, actualStart + i], addItems[i], deletedElements[i]);
158
148
  }
159
149
 
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
- );
150
+ // Remaining add items: add
151
+ for (let i = minCount; i < addItems.length; i++) {
152
+ generateAddPatch(state, [...path, actualStart + i], addItems[i]);
174
153
  }
175
154
 
176
- // Generate length patch if option is enabled
177
- if (state.options.arrayLengthAssignment !== false) {
178
- generateReplacePatch(state, [...path, 'length'], obj.length);
155
+ // Remaining delete items: remove (generate in reverse order)
156
+ for (let i = actualDeleteCount - 1; i >= minCount; i--) {
157
+ generateDeletePatch(state, [...path, actualStart + i], deletedElements[i]);
179
158
  }
180
159
 
181
160
  break;
@@ -184,7 +163,8 @@ function generateArrayPatches(
184
163
  case 'sort':
185
164
  case 'reverse': {
186
165
  // These reorder the entire array - generate full replace
187
- generateReplacePatch(state, path, [...obj]);
166
+ // oldValue contains the array before the mutation
167
+ generateReplacePatch(state, path, array, oldArray);
188
168
  break;
189
169
  }
190
170
  }
package/src/index.ts CHANGED
@@ -1,13 +1,6 @@
1
1
  import {createProxy} from './proxy.js';
2
2
  import {compressPatches} from './optimizer.js';
3
- import type {
4
- NonPrimitive,
5
- Draft,
6
- RecordPatchesOptions,
7
- Patches,
8
- Patch,
9
- Operation,
10
- } from './types.js';
3
+ import type {NonPrimitive, Draft, RecordPatchesOptions, Patches} from './types.js';
11
4
 
12
5
  /**
13
6
  * Record JSON patches from mutations applied to an object, array, Map, or Set.
@@ -26,24 +19,18 @@ import type {
26
19
  * console.log(state.user.name); // 'Jane' (mutated in place!)
27
20
  * console.log(patches); // [{ op: 'replace', path: ['user', 'name'], value: 'Jane' }]
28
21
  */
29
- export function recordPatches<T extends NonPrimitive>(
30
- state: T,
31
- mutate: (state: Draft<T>) => void,
32
- options: RecordPatchesOptions = {},
33
- ): Patches<true> {
34
- const internalPatchesOptions = {
35
- pathAsArray: options.pathAsArray ?? true,
36
- arrayLengthAssignment: options.arrayLengthAssignment ?? true,
37
- };
38
-
22
+ export function recordPatches<
23
+ T extends NonPrimitive,
24
+ PatchesOption extends RecordPatchesOptions = {},
25
+ >(state: T, mutate: (state: Draft<T>) => void, options?: PatchesOption): Patches {
39
26
  const recorderState = {
40
- original: state,
27
+ state,
41
28
  patches: [],
42
29
  basePath: [],
43
30
  options: {
44
31
  ...options,
45
- internalPatchesOptions,
46
32
  },
33
+ proxyCache: new WeakMap(),
47
34
  };
48
35
 
49
36
  // Create proxy
@@ -53,42 +40,11 @@ export function recordPatches<T extends NonPrimitive>(
53
40
  mutate(proxy);
54
41
 
55
42
  // Return patches (optionally compressed)
56
- if (options.compressPatches !== false) {
43
+ if (options?.compressPatches !== false) {
57
44
  return compressPatches(recorderState.patches);
58
45
  }
59
46
 
60
- return recorderState.patches as Patches<true>;
61
- }
62
-
63
- /**
64
- * Mutative-compatible API for easy switching between mutative and patch-recorder.
65
- * Returns [state, patches] tuple like mutative does.
66
- *
67
- * Unlike mutative, this mutates the original object in place (state === originalState).
68
- * The returned state is the same reference as the input state for API compatibility.
69
- *
70
- * @param state - The state to mutate and record patches from
71
- * @param mutate - A function that receives a draft of the state and applies mutations
72
- * @param options - Configuration options (enablePatches is forced but ignored - patches are always returned)
73
- * @returns Tuple [state, patches] where state is the mutated state (same reference as input)
74
- *
75
- * @example
76
- * const state = { user: { name: 'John' } };
77
- * const [nextState, patches] = create(state, (draft) => {
78
- * draft.user.name = 'Jane';
79
- * }, {enabledPatches: true});
80
- * console.log(nextState === state); // true (mutated in place!)
81
- * console.log(patches); // [{ op: 'replace', path: ['user', 'name'], value: 'Jane' }]
82
- */
83
- export function create<T extends NonPrimitive>(
84
- state: T,
85
- mutate: (state: Draft<T>) => void,
86
- options: RecordPatchesOptions & {enablePatches: true} = {enablePatches: true},
87
- ): [T, Patches<true>] {
88
- // Extract enablePatches but ignore it (patches are always returned)
89
- const {enablePatches, ...recordPatchesOptions} = options;
90
- const patches = recordPatches(state, mutate, recordPatchesOptions);
91
- return [state, patches];
47
+ return recorderState.patches as Patches;
92
48
  }
93
49
 
94
50
  // Re-export types
package/src/maps.ts CHANGED
@@ -1,29 +1,29 @@
1
- import type {RecorderState} from './types.js';
1
+ import type {PatchPath, RecorderState} from './types.js';
2
2
  import {createProxy} from './proxy.js';
3
- import {Operation} from './types.js';
4
3
  import {generateAddPatch, generateDeletePatch, generateReplacePatch} from './patches.js';
5
- import {cloneIfNeeded, isMap, isArray} from './utils.js';
4
+ import {cloneIfNeeded} from './utils.js';
6
5
 
7
6
  /**
8
7
  * Handle property access on Map objects
9
8
  * Wraps mutating methods (set, delete, clear) to generate patches
10
9
  */
11
- export function handleMapGet<K = any, V = any>(
12
- obj: Map<K, V>,
10
+ export function handleMapGet(
11
+ obj: Map<any, any>,
13
12
  prop: string | symbol,
14
- path: (string | number)[],
13
+ path: PatchPath,
15
14
  state: RecorderState<any>,
16
15
  ): any {
17
- // Skip symbol properties
16
+ // Handle symbol properties - return the property value directly
17
+ // Symbol methods like Symbol.iterator should work normally
18
18
  if (typeof prop === 'symbol') {
19
19
  return (obj as any)[prop];
20
20
  }
21
21
 
22
22
  // Mutating methods
23
23
  if (prop === 'set') {
24
- return (key: K, value: V) => {
25
- // Check if key existed BEFORE mutation
26
- const existed = keyExistsInOriginal(state.original, path, key);
24
+ return (key: any, value: any) => {
25
+ // Check if key exists BEFORE mutation (current state, not original)
26
+ const existed = obj.has(key);
27
27
  const oldValue = obj.get(key);
28
28
  const result = obj.set(key, value);
29
29
 
@@ -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));
@@ -43,7 +43,7 @@ export function handleMapGet<K = any, V = any>(
43
43
  }
44
44
 
45
45
  if (prop === 'delete') {
46
- return (key: K) => {
46
+ return (key: any) => {
47
47
  const oldValue = obj.get(key);
48
48
  const result = obj.delete(key);
49
49
 
@@ -71,14 +71,12 @@ export function handleMapGet<K = any, V = any>(
71
71
 
72
72
  // Non-mutating methods
73
73
  if (prop === 'get') {
74
- return (key: K) => {
74
+ return (key: any) => {
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;
@@ -100,21 +98,3 @@ export function handleMapGet<K = any, V = any>(
100
98
  return (obj as any)[prop];
101
99
  }
102
100
 
103
- /**
104
- * Navigate to the original Map at the given path and check if a key exists
105
- * This is needed to check if a key existed before mutations
106
- */
107
- function keyExistsInOriginal(original: any, path: (string | number)[], key: any): boolean {
108
- let current = original;
109
- for (const part of path) {
110
- if (current == null) return false;
111
- current = current[part];
112
- }
113
-
114
- // If we reached a Map, check if the key exists
115
- if (current instanceof Map) {
116
- return current.has(key);
117
- }
118
-
119
- return false;
120
- }