@blumintinc/typescript-memoize 1.2.0 → 1.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.
@@ -1,250 +1,308 @@
1
- const equal = require('fast-deep-equal');
2
-
3
- interface MemoizeArgs {
4
- expiring?: number;
5
- hashFunction?: boolean | ((...args: any[]) => any);
6
- tags?: string[];
7
- useDeepEqual?: boolean;
8
- }
9
-
10
- export function Memoize(args?: MemoizeArgs | MemoizeArgs['hashFunction']) {
11
- let hashFunction: MemoizeArgs['hashFunction'];
12
- let duration: MemoizeArgs['expiring'];
13
- let tags: MemoizeArgs['tags'];
14
- let useDeepEqual: MemoizeArgs['useDeepEqual'] = true;
15
-
16
- if (typeof args === 'object') {
17
- hashFunction = args.hashFunction;
18
- duration = args.expiring;
19
- tags = args.tags;
20
- useDeepEqual = args.useDeepEqual ?? true;
21
- } else {
22
- hashFunction = args;
23
- }
24
-
25
- return (target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) => {
26
- if (descriptor.value != null) {
27
- descriptor.value = getNewFunction(descriptor.value, hashFunction, duration, tags, useDeepEqual);
28
- } else if (descriptor.get != null) {
29
- descriptor.get = getNewFunction(descriptor.get, hashFunction, duration, tags, useDeepEqual);
30
- } else {
31
- throw 'Only put a Memoize() decorator on a method or get accessor.';
32
- }
33
- };
34
- }
35
-
36
- export function MemoizeExpiring(expiring: number, hashFunction?: MemoizeArgs['hashFunction']) {
37
- return Memoize({
38
- expiring,
39
- hashFunction
40
- });
41
- }
42
-
43
- const clearCacheTagsMap: Map<string, Map<any, any>[]> = new Map();
44
-
45
- export function clear (tags: string[]): number {
46
- const cleared: Set<Map<any, any>> = new Set();
47
- for (const tag of tags) {
48
- const maps = clearCacheTagsMap.get(tag);
49
- if (maps) {
50
- for (const mp of maps) {
51
- if (!cleared.has(mp)) {
52
- mp.clear();
53
- cleared.add(mp);
54
- }
55
- }
56
- }
57
- }
58
- return cleared.size;
59
- }
60
-
61
- // A wrapper around Map that uses deep equality for key comparison
62
- class DeepEqualMap<K, V> {
63
- private map = new Map<string, { key: K, value: V }>();
64
-
65
- has(key: K): boolean {
66
- const entries = Array.from(this.map.values());
67
- for (const entry of entries) {
68
- if (equal(entry.key, key)) {
69
- return true;
70
- }
71
- }
72
- return false;
73
- }
74
-
75
- get(key: K): V | undefined {
76
- const entries = Array.from(this.map.values());
77
- for (const entry of entries) {
78
- if (equal(entry.key, key)) {
79
- return entry.value;
80
- }
81
- }
82
- return undefined;
83
- }
84
-
85
- set(key: K, value: V): this {
86
- const entries = Array.from(this.map.entries());
87
- for (const [serializedKey, entry] of entries) {
88
- if (equal(entry.key, key)) {
89
- this.map.delete(serializedKey);
90
- break;
91
- }
92
- }
93
-
94
- const serializedKey = `${Date.now()}_${Math.random()}`;
95
- this.map.set(serializedKey, { key, value });
96
- return this;
97
- }
98
-
99
- clear(): void {
100
- this.map.clear();
101
- }
102
- }
103
-
104
- function getNewFunction(
105
- originalMethod: () => void,
106
- hashFunction?: MemoizeArgs['hashFunction'],
107
- duration: number = 0,
108
- tags?: MemoizeArgs['tags'],
109
- useDeepEqual: boolean = true
110
- ) {
111
- const propMapName = Symbol(`__memoized_map__`);
112
- const propDeepMapName = Symbol(`__memoized_deep_map__`);
113
-
114
- // The function returned here gets called instead of originalMethod.
115
- return function (...args: any[]) {
116
- let returnedValue: any;
117
-
118
- // Get or create appropriate map based on deep equality requirement
119
- if (useDeepEqual) {
120
- if (!this.hasOwnProperty(propDeepMapName)) {
121
- Object.defineProperty(this, propDeepMapName, {
122
- configurable: false,
123
- enumerable: false,
124
- writable: false,
125
- value: new DeepEqualMap<any, any>()
126
- });
127
- }
128
- let myMap: DeepEqualMap<any, any> = this[propDeepMapName];
129
-
130
- if (Array.isArray(tags)) {
131
- for (const tag of tags) {
132
- // Since DeepEqualMap doesn't match the Map interface exactly,
133
- // we wrap it in a Map for tag clearing purposes
134
- const mapWrapper = {
135
- clear: () => myMap.clear()
136
- } as any;
137
-
138
- if (clearCacheTagsMap.has(tag)) {
139
- clearCacheTagsMap.get(tag).push(mapWrapper);
140
- } else {
141
- clearCacheTagsMap.set(tag, [mapWrapper]);
142
- }
143
- }
144
- }
145
-
146
- let hashKey: any;
147
-
148
- // If true is passed as first parameter, will automatically use every argument
149
- if (hashFunction === true) {
150
- hashKey = args;
151
- } else if (hashFunction) {
152
- hashKey = hashFunction.apply(this, args);
153
- } else if (args.length > 0) {
154
- hashKey = args.length === 1 ? args[0] : args;
155
- } else {
156
- hashKey = this;
157
- }
158
-
159
- // Handle expiration
160
- const timestampKey = { __timestamp: true, key: hashKey };
161
- let isExpired: boolean = false;
162
-
163
- if (duration > 0) {
164
- if (!myMap.has(timestampKey)) {
165
- isExpired = true;
166
- } else {
167
- let timestamp = myMap.get(timestampKey);
168
- isExpired = (Date.now() - timestamp) > duration;
169
- }
170
- }
171
-
172
- if (myMap.has(hashKey) && !isExpired) {
173
- returnedValue = myMap.get(hashKey);
174
- } else {
175
- returnedValue = originalMethod.apply(this, args);
176
- myMap.set(hashKey, returnedValue);
177
- if (duration > 0) {
178
- myMap.set(timestampKey, Date.now());
179
- }
180
- }
181
- } else {
182
- // Original implementation with standard Map (shallow equality)
183
- if (!this.hasOwnProperty(propMapName)) {
184
- Object.defineProperty(this, propMapName, {
185
- configurable: false,
186
- enumerable: false,
187
- writable: false,
188
- value: new Map<any, any>()
189
- });
190
- }
191
- let myMap: Map<any, any> = this[propMapName];
192
-
193
- if (Array.isArray(tags)) {
194
- for (const tag of tags) {
195
- if (clearCacheTagsMap.has(tag)) {
196
- clearCacheTagsMap.get(tag).push(myMap);
197
- } else {
198
- clearCacheTagsMap.set(tag, [myMap]);
199
- }
200
- }
201
- }
202
-
203
- if (hashFunction || args.length > 0 || duration > 0) {
204
- let hashKey: any;
205
-
206
- // If true is passed as first parameter, will automatically use every argument, passed to string
207
- if (hashFunction === true) {
208
- hashKey = args.map(a => a.toString()).join('!');
209
- } else if (hashFunction) {
210
- hashKey = hashFunction.apply(this, args);
211
- } else {
212
- hashKey = args[0];
213
- }
214
-
215
- const timestampKey = `${hashKey}__timestamp`;
216
- let isExpired: boolean = false;
217
- if (duration > 0) {
218
- if (!myMap.has(timestampKey)) {
219
- // "Expired" since it was never called before
220
- isExpired = true;
221
- } else {
222
- let timestamp = myMap.get(timestampKey);
223
- isExpired = (Date.now() - timestamp) > duration;
224
- }
225
- }
226
-
227
- if (myMap.has(hashKey) && !isExpired) {
228
- returnedValue = myMap.get(hashKey);
229
- } else {
230
- returnedValue = originalMethod.apply(this, args);
231
- myMap.set(hashKey, returnedValue);
232
- if (duration > 0) {
233
- myMap.set(timestampKey, Date.now());
234
- }
235
- }
236
-
237
- } else {
238
- const hashKey = this;
239
- if (myMap.has(hashKey)) {
240
- returnedValue = myMap.get(hashKey);
241
- } else {
242
- returnedValue = originalMethod.apply(this, args);
243
- myMap.set(hashKey, returnedValue);
244
- }
245
- }
246
- }
247
-
248
- return returnedValue;
249
- };
250
- }
1
+ import equal from '@blumintinc/fast-deep-equal';
2
+
3
+ // Detects class instances with no enumerable properties (e.g., Firestore Transaction,
4
+ // WriteBatch). Deep equality cannot meaningfully compare these — it always returns true
5
+ // because there are no enumerable keys to diff. Reference equality is strictly more
6
+ // correct: "same reference" is a valid identity check, while "always equal" is not.
7
+ function isOpaqueClassInstance(obj: unknown): boolean {
8
+ if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return false;
9
+ const proto = Object.getPrototypeOf(obj);
10
+ // Plain objects ({}) and null-prototype objects (Object.create(null))
11
+ if (proto === null || proto === Object.prototype) return false;
12
+ // Objects whose prototype was created via Object.create({}) — still plain-like
13
+ if (proto.constructor === Object) return false;
14
+ // Known value types that fast-deep-equal handles correctly
15
+ if (obj instanceof Date || obj instanceof RegExp ||
16
+ obj instanceof Map || obj instanceof Set) return false;
17
+ return Object.keys(obj as Record<string, unknown>).length === 0;
18
+ }
19
+
20
+ function containsOpaqueValue(obj: unknown): boolean {
21
+ if (typeof obj !== 'object' || obj === null) return false;
22
+ return Object.values(obj as Record<string, unknown>).some(isOpaqueClassInstance);
23
+ }
24
+
25
+ // Uses reference equality for opaque class instances while preserving deep equality
26
+ // for everything else. When no opaque values are present, delegates entirely to
27
+ // fast-deep-equal for full edge-case handling (TypedArrays, circular refs, etc.).
28
+ function memoizeEqual(a: unknown, b: unknown): boolean {
29
+ if (a === b) return true;
30
+ if (a == null || b == null) return false;
31
+ if (typeof a !== 'object' || typeof b !== 'object') return equal(a, b);
32
+
33
+ // Opaque class instances: reference equality (already checked a === b above)
34
+ if (isOpaqueClassInstance(a) || isOpaqueClassInstance(b)) return false;
35
+
36
+ // Arrays: element-wise with opaque awareness
37
+ if (Array.isArray(a) && Array.isArray(b)) {
38
+ if (a.length !== b.length) return false;
39
+ return a.every((el, i) => memoizeEqual(el, (b as unknown[])[i]));
40
+ }
41
+
42
+ // Objects: if no value is opaque, delegate to fast-deep-equal
43
+ if (!containsOpaqueValue(a) && !containsOpaqueValue(b)) {
44
+ return equal(a, b);
45
+ }
46
+
47
+ // Recursive property comparison with opaque awareness
48
+ const keysA = Object.keys(a);
49
+ const keysB = Object.keys(b);
50
+ if (keysA.length !== keysB.length) return false;
51
+ return keysA.every(
52
+ key => key in (b as Record<string, unknown>) &&
53
+ memoizeEqual(
54
+ (a as Record<string, unknown>)[key],
55
+ (b as Record<string, unknown>)[key],
56
+ ),
57
+ );
58
+ }
59
+
60
+ interface MemoizeArgs {
61
+ expiring?: number;
62
+ hashFunction?: boolean | ((...args: any[]) => any);
63
+ tags?: string[];
64
+ useDeepEqual?: boolean;
65
+ }
66
+
67
+
68
+ export function Memoize(args?: MemoizeArgs | MemoizeArgs['hashFunction']) {
69
+ let hashFunction: MemoizeArgs['hashFunction'];
70
+ let duration: MemoizeArgs['expiring'];
71
+ let tags: MemoizeArgs['tags'];
72
+ let useDeepEqual: MemoizeArgs['useDeepEqual'] = true;
73
+
74
+ if (typeof args === 'object') {
75
+ hashFunction = args.hashFunction;
76
+ duration = args.expiring;
77
+ tags = args.tags;
78
+ useDeepEqual = args.useDeepEqual ?? true;
79
+ } else {
80
+ hashFunction = args;
81
+ }
82
+
83
+ return (target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) => {
84
+ if (descriptor.value != null) {
85
+ descriptor.value = getNewFunction(descriptor.value, hashFunction, duration, tags, useDeepEqual);
86
+ } else if (descriptor.get != null) {
87
+ descriptor.get = getNewFunction(descriptor.get, hashFunction, duration, tags, useDeepEqual);
88
+ } else {
89
+ throw 'Only put a Memoize() decorator on a method or get accessor.';
90
+ }
91
+ };
92
+ }
93
+
94
+ export function MemoizeExpiring(expiring: number, hashFunction?: MemoizeArgs['hashFunction']) {
95
+ return Memoize({
96
+ expiring,
97
+ hashFunction
98
+ });
99
+ }
100
+
101
+ const clearCacheTagsMap: Map<string, Map<any, any>[]> = new Map();
102
+
103
+ export function clear (tags: string[]): number {
104
+ const cleared: Set<Map<any, any>> = new Set();
105
+ for (const tag of tags) {
106
+ const maps = clearCacheTagsMap.get(tag);
107
+ if (maps) {
108
+ for (const mp of maps) {
109
+ if (!cleared.has(mp)) {
110
+ mp.clear();
111
+ cleared.add(mp);
112
+ }
113
+ }
114
+ }
115
+ }
116
+ return cleared.size;
117
+ }
118
+
119
+ // A wrapper around Map that uses deep equality for key comparison
120
+ class DeepEqualMap<K, V> {
121
+ private map = new Map<string, { key: K, value: V }>();
122
+
123
+ has(key: K): boolean {
124
+ const entries = Array.from(this.map.values());
125
+ for (const entry of entries) {
126
+ if (memoizeEqual(entry.key, key)) {
127
+ return true;
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+
133
+ get(key: K): V | undefined {
134
+ const entries = Array.from(this.map.values());
135
+ for (const entry of entries) {
136
+ if (memoizeEqual(entry.key, key)) {
137
+ return entry.value;
138
+ }
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ set(key: K, value: V): this {
144
+ const entries = Array.from(this.map.entries());
145
+ for (const [serializedKey, entry] of entries) {
146
+ if (memoizeEqual(entry.key, key)) {
147
+ this.map.delete(serializedKey);
148
+ break;
149
+ }
150
+ }
151
+
152
+ const serializedKey = `${Date.now()}_${Math.random()}`;
153
+ this.map.set(serializedKey, { key, value });
154
+ return this;
155
+ }
156
+
157
+ clear(): void {
158
+ this.map.clear();
159
+ }
160
+ }
161
+
162
+ function getNewFunction(
163
+ originalMethod: () => void,
164
+ hashFunction?: MemoizeArgs['hashFunction'],
165
+ duration: number = 0,
166
+ tags?: MemoizeArgs['tags'],
167
+ useDeepEqual: boolean = true
168
+ ) {
169
+ const propMapName = Symbol(`__memoized_map__`);
170
+ const propDeepMapName = Symbol(`__memoized_deep_map__`);
171
+
172
+ // The function returned here gets called instead of originalMethod.
173
+ return function (...args: any[]) {
174
+ let returnedValue: any;
175
+
176
+ // Get or create appropriate map based on deep equality requirement
177
+ if (useDeepEqual) {
178
+ if (!this.hasOwnProperty(propDeepMapName)) {
179
+ Object.defineProperty(this, propDeepMapName, {
180
+ configurable: false,
181
+ enumerable: false,
182
+ writable: false,
183
+ value: new DeepEqualMap<any, any>()
184
+ });
185
+ }
186
+ let myMap: DeepEqualMap<any, any> = this[propDeepMapName];
187
+
188
+ if (Array.isArray(tags)) {
189
+ for (const tag of tags) {
190
+ // Since DeepEqualMap doesn't match the Map interface exactly,
191
+ // we wrap it in a Map for tag clearing purposes
192
+ const mapWrapper = {
193
+ clear: () => myMap.clear()
194
+ } as any;
195
+
196
+ if (clearCacheTagsMap.has(tag)) {
197
+ clearCacheTagsMap.get(tag).push(mapWrapper);
198
+ } else {
199
+ clearCacheTagsMap.set(tag, [mapWrapper]);
200
+ }
201
+ }
202
+ }
203
+
204
+ let hashKey: any;
205
+
206
+ // If true is passed as first parameter, will automatically use every argument
207
+ if (hashFunction === true) {
208
+ hashKey = args;
209
+ } else if (hashFunction) {
210
+ hashKey = hashFunction.apply(this, args);
211
+ } else if (args.length > 0) {
212
+ hashKey = args.length === 1 ? args[0] : args;
213
+ } else {
214
+ hashKey = this;
215
+ }
216
+
217
+ // Handle expiration
218
+ const timestampKey = { __timestamp: true, key: hashKey };
219
+ let isExpired: boolean = false;
220
+
221
+ if (duration > 0) {
222
+ if (!myMap.has(timestampKey)) {
223
+ isExpired = true;
224
+ } else {
225
+ let timestamp = myMap.get(timestampKey);
226
+ isExpired = (Date.now() - timestamp) > duration;
227
+ }
228
+ }
229
+
230
+ if (myMap.has(hashKey) && !isExpired) {
231
+ returnedValue = myMap.get(hashKey);
232
+ } else {
233
+ returnedValue = originalMethod.apply(this, args);
234
+ myMap.set(hashKey, returnedValue);
235
+ if (duration > 0) {
236
+ myMap.set(timestampKey, Date.now());
237
+ }
238
+ }
239
+ } else {
240
+ // Original implementation with standard Map (shallow equality)
241
+ if (!this.hasOwnProperty(propMapName)) {
242
+ Object.defineProperty(this, propMapName, {
243
+ configurable: false,
244
+ enumerable: false,
245
+ writable: false,
246
+ value: new Map<any, any>()
247
+ });
248
+ }
249
+ let myMap: Map<any, any> = this[propMapName];
250
+
251
+ if (Array.isArray(tags)) {
252
+ for (const tag of tags) {
253
+ if (clearCacheTagsMap.has(tag)) {
254
+ clearCacheTagsMap.get(tag).push(myMap);
255
+ } else {
256
+ clearCacheTagsMap.set(tag, [myMap]);
257
+ }
258
+ }
259
+ }
260
+
261
+ if (hashFunction || args.length > 0 || duration > 0) {
262
+ let hashKey: any;
263
+
264
+ // If true is passed as first parameter, will automatically use every argument, passed to string
265
+ if (hashFunction === true) {
266
+ hashKey = args.map(a => a.toString()).join('!');
267
+ } else if (hashFunction) {
268
+ hashKey = hashFunction.apply(this, args);
269
+ } else {
270
+ hashKey = args[0];
271
+ }
272
+
273
+ const timestampKey = `${hashKey}__timestamp`;
274
+ let isExpired: boolean = false;
275
+ if (duration > 0) {
276
+ if (!myMap.has(timestampKey)) {
277
+ // "Expired" since it was never called before
278
+ isExpired = true;
279
+ } else {
280
+ let timestamp = myMap.get(timestampKey);
281
+ isExpired = (Date.now() - timestamp) > duration;
282
+ }
283
+ }
284
+
285
+ if (myMap.has(hashKey) && !isExpired) {
286
+ returnedValue = myMap.get(hashKey);
287
+ } else {
288
+ returnedValue = originalMethod.apply(this, args);
289
+ myMap.set(hashKey, returnedValue);
290
+ if (duration > 0) {
291
+ myMap.set(timestampKey, Date.now());
292
+ }
293
+ }
294
+
295
+ } else {
296
+ const hashKey = this;
297
+ if (myMap.has(hashKey)) {
298
+ returnedValue = myMap.get(hashKey);
299
+ } else {
300
+ returnedValue = originalMethod.apply(this, args);
301
+ myMap.set(hashKey, returnedValue);
302
+ }
303
+ }
304
+ }
305
+
306
+ return returnedValue;
307
+ };
308
+ }