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/types.ts CHANGED
@@ -6,47 +6,36 @@ export const Operation = {
6
6
 
7
7
  export type PatchOp = (typeof Operation)[keyof typeof Operation];
8
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
- };
9
+ /**
10
+ * Function that extracts an ID from an item value
11
+ */
12
+ export type GetItemIdFunction = (value: any) => string | number | undefined | null;
21
13
 
22
- export interface IPatch {
14
+ /**
15
+ * Recursive configuration for getItemId - can be a function or nested object
16
+ */
17
+ export type GetItemIdConfig = {
18
+ [key: string]: GetItemIdFunction | GetItemIdConfig;
19
+ };
20
+
21
+ export type PatchPath = (string | number | symbol | object)[];
22
+
23
+ export type Patch = {
24
+ path: PatchPath;
23
25
  op: PatchOp;
24
26
  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
- };
27
+ /**
28
+ * Optional ID of the item being removed or replaced.
29
+ * Populated when getItemId option is configured for the item's parent path.
30
+ */
31
+ id?: string | number;
32
+ };
40
33
 
41
- export type Patches<P extends PatchesOptions = any> = Patch<P>[];
34
+ export type Patches = Patch[];
42
35
 
43
36
  export type NonPrimitive = object | Array<unknown>;
44
37
 
45
38
  export interface RecordPatchesOptions {
46
- /**
47
- * Return paths as arrays (default: true) or strings
48
- */
49
- pathAsArray?: boolean;
50
39
  /**
51
40
  * Include array length in patches (default: true)
52
41
  */
@@ -55,13 +44,35 @@ export interface RecordPatchesOptions {
55
44
  * Compress patches by merging redundant operations (default: true)
56
45
  */
57
46
  compressPatches?: boolean;
47
+ /**
48
+ * Configuration for extracting item IDs for remove/replace patches.
49
+ * Maps paths to functions that extract IDs from item values.
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * recordPatches(state, mutate, {
54
+ * getItemId: {
55
+ * items: (item) => item.id,
56
+ * users: (user) => user.userId,
57
+ * nested: {
58
+ * array: (item) => item._id
59
+ * }
60
+ * }
61
+ * });
62
+ * ```
63
+ */
64
+ getItemId?: GetItemIdConfig;
58
65
  }
59
66
 
60
- export type Draft<T> = T;
67
+ export type Draft<T extends NonPrimitive> = T;
61
68
 
62
- export interface RecorderState<T> {
63
- original: T;
64
- patches: Patches<any>;
65
- basePath: (string | number)[];
66
- options: RecordPatchesOptions & {internalPatchesOptions: PatchesOptions};
69
+ export interface RecorderState<T extends NonPrimitive> {
70
+ state: T;
71
+ patches: Patches;
72
+ basePath: PatchPath;
73
+ options: RecordPatchesOptions;
74
+ /**
75
+ * Cache for proxies to avoid creating new ones on repeated property access
76
+ */
77
+ proxyCache: WeakMap<object, any>;
67
78
  }
package/src/utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type {PatchesOptions} from './types.js';
1
+ import type {GetItemIdConfig, GetItemIdFunction, PatchPath} from './types.js';
2
2
 
3
3
  /**
4
4
  * Type guard to check if a value is a plain object (not null, not array, not Map/Set)
@@ -37,27 +37,12 @@ export function isSet(value: unknown): value is Set<unknown> {
37
37
  /**
38
38
  * Format a path array to either array format or JSON Pointer string format
39
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;
40
+ export function formatPath(path: PatchPath): string {
41
+ // Convert to JSON Pointer string format (RFC 6901)
42
+ if (path.length === 0) {
43
+ return '';
44
+ }
45
+ return '/' + path.map((part) => String(part).replace(/~/g, '~0').replace(/\//g, '~1')).join('/');
61
46
  }
62
47
 
63
48
  /**
@@ -148,3 +133,123 @@ export function cloneIfNeeded<T>(value: T): T {
148
133
  }
149
134
  return cloned;
150
135
  }
136
+
137
+ /**
138
+ * Find a getItemId function for a given path.
139
+ * The function is looked up by traversing the getItemId config object
140
+ * using the parent path (all elements except the last one).
141
+ *
142
+ * @example
143
+ * // For path ['items', 3] with config { items: (item) => item.id }
144
+ * // Returns the function (item) => item.id
145
+ */
146
+ export function findGetItemIdFn(
147
+ path: PatchPath,
148
+ getItemIdConfig: GetItemIdConfig | undefined,
149
+ ): GetItemIdFunction | undefined {
150
+ if (!getItemIdConfig || path.length === 0) {
151
+ return undefined;
152
+ }
153
+
154
+ // We want to match the parent path (all elements except the last one)
155
+ // For path ['items', 3], we want to find config at 'items'
156
+ // For path ['user', 'settings', 'darkMode'], we want to find config at ['user', 'settings']
157
+ const parentPath = path.slice(0, -1);
158
+
159
+ if (parentPath.length === 0) {
160
+ // The path is directly under root (e.g., ['items'])
161
+ // In this case, there's no parent to match against
162
+ return undefined;
163
+ }
164
+
165
+ // Navigate the config object using the parent path
166
+ let current: GetItemIdConfig | GetItemIdFunction | undefined = getItemIdConfig;
167
+
168
+ for (let i = 0; i < parentPath.length; i++) {
169
+ const key = parentPath[i];
170
+
171
+ // Skip numeric indices (array positions) in the path
172
+ if (typeof key === 'number') {
173
+ continue;
174
+ }
175
+
176
+ if (current === undefined || typeof current !== 'object') {
177
+ return undefined;
178
+ }
179
+
180
+ if (typeof key === 'object' || typeof key === 'symbol') {
181
+ // there is no way to match an object or symbol key in the config
182
+ return undefined;
183
+ }
184
+
185
+ current = (current as GetItemIdConfig)[key];
186
+ }
187
+
188
+ // current should now be a function or undefined
189
+ if (typeof current === 'function') {
190
+ return current as GetItemIdFunction;
191
+ }
192
+
193
+ return undefined;
194
+ }
195
+
196
+ /**
197
+ * Convert a path array or string to a string key for optimized lookup.
198
+ * Uses null character (\x00) as delimiter since it's unlikely in property names.
199
+ * This is significantly faster than JSON.stringify for the common case.
200
+ *
201
+ * @param path - The path array or string to convert
202
+ * @returns A string key representation of the path
203
+ */
204
+ export function pathToKey(path: PatchPath): string {
205
+ // If path is already a string, use it directly
206
+ if (typeof path === 'string') {
207
+ return path;
208
+ }
209
+ // Otherwise convert array to string
210
+ if (path.length === 0) {
211
+ return '';
212
+ }
213
+ if (path.length === 1) {
214
+ const elem = path[0];
215
+ if (typeof elem === 'symbol') {
216
+ return elem.toString();
217
+ }
218
+ if (typeof elem === 'object') {
219
+ return JSON.stringify(elem);
220
+ }
221
+ return String(elem);
222
+ }
223
+ return path.map((elem) => {
224
+ if (typeof elem === 'symbol') {
225
+ return elem.toString();
226
+ }
227
+ if (typeof elem === 'object') {
228
+ return JSON.stringify(elem);
229
+ }
230
+ return String(elem);
231
+ }).join('\x00');
232
+ }
233
+
234
+ /**
235
+ * Convert a string key back to a path array.
236
+ * This is the inverse of pathToKey.
237
+ *
238
+ * @param key - The string key to convert
239
+ * @returns The path array
240
+ */
241
+ export function keyToPath(key: string): (string | number)[] {
242
+ if (key === '') {
243
+ return [];
244
+ }
245
+ if (key.indexOf('\x00') === -1) {
246
+ // No delimiter, single element
247
+ // Try to parse as number for consistency
248
+ const num = Number(key);
249
+ return isNaN(num) ? [key] : [num];
250
+ }
251
+ return key.split('\x00').map((part) => {
252
+ const num = Number(part);
253
+ return isNaN(num) ? part : num;
254
+ });
255
+ }