@naturalcycles/js-lib 15.98.0 → 15.99.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,13 +1,16 @@
1
1
  import type { CommonLogger } from '../log/commonLogger.js';
2
- import type { AnyAsyncFunction, AnyFunction, AnyObject, MaybeParameters } from '../types.js';
2
+ import type { AnyFunction, AnyObject, MaybeParameters } from '../types.js';
3
3
  import type { MethodDecorator } from './decorator.util.js';
4
4
  import type { AsyncMemoCache } from './memo.util.js';
5
5
  export interface AsyncMemoOptions<FN> {
6
6
  /**
7
7
  * Provide a custom implementation of AsyncMemoCache.
8
8
  * Function that creates an instance of `AsyncMemoCache`.
9
+ * Defaults to in-memory `MapAsyncMemoCache`, which never expires.
10
+ * e.g `LRUAsyncMemoCache` (ttl, max) from `@naturalcycles/nodejs-lib`,
11
+ * or `CommonKeyValueDaoMemoCache` (persisted in a DB) from `@naturalcycles/db-lib`.
9
12
  */
10
- cacheFactory: () => AsyncMemoCache;
13
+ cacheFactory?: () => AsyncMemoCache;
11
14
  /**
12
15
  * Provide a custom implementation of CacheKey function.
13
16
  */
@@ -23,17 +26,22 @@ export interface AsyncMemoInstance {
23
26
  */
24
27
  clear: () => Promise<void>;
25
28
  getInstanceCache: () => Map<AnyObject, AsyncMemoCache>;
26
- getCache: (instance: AnyAsyncFunction) => AsyncMemoCache | undefined;
29
+ getCache: (instance: AnyObject) => AsyncMemoCache | undefined;
27
30
  }
28
31
  /**
29
- * Like `@_Memo`, but allowing async MemoCache implementation.
32
+ * Like `@_Memo`, but awaits the method and caches the resolved **value**, not the Promise:
33
+ *
34
+ * - The cache is async (`AsyncMemoCache`), so it can be backed by e.g Redis or Datastore
35
+ * and survive a process restart. That's the reason to use it over `@_Memo`.
36
+ * - A rejection is NOT cached, so the next call retries the method (same as `@_Memo`).
37
+ * - Parallel calls with the same key share one in-flight Promise, also while the cache lookup is pending.
38
+ *
39
+ * Cache errors (`get`/`set`) are logged and treated as a cache miss, they never fail the method.
30
40
  *
31
41
  * Implementation is more complex than `@_Memo`, because it needs to handle "in-flight" Promises
32
42
  * while waiting for cache to resolve, to prevent "async swarm" issue.
33
- *
34
- * @experimental consider normal `@_Memo` for most of the cases, it's stable and predictable
35
43
  */
36
- export declare const _AsyncMemo: <FN>(opt: AsyncMemoOptions<FN>) => MethodDecorator<FN>;
44
+ export declare const _AsyncMemo: <FN>(opt?: AsyncMemoOptions<FN>) => MethodDecorator<FN>;
37
45
  /**
38
46
  Call it on a method that is decorated with `@_AsyncMemo` to get access to additional functions,
39
47
  e.g `clear` to clear the cache, or get its underlying data.
@@ -2,16 +2,21 @@ import { _assert, _assertTypeOf } from '../error/assert.js';
2
2
  import { consoleLogger } from '../log/commonLogger.js';
3
3
  import { _objectAssign, MISS } from '../types.js';
4
4
  import { _getTargetMethodSignature } from './decorator.util.js';
5
- import { jsonMemoSerializer } from './memo.util.js';
5
+ import { jsonMemoSerializer, MapAsyncMemoCache } from './memo.util.js';
6
6
  /**
7
- * Like `@_Memo`, but allowing async MemoCache implementation.
7
+ * Like `@_Memo`, but awaits the method and caches the resolved **value**, not the Promise:
8
+ *
9
+ * - The cache is async (`AsyncMemoCache`), so it can be backed by e.g Redis or Datastore
10
+ * and survive a process restart. That's the reason to use it over `@_Memo`.
11
+ * - A rejection is NOT cached, so the next call retries the method (same as `@_Memo`).
12
+ * - Parallel calls with the same key share one in-flight Promise, also while the cache lookup is pending.
13
+ *
14
+ * Cache errors (`get`/`set`) are logged and treated as a cache miss, they never fail the method.
8
15
  *
9
16
  * Implementation is more complex than `@_Memo`, because it needs to handle "in-flight" Promises
10
17
  * while waiting for cache to resolve, to prevent "async swarm" issue.
11
- *
12
- * @experimental consider normal `@_Memo` for most of the cases, it's stable and predictable
13
18
  */
14
- export const _AsyncMemo = (opt) => (target, key, descriptor) => {
19
+ export const _AsyncMemo = (opt = {}) => (target, key, descriptor) => {
15
20
  _assertTypeOf(descriptor.value, 'function', 'Memoization can be applied only to methods');
16
21
  const originalFn = descriptor.value;
17
22
  // Map from "instance" of the Class where @_AsyncMemo is applied to AsyncMemoCache instance.
@@ -21,7 +26,7 @@ export const _AsyncMemo = (opt) => (target, key, descriptor) => {
21
26
  // It only holds values that are "in-flight", until Promise is resolved
22
27
  // After it's resolved - it's evicted from the cache and moved to the "proper" `instanceCache`
23
28
  const instancePromiseCache = new Map();
24
- const { logger = consoleLogger, cacheFactory, cacheKeyFn = jsonMemoSerializer } = opt;
29
+ const { logger = consoleLogger, cacheFactory = () => new MapAsyncMemoCache(), cacheKeyFn = jsonMemoSerializer, } = opt;
25
30
  const keyStr = String(key);
26
31
  const methodSignature = _getTargetMethodSignature(target, keyStr);
27
32
  // oxlint-disable-next-line @typescript-eslint/promise-function-async
@@ -24,7 +24,7 @@ export interface MemoInstance {
24
24
  */
25
25
  clear: () => void;
26
26
  getInstanceCache: () => Map<AnyObject, MemoCache>;
27
- getCache: (instance: AnyFunction) => MemoCache | undefined;
27
+ getCache: (instance: AnyObject) => MemoCache | undefined;
28
28
  }
29
29
  /**
30
30
  * Memoizes the method of the class, so it caches the output and returns the cached version if the "key"
@@ -35,6 +35,11 @@ export interface MemoInstance {
35
35
  * So, error-throwing functions will be called multiple times.
36
36
  * Therefor, if the origin function can possibly throw - it should try to be idempotent.
37
37
  *
38
+ * For an async method it's the returned **Promise** that is cached, so parallel calls with the same key
39
+ * share one in-flight Promise. A rejected Promise is evicted from the cache, so the next call retries
40
+ * the method, same as for a thrown Error.
41
+ * Use `@_AsyncMemo` for an async-backed cache (e.g Redis), which stores the resolved value instead.
42
+ *
38
43
  * Cache is stored **per instance** - separate cache for separate instances of the class.
39
44
  * If you don't want it that way - you can use a static method, then there will be only one "instance".
40
45
  *
@@ -12,6 +12,11 @@ import { jsonMemoSerializer, MapMemoCache } from './memo.util.js';
12
12
  * So, error-throwing functions will be called multiple times.
13
13
  * Therefor, if the origin function can possibly throw - it should try to be idempotent.
14
14
  *
15
+ * For an async method it's the returned **Promise** that is cached, so parallel calls with the same key
16
+ * share one in-flight Promise. A rejected Promise is evicted from the cache, so the next call retries
17
+ * the method, same as for a thrown Error.
18
+ * Use `@_AsyncMemo` for an async-backed cache (e.g Redis), which stores the resolved value instead.
19
+ *
15
20
  * Cache is stored **per instance** - separate cache for separate instances of the class.
16
21
  * If you don't want it that way - you can use a static method, then there will be only one "instance".
17
22
  *
@@ -57,6 +62,20 @@ export const _Memo = (opt = {}) => (target, key, descriptor) => {
57
62
  catch (err) {
58
63
  logger.error({ err });
59
64
  }
65
+ if (value instanceof Promise) {
66
+ // Don't cache a rejection: evict it, so the next call retries the method.
67
+ // The derived Promise is handled, so it adds no unhandled rejection.
68
+ value.catch(() => {
69
+ try {
70
+ // Only if the key still holds this very Promise (and not a newer one, cached after an eviction)
71
+ if (cache.get(cacheKey) === value)
72
+ cache.delete(cacheKey);
73
+ }
74
+ catch (err) {
75
+ logger.error({ err });
76
+ }
77
+ });
78
+ }
60
79
  return value;
61
80
  };
62
81
  _objectAssign(descriptor.value, {
@@ -19,6 +19,7 @@ export interface MemoCache<KEY = any, VALUE = any> {
19
19
  */
20
20
  get: (k: KEY) => VALUE;
21
21
  set: (k: KEY, v: VALUE, opt?: MemoCacheOptions) => void;
22
+ delete: (k: KEY) => void;
22
23
  /**
23
24
  * Clear is only called when `_getMemoCache().clear()` is called.
24
25
  * Otherwise the Cache is "persistent" (never cleared).
@@ -33,6 +34,7 @@ export interface AsyncMemoCache<KEY = any, VALUE = any> {
33
34
  */
34
35
  get: (k: KEY) => Promise<VALUE | typeof MISS>;
35
36
  set: (k: KEY, v: VALUE, opt?: MemoCacheOptions) => Promise<void>;
37
+ delete: (k: KEY) => Promise<void>;
36
38
  /**
37
39
  * Clear is only called when `_getAsyncMemo().clear()` is called.
38
40
  * Otherwise the Cache is "persistent" (never cleared).
@@ -44,18 +46,18 @@ export declare class MapMemoCache<KEY = any, VALUE = any> implements MemoCache<K
44
46
  has(k: KEY): boolean;
45
47
  get(k: KEY): VALUE;
46
48
  set(k: KEY, v: VALUE): void;
49
+ delete(k: KEY): void;
47
50
  clear(): void;
48
51
  }
49
52
  /**
50
53
  * Implementation of AsyncMemoCache backed by a synchronous Map.
51
- * Doesn't have a practical use except testing,
52
- * because the point of AsyncMemoCache is to have an **async** backed cache.
54
+ * The default cache of `@_AsyncMemo`: in-memory, never expires.
55
+ * For an **async** backed cache (Redis, Datastore) use another implementation.
53
56
  */
54
57
  export declare class MapAsyncMemoCache<KEY = any, VALUE = any> implements AsyncMemoCache<KEY, VALUE> {
55
- private delay;
56
- constructor(delay?: number);
57
58
  private m;
58
59
  get(k: KEY): Promise<VALUE | typeof MISS>;
59
60
  set(k: KEY, v: VALUE): Promise<void>;
61
+ delete(k: KEY): Promise<void>;
60
62
  clear(): Promise<void>;
61
63
  }
@@ -1,5 +1,4 @@
1
1
  import { _isPrimitive } from '../is.util.js';
2
- import { pDelay } from '../promise/pDelay.js';
3
2
  import { MISS } from '../types.js';
4
3
  export const jsonMemoSerializer = args => {
5
4
  if (args.length === 0)
@@ -64,33 +63,34 @@ export class MapMemoCache {
64
63
  set(k, v) {
65
64
  this.m.set(k, v);
66
65
  }
66
+ delete(k) {
67
+ this.m.delete(k);
68
+ }
67
69
  clear() {
68
70
  this.m.clear();
69
71
  }
70
72
  }
71
73
  /**
72
74
  * Implementation of AsyncMemoCache backed by a synchronous Map.
73
- * Doesn't have a practical use except testing,
74
- * because the point of AsyncMemoCache is to have an **async** backed cache.
75
+ * The default cache of `@_AsyncMemo`: in-memory, never expires.
76
+ * For an **async** backed cache (Redis, Datastore) use another implementation.
75
77
  */
76
78
  export class MapAsyncMemoCache {
77
- delay;
78
- constructor(delay = 0) {
79
- this.delay = delay;
80
- }
81
79
  m = new Map();
82
80
  async get(k) {
83
- await pDelay(this.delay);
84
- if (!this.m.has(k))
81
+ const v = this.m.get(k);
82
+ // `undefined` is a valid VALUE, only then `has` tells it apart from a miss
83
+ if (v === undefined && !this.m.has(k))
85
84
  return MISS;
86
- return this.m.get(k);
85
+ return v;
87
86
  }
88
87
  async set(k, v) {
89
- await pDelay(this.delay);
90
88
  this.m.set(k, v);
91
89
  }
90
+ async delete(k) {
91
+ this.m.delete(k);
92
+ }
92
93
  async clear() {
93
- await pDelay(this.delay);
94
94
  this.m.clear();
95
95
  }
96
96
  }
@@ -21,6 +21,20 @@ export function _memoized(fn, opt = {}) {
21
21
  catch (err) {
22
22
  logger.error({ err });
23
23
  }
24
+ if (value instanceof Promise) {
25
+ // Don't cache a rejection: evict it, so the next call retries the function.
26
+ // The derived Promise is handled, so it adds no unhandled rejection.
27
+ value.catch(() => {
28
+ try {
29
+ // Only if the key still holds this very Promise (and not a newer one, cached after an eviction)
30
+ if (cache.get(cacheKey) === value)
31
+ cache.delete(cacheKey);
32
+ }
33
+ catch (err) {
34
+ logger.error({ err });
35
+ }
36
+ });
37
+ }
24
38
  return value;
25
39
  };
26
40
  Object.assign(memoizedFn, { cache });
@@ -4,4 +4,10 @@ import type { AsyncMemoCache } from './memo.util.js';
4
4
  export interface MemoizedAsyncFunction {
5
5
  cache: AsyncMemoCache;
6
6
  }
7
- export declare function _memoizedAsync<FN extends AnyAsyncFunction>(fn: FN, opt: AsyncMemoOptions<FN>): FN & MemoizedAsyncFunction;
7
+ /**
8
+ * Function version of `@_AsyncMemo`: awaits `fn` and caches the resolved value, not the Promise,
9
+ * so a rejection is not cached. Cache errors are logged and treated as a miss.
10
+ *
11
+ * Unlike the decorator, it does NOT dedupe parallel calls with the same key.
12
+ */
13
+ export declare function _memoizedAsync<FN extends AnyAsyncFunction>(fn: FN, opt?: AsyncMemoOptions<FN>): FN & MemoizedAsyncFunction;
@@ -1,13 +1,20 @@
1
1
  import { consoleLogger } from '../log/commonLogger.js';
2
2
  import { MISS } from '../types.js';
3
- import { jsonMemoSerializer } from './memo.util.js';
4
- export function _memoizedAsync(fn, opt) {
5
- const { logger = consoleLogger, cacheFactory, cacheKeyFn = jsonMemoSerializer } = opt;
3
+ import { jsonMemoSerializer, MapAsyncMemoCache } from './memo.util.js';
4
+ /**
5
+ * Function version of `@_AsyncMemo`: awaits `fn` and caches the resolved value, not the Promise,
6
+ * so a rejection is not cached. Cache errors are logged and treated as a miss.
7
+ *
8
+ * Unlike the decorator, it does NOT dedupe parallel calls with the same key.
9
+ */
10
+ export function _memoizedAsync(fn, opt = {}) {
11
+ const { logger = consoleLogger, cacheFactory = () => new MapAsyncMemoCache(), cacheKeyFn = jsonMemoSerializer, } = opt;
6
12
  const cache = cacheFactory();
7
13
  const memoizedFn = async function (...args) {
8
14
  const ctx = this;
9
15
  const cacheKey = cacheKeyFn(args);
10
- let value;
16
+ // MISS, so that a failed cache `get` is a miss too (and not a cached `undefined`)
17
+ let value = MISS;
11
18
  try {
12
19
  value = await cache.get(cacheKey);
13
20
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
3
  "type": "module",
4
- "version": "15.98.0",
4
+ "version": "15.99.0",
5
5
  "dependencies": {
6
6
  "tslib": "^2"
7
7
  },
@@ -1,19 +1,22 @@
1
1
  import { _assert, _assertTypeOf } from '../error/assert.js'
2
2
  import { consoleLogger } from '../log/commonLogger.js'
3
3
  import type { CommonLogger } from '../log/commonLogger.js'
4
- import type { AnyAsyncFunction, AnyFunction, AnyObject, MaybeParameters } from '../types.js'
4
+ import type { AnyFunction, AnyObject, MaybeParameters } from '../types.js'
5
5
  import { _objectAssign, MISS } from '../types.js'
6
6
  import type { MethodDecorator } from './decorator.util.js'
7
7
  import { _getTargetMethodSignature } from './decorator.util.js'
8
8
  import type { AsyncMemoCache } from './memo.util.js'
9
- import { jsonMemoSerializer } from './memo.util.js'
9
+ import { jsonMemoSerializer, MapAsyncMemoCache } from './memo.util.js'
10
10
 
11
11
  export interface AsyncMemoOptions<FN> {
12
12
  /**
13
13
  * Provide a custom implementation of AsyncMemoCache.
14
14
  * Function that creates an instance of `AsyncMemoCache`.
15
+ * Defaults to in-memory `MapAsyncMemoCache`, which never expires.
16
+ * e.g `LRUAsyncMemoCache` (ttl, max) from `@naturalcycles/nodejs-lib`,
17
+ * or `CommonKeyValueDaoMemoCache` (persisted in a DB) from `@naturalcycles/db-lib`.
15
18
  */
16
- cacheFactory: () => AsyncMemoCache
19
+ cacheFactory?: () => AsyncMemoCache
17
20
 
18
21
  /**
19
22
  * Provide a custom implementation of CacheKey function.
@@ -34,19 +37,24 @@ export interface AsyncMemoInstance {
34
37
 
35
38
  getInstanceCache: () => Map<AnyObject, AsyncMemoCache>
36
39
 
37
- getCache: (instance: AnyAsyncFunction) => AsyncMemoCache | undefined
40
+ getCache: (instance: AnyObject) => AsyncMemoCache | undefined
38
41
  }
39
42
 
40
43
  /**
41
- * Like `@_Memo`, but allowing async MemoCache implementation.
44
+ * Like `@_Memo`, but awaits the method and caches the resolved **value**, not the Promise:
45
+ *
46
+ * - The cache is async (`AsyncMemoCache`), so it can be backed by e.g Redis or Datastore
47
+ * and survive a process restart. That's the reason to use it over `@_Memo`.
48
+ * - A rejection is NOT cached, so the next call retries the method (same as `@_Memo`).
49
+ * - Parallel calls with the same key share one in-flight Promise, also while the cache lookup is pending.
50
+ *
51
+ * Cache errors (`get`/`set`) are logged and treated as a cache miss, they never fail the method.
42
52
  *
43
53
  * Implementation is more complex than `@_Memo`, because it needs to handle "in-flight" Promises
44
54
  * while waiting for cache to resolve, to prevent "async swarm" issue.
45
- *
46
- * @experimental consider normal `@_Memo` for most of the cases, it's stable and predictable
47
55
  */
48
56
  export const _AsyncMemo =
49
- <FN>(opt: AsyncMemoOptions<FN>): MethodDecorator<FN> =>
57
+ <FN>(opt: AsyncMemoOptions<FN> = {}): MethodDecorator<FN> =>
50
58
  (target, key, descriptor) => {
51
59
  _assertTypeOf<AnyFunction>(
52
60
  descriptor.value,
@@ -65,7 +73,11 @@ export const _AsyncMemo =
65
73
  // After it's resolved - it's evicted from the cache and moved to the "proper" `instanceCache`
66
74
  const instancePromiseCache = new Map<AnyObject, Map<any, Promise<any>>>()
67
75
 
68
- const { logger = consoleLogger, cacheFactory, cacheKeyFn = jsonMemoSerializer } = opt
76
+ const {
77
+ logger = consoleLogger,
78
+ cacheFactory = () => new MapAsyncMemoCache(),
79
+ cacheKeyFn = jsonMemoSerializer,
80
+ } = opt
69
81
 
70
82
  const keyStr = String(key)
71
83
  const methodSignature = _getTargetMethodSignature(target, keyStr)
@@ -35,7 +35,7 @@ export interface MemoInstance {
35
35
 
36
36
  getInstanceCache: () => Map<AnyObject, MemoCache>
37
37
 
38
- getCache: (instance: AnyFunction) => MemoCache | undefined
38
+ getCache: (instance: AnyObject) => MemoCache | undefined
39
39
  }
40
40
 
41
41
  /**
@@ -47,6 +47,11 @@ export interface MemoInstance {
47
47
  * So, error-throwing functions will be called multiple times.
48
48
  * Therefor, if the origin function can possibly throw - it should try to be idempotent.
49
49
  *
50
+ * For an async method it's the returned **Promise** that is cached, so parallel calls with the same key
51
+ * share one in-flight Promise. A rejected Promise is evicted from the cache, so the next call retries
52
+ * the method, same as for a thrown Error.
53
+ * Use `@_AsyncMemo` for an async-backed cache (e.g Redis), which stores the resolved value instead.
54
+ *
50
55
  * Cache is stored **per instance** - separate cache for separate instances of the class.
51
56
  * If you don't want it that way - you can use a static method, then there will be only one "instance".
52
57
  *
@@ -111,6 +116,19 @@ export const _Memo =
111
116
  logger.error({ err })
112
117
  }
113
118
 
119
+ if (value instanceof Promise) {
120
+ // Don't cache a rejection: evict it, so the next call retries the method.
121
+ // The derived Promise is handled, so it adds no unhandled rejection.
122
+ value.catch(() => {
123
+ try {
124
+ // Only if the key still holds this very Promise (and not a newer one, cached after an eviction)
125
+ if (cache.get(cacheKey) === value) cache.delete(cacheKey)
126
+ } catch (err) {
127
+ logger.error({ err })
128
+ }
129
+ })
130
+ }
131
+
114
132
  return value
115
133
  } as any
116
134
 
@@ -1,5 +1,4 @@
1
1
  import { _isPrimitive } from '../is.util.js'
2
- import { pDelay } from '../promise/pDelay.js'
3
2
  import type { UnixTimestamp } from '../types.js'
4
3
  import { MISS } from '../types.js'
5
4
 
@@ -29,7 +28,7 @@ export interface MemoCache<KEY = any, VALUE = any> {
29
28
  */
30
29
  get: (k: KEY) => VALUE
31
30
  set: (k: KEY, v: VALUE, opt?: MemoCacheOptions) => void
32
-
31
+ delete: (k: KEY) => void
33
32
  /**
34
33
  * Clear is only called when `_getMemoCache().clear()` is called.
35
34
  * Otherwise the Cache is "persistent" (never cleared).
@@ -47,6 +46,7 @@ export interface AsyncMemoCache<KEY = any, VALUE = any> {
47
46
  */
48
47
  get: (k: KEY) => Promise<VALUE | typeof MISS>
49
48
  set: (k: KEY, v: VALUE, opt?: MemoCacheOptions) => Promise<void>
49
+ delete: (k: KEY) => Promise<void>
50
50
 
51
51
  /**
52
52
  * Clear is only called when `_getAsyncMemo().clear()` is called.
@@ -116,6 +116,10 @@ export class MapMemoCache<KEY = any, VALUE = any> implements MemoCache<KEY, VALU
116
116
  this.m.set(k, v)
117
117
  }
118
118
 
119
+ delete(k: KEY): void {
120
+ this.m.delete(k)
121
+ }
122
+
119
123
  clear(): void {
120
124
  this.m.clear()
121
125
  }
@@ -123,27 +127,28 @@ export class MapMemoCache<KEY = any, VALUE = any> implements MemoCache<KEY, VALU
123
127
 
124
128
  /**
125
129
  * Implementation of AsyncMemoCache backed by a synchronous Map.
126
- * Doesn't have a practical use except testing,
127
- * because the point of AsyncMemoCache is to have an **async** backed cache.
130
+ * The default cache of `@_AsyncMemo`: in-memory, never expires.
131
+ * For an **async** backed cache (Redis, Datastore) use another implementation.
128
132
  */
129
133
  export class MapAsyncMemoCache<KEY = any, VALUE = any> implements AsyncMemoCache<KEY, VALUE> {
130
- constructor(private delay = 0) {}
131
-
132
134
  private m = new Map<KEY, VALUE>()
133
135
 
134
136
  async get(k: KEY): Promise<VALUE | typeof MISS> {
135
- await pDelay(this.delay)
136
- if (!this.m.has(k)) return MISS
137
- return this.m.get(k)!
137
+ const v = this.m.get(k)
138
+ // `undefined` is a valid VALUE, only then `has` tells it apart from a miss
139
+ if (v === undefined && !this.m.has(k)) return MISS
140
+ return v!
138
141
  }
139
142
 
140
143
  async set(k: KEY, v: VALUE): Promise<void> {
141
- await pDelay(this.delay)
142
144
  this.m.set(k, v)
143
145
  }
144
146
 
147
+ async delete(k: KEY): Promise<void> {
148
+ this.m.delete(k)
149
+ }
150
+
145
151
  async clear(): Promise<void> {
146
- await pDelay(this.delay)
147
152
  this.m.clear()
148
153
  }
149
154
  }
@@ -41,6 +41,19 @@ export function _memoized<FN extends AnyFunction>(
41
41
  logger.error({ err })
42
42
  }
43
43
 
44
+ if (value instanceof Promise) {
45
+ // Don't cache a rejection: evict it, so the next call retries the function.
46
+ // The derived Promise is handled, so it adds no unhandled rejection.
47
+ value.catch(() => {
48
+ try {
49
+ // Only if the key still holds this very Promise (and not a newer one, cached after an eviction)
50
+ if (cache.get(cacheKey) === value) cache.delete(cacheKey)
51
+ } catch (err) {
52
+ logger.error({ err })
53
+ }
54
+ })
55
+ }
56
+
44
57
  return value
45
58
  }
46
59
 
@@ -3,24 +3,35 @@ import type { AnyAsyncFunction, MaybeParameters } from '../types.js'
3
3
  import { MISS } from '../types.js'
4
4
  import type { AsyncMemoOptions } from './asyncMemo.decorator.js'
5
5
  import type { AsyncMemoCache } from './memo.util.js'
6
- import { jsonMemoSerializer } from './memo.util.js'
6
+ import { jsonMemoSerializer, MapAsyncMemoCache } from './memo.util.js'
7
7
 
8
8
  export interface MemoizedAsyncFunction {
9
9
  cache: AsyncMemoCache
10
10
  }
11
11
 
12
+ /**
13
+ * Function version of `@_AsyncMemo`: awaits `fn` and caches the resolved value, not the Promise,
14
+ * so a rejection is not cached. Cache errors are logged and treated as a miss.
15
+ *
16
+ * Unlike the decorator, it does NOT dedupe parallel calls with the same key.
17
+ */
12
18
  export function _memoizedAsync<FN extends AnyAsyncFunction>(
13
19
  fn: FN,
14
- opt: AsyncMemoOptions<FN>,
20
+ opt: AsyncMemoOptions<FN> = {},
15
21
  ): FN & MemoizedAsyncFunction {
16
- const { logger = consoleLogger, cacheFactory, cacheKeyFn = jsonMemoSerializer } = opt
22
+ const {
23
+ logger = consoleLogger,
24
+ cacheFactory = () => new MapAsyncMemoCache(),
25
+ cacheKeyFn = jsonMemoSerializer,
26
+ } = opt
17
27
 
18
28
  const cache = cacheFactory()
19
29
 
20
30
  const memoizedFn = async function (this: any, ...args: MaybeParameters<FN>): Promise<any> {
21
31
  const ctx = this
22
32
  const cacheKey = cacheKeyFn(args)
23
- let value: any
33
+ // MISS, so that a failed cache `get` is a miss too (and not a cached `undefined`)
34
+ let value: any = MISS
24
35
 
25
36
  try {
26
37
  value = await cache.get(cacheKey)