Skip to content
21 changes: 18 additions & 3 deletions packages/sdk/src/utils/Mapping.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { formLookupKey } from './utils'
import LRU from '../../vendor/quick-lru'
import { MarkRequired } from 'ts-essentials'

type KeyType = (string | number)[]

interface BaseOptions<K extends KeyType, V> {
valueFactory: (...args: K) => Promise<V>
isCacheableValue?: (value: V) => boolean
}

interface CacheMapOptions<K extends KeyType, V> extends BaseOptions<K, V> {
Expand All @@ -23,12 +25,20 @@ interface ValueWrapper<V> {
* A map that lazily creates values. The factory function is called only when a key
* is accessed for the first time. Subsequent calls to `get()` return the cached value
* unless it has been evicted due to `maxSize` or `maxAge` limits.
*
* It is possible to implement e.g. positive cache by using `isCacheableValue()`
* config option. If that method returns `false`, the value is not stored to cache.
* Note that using this option doesn't change the concurrent promise handling:
* also in this case all concurrent `get()` calls are grouped so that only one
* call to `valueFactory` is made. (If we wouldn't group these calls, all concurrent
* `get()` calls were cache misses, i.e. affecting significantly cases where the
* `isCacheableValue()` returns `true`.)
*/
export class Mapping<K extends KeyType, V> {

private readonly delegate: Map<string, ValueWrapper<V>>
private readonly pendingPromises: Map<string, Promise<V>> = new Map()
private readonly opts: CacheMapOptions<K, V> | LazyMapOptions<K, V>
private readonly opts: MarkRequired<CacheMapOptions<K, V> | LazyMapOptions<K, V>, 'isCacheableValue'>

/**
* Prefer constructing the class via createCacheMap() and createLazyMap()
Expand All @@ -44,7 +54,10 @@ export class Mapping<K extends KeyType, V> {
} else {
this.delegate = new Map<string, ValueWrapper<V>>()
}
this.opts = opts
this.opts = {
isCacheableValue: () => true,
...opts
}
}

async get(...args: K): Promise<V> {
Expand All @@ -64,7 +77,9 @@ export class Mapping<K extends KeyType, V> {
this.pendingPromises.delete(key)
}
valueWrapper = { value }
this.delegate.set(key, valueWrapper)
if (this.opts.isCacheableValue(value)) {
this.delegate.set(key, valueWrapper)
}
}
return valueWrapper.value
}
Expand Down
17 changes: 17 additions & 0 deletions packages/sdk/test/unit/Mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ describe('Mapping', () => {
expect(valueFactory).toHaveBeenCalledTimes(2)
})

it('isCacheableValue', async () => {
const valueFactory = jest.fn().mockImplementation(async (p1: string, p2: number) => {
return `${p1}${p2}`
})
const mapping = createLazyMap({ valueFactory, isCacheableValue: (value: string) => value === 'foo1' })
const result1 = await mapping.get('foo', 1)
const result2 = await mapping.get('foo', 1)
expect(result1).toBe('foo1')
expect(result2).toBe('foo1')
expect(valueFactory).toHaveBeenCalledTimes(1)
const result3 = await mapping.get('foo', 2)
const result4 = await mapping.get('foo', 2)
expect(result3).toBe('foo2')
expect(result4).toBe('foo2')
expect(valueFactory).toHaveBeenCalledTimes(1 + 2) // two additional calls as neither of the new calls was cached
})

it('concurrency', async () => {
const valueFactory = jest.fn().mockImplementation(async (p1: string, p2: number) => {
await wait(50)
Expand Down