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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +337 -0
  3. package/dist/arrays.d.ts +6 -0
  4. package/dist/arrays.d.ts.map +1 -0
  5. package/dist/arrays.js +143 -0
  6. package/dist/arrays.js.map +1 -0
  7. package/dist/index.d.ts +21 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +44 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/maps.d.ts +7 -0
  12. package/dist/maps.d.ts.map +1 -0
  13. package/dist/maps.js +96 -0
  14. package/dist/maps.js.map +1 -0
  15. package/dist/optimizer.d.ts +7 -0
  16. package/dist/optimizer.d.ts.map +1 -0
  17. package/dist/optimizer.js +123 -0
  18. package/dist/optimizer.js.map +1 -0
  19. package/dist/patches.d.ts +18 -0
  20. package/dist/patches.d.ts.map +1 -0
  21. package/dist/patches.js +46 -0
  22. package/dist/patches.js.map +1 -0
  23. package/dist/proxy.d.ts +3 -0
  24. package/dist/proxy.d.ts.map +1 -0
  25. package/dist/proxy.js +127 -0
  26. package/dist/proxy.js.map +1 -0
  27. package/dist/sets.d.ts +7 -0
  28. package/dist/sets.d.ts.map +1 -0
  29. package/dist/sets.js +78 -0
  30. package/dist/sets.js.map +1 -0
  31. package/dist/types.d.ts +55 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +6 -0
  34. package/dist/types.js.map +1 -0
  35. package/dist/utils.d.ts +33 -0
  36. package/dist/utils.d.ts.map +1 -0
  37. package/dist/utils.js +132 -0
  38. package/dist/utils.js.map +1 -0
  39. package/package.json +60 -0
  40. package/src/arrays.ts +191 -0
  41. package/src/index.ts +71 -0
  42. package/src/maps.ts +120 -0
  43. package/src/optimizer.ts +136 -0
  44. package/src/patches.ts +67 -0
  45. package/src/proxy.ts +163 -0
  46. package/src/sets.ts +100 -0
  47. package/src/types.ts +67 -0
  48. package/src/utils.ts +150 -0
@@ -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
+ }