@faststore/core 4.10.1-dev.2 → 4.10.1-dev.3
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/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +121 -119
- package/CHANGELOG.md +8 -0
- package/package.json +6 -6
- package/src/server/cms/index.ts +59 -7
- package/src/server/cms/pdp.ts +10 -5
- package/src/server/cms/plp.ts +10 -5
- package/src/server/content/promiseCache.ts +68 -0
- package/src/server/content/service.ts +45 -42
- package/test/server/cms/index.test.ts +102 -9
- package/test/server/content/promiseCache.test.ts +150 -0
- package/test/server/content/service.test.ts +237 -10
package/src/server/cms/pdp.ts
CHANGED
|
@@ -58,11 +58,16 @@ const getPDPFromCmsEnvData = async (
|
|
|
58
58
|
|
|
59
59
|
const template = findBestPDPTemplate(pages, product)
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
// The template locator comes from the build-time CMS data, never from a
|
|
62
|
+
// preview, so the fetched document can be shared across page generations.
|
|
63
|
+
return getPage<PDPContentType>(
|
|
64
|
+
{
|
|
65
|
+
contentType: 'pdp',
|
|
66
|
+
documentId: template.documentId as string,
|
|
67
|
+
versionId: template.versionId,
|
|
68
|
+
},
|
|
69
|
+
{ cache: true }
|
|
70
|
+
)
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
const getPDPFromCms = async (
|
package/src/server/cms/plp.ts
CHANGED
|
@@ -74,11 +74,16 @@ const getPLPFromCmsEnvData = async (
|
|
|
74
74
|
|
|
75
75
|
const template = findBestPLPTemplate(pages, slug, rewrites)
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
// The template locator comes from the build-time CMS data, never from a
|
|
78
|
+
// preview, so the fetched document can be shared across page generations.
|
|
79
|
+
return getPage<PLPContentType>(
|
|
80
|
+
{
|
|
81
|
+
contentType: 'plp',
|
|
82
|
+
documentId: template.documentId as string,
|
|
83
|
+
versionId: template.versionId,
|
|
84
|
+
},
|
|
85
|
+
{ cache: true }
|
|
86
|
+
)
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
export const getPLPFromCms = async (
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Bounds how stale shared CMS content can get in long-lived processes (ISR);
|
|
2
|
+
// a CMS publish already triggers a new build.
|
|
3
|
+
export const CONTENT_CACHE_TTL_MS = 5 * 60 * 1000
|
|
4
|
+
|
|
5
|
+
type PromiseCacheEntry<T> = {
|
|
6
|
+
expiresAt: number
|
|
7
|
+
promise: Promise<T>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Per-process memoization of async loaders. Concurrent callers share the
|
|
12
|
+
* in-flight promise, entries expire after `ttlMs`, and rejections are evicted
|
|
13
|
+
* so the next caller retries. When `shouldCache` rejects a resolved value, the
|
|
14
|
+
* entry is evicted too, so only the callers already waiting share that value.
|
|
15
|
+
* Expired entries are also dropped whenever a new one is stored, so keys that
|
|
16
|
+
* are never requested again (e.g. superseded document versions) do not pile up
|
|
17
|
+
* for the lifetime of the process.
|
|
18
|
+
*/
|
|
19
|
+
export function createPromiseCache<T>(ttlMs = CONTENT_CACHE_TTL_MS) {
|
|
20
|
+
const entries = new Map<string, PromiseCacheEntry<T>>()
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
get(
|
|
24
|
+
key: string,
|
|
25
|
+
load: () => Promise<T>,
|
|
26
|
+
shouldCache: (value: T) => boolean = () => true
|
|
27
|
+
): Promise<T> {
|
|
28
|
+
const cached = entries.get(key)
|
|
29
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
30
|
+
return cached.promise
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const evict = () => {
|
|
34
|
+
if (entries.get(key)?.promise === promise) {
|
|
35
|
+
entries.delete(key)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const now = Date.now()
|
|
40
|
+
entries.forEach((entry, entryKey) => {
|
|
41
|
+
if (entry.expiresAt <= now) entries.delete(entryKey)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const promise = load().then(
|
|
45
|
+
(value) => {
|
|
46
|
+
if (!shouldCache(value)) evict()
|
|
47
|
+
return value
|
|
48
|
+
},
|
|
49
|
+
(err: unknown) => {
|
|
50
|
+
evict()
|
|
51
|
+
throw err
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
entries.set(key, { expiresAt: now + ttlMs, promise })
|
|
56
|
+
|
|
57
|
+
return promise
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
clear() {
|
|
61
|
+
entries.clear()
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
get size() {
|
|
65
|
+
return entries.size
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import config from '../../../discovery.config'
|
|
15
15
|
import { getPDP, type PDPContentType } from '../cms/pdp'
|
|
16
16
|
import { getPLP, type PLPContentType } from '../cms/plp'
|
|
17
|
+
import { createPromiseCache } from './promiseCache'
|
|
17
18
|
import type { ContentOptions, ContentParams } from './types'
|
|
18
19
|
import { isBranchPreview, isContentPlatformSource } from './utils'
|
|
19
20
|
|
|
@@ -21,26 +22,21 @@ type ContentResult = ContentData | PageContentType
|
|
|
21
22
|
|
|
22
23
|
const CONTENT_AUTHOR_FIELDS = ['createdBy', 'updatedBy'] as const
|
|
23
24
|
|
|
24
|
-
type ContentListCacheEntry = {
|
|
25
|
-
expiresAt: number
|
|
26
|
-
promise: Promise<{ data: ContentResult[] }>
|
|
27
|
-
}
|
|
28
|
-
|
|
29
25
|
const OPTIONAL_CONTENT_TYPES = [
|
|
30
26
|
'globalHeaderSections',
|
|
31
27
|
'globalFooterSections',
|
|
32
28
|
] as const
|
|
33
29
|
|
|
34
|
-
// Bounds how stale the template list can get in long-lived processes (ISR);
|
|
35
|
-
// a CMS publish already triggers a new build.
|
|
36
|
-
const CONTENT_LIST_CACHE_TTL_MS = 5 * 60 * 1000
|
|
37
|
-
|
|
38
30
|
export class ContentService {
|
|
39
31
|
private readonly clientCP = new ClientCP({
|
|
40
32
|
tenant: config.api.storeId,
|
|
41
33
|
})
|
|
42
34
|
|
|
43
|
-
private readonly contentListCache =
|
|
35
|
+
private readonly contentListCache = createPromiseCache<{
|
|
36
|
+
data: ContentResult[]
|
|
37
|
+
}>()
|
|
38
|
+
|
|
39
|
+
private readonly firstEntryCache = createPromiseCache<PageContentType>()
|
|
44
40
|
|
|
45
41
|
async getSingleContent<T extends ContentData>(
|
|
46
42
|
params: ContentParams
|
|
@@ -50,7 +46,7 @@ export class ContentService {
|
|
|
50
46
|
if (isContentPlatformSource()) {
|
|
51
47
|
return this.getFromCP<T>(options)
|
|
52
48
|
}
|
|
53
|
-
return getPage(options.cmsOptions)
|
|
49
|
+
return getPage(options.cmsOptions, { cache: !options.isPreview })
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
async getMultipleContent(
|
|
@@ -136,30 +132,9 @@ export class ContentService {
|
|
|
136
132
|
private getCachedContentList(
|
|
137
133
|
serviceParams: EntryPathParams
|
|
138
134
|
): Promise<{ data: ContentResult[] }> {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const key = JSON.stringify([accountName, storeId, contentType, locale])
|
|
142
|
-
|
|
143
|
-
const cached = this.contentListCache.get(key)
|
|
144
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
145
|
-
return cached.promise
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const promise = this.listAndFillEntries(serviceParams, false).catch(
|
|
149
|
-
(err: unknown) => {
|
|
150
|
-
if (this.contentListCache.get(key)?.promise === promise) {
|
|
151
|
-
this.contentListCache.delete(key)
|
|
152
|
-
}
|
|
153
|
-
throw err
|
|
154
|
-
}
|
|
135
|
+
return this.contentListCache.get(getContentTypeKey(serviceParams), () =>
|
|
136
|
+
this.listAndFillEntries(serviceParams, false)
|
|
155
137
|
)
|
|
156
|
-
|
|
157
|
-
this.contentListCache.set(key, {
|
|
158
|
-
expiresAt: Date.now() + CONTENT_LIST_CACHE_TTL_MS,
|
|
159
|
-
promise,
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
return promise
|
|
163
138
|
}
|
|
164
139
|
|
|
165
140
|
private async listAndFillEntries(
|
|
@@ -208,9 +183,21 @@ export class ContentService {
|
|
|
208
183
|
params: EntryPathParams,
|
|
209
184
|
isPreview: boolean
|
|
210
185
|
): Promise<PageContentType> {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
186
|
+
if (params.entryId || params.slug) {
|
|
187
|
+
return this.getEntryData(params, isPreview)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (isPreview) {
|
|
191
|
+
return this.fetchFirstEntryFromList(params, true)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// An empty required type is likely content that is about to be published,
|
|
195
|
+
// so it is not kept for the whole TTL. Optional types are empty by design.
|
|
196
|
+
return this.firstEntryCache.get(
|
|
197
|
+
getContentTypeKey(params),
|
|
198
|
+
() => this.fetchFirstEntryFromList(params, false),
|
|
199
|
+
(entry) => !isEmptyEntry(entry) || isOptionalContentType(params)
|
|
200
|
+
)
|
|
214
201
|
}
|
|
215
202
|
|
|
216
203
|
private async getEntryData(
|
|
@@ -245,11 +232,7 @@ export class ContentService {
|
|
|
245
232
|
): Promise<PageContentType> {
|
|
246
233
|
const { entries } = await this.clientCP.listEntries(params)
|
|
247
234
|
if (!entries || entries.length === 0) {
|
|
248
|
-
|
|
249
|
-
params.contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
if (!isOptional) {
|
|
235
|
+
if (!isOptionalContentType(params)) {
|
|
253
236
|
console.warn('No entries found for params', params)
|
|
254
237
|
}
|
|
255
238
|
return {} as PageContentType
|
|
@@ -358,6 +341,26 @@ export class ContentService {
|
|
|
358
341
|
}
|
|
359
342
|
}
|
|
360
343
|
|
|
344
|
+
function isOptionalContentType({ contentType }: EntryPathParams): boolean {
|
|
345
|
+
return OPTIONAL_CONTENT_TYPES.includes(
|
|
346
|
+
contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function isEmptyEntry(entry: PageContentType): boolean {
|
|
351
|
+
return Object.keys(entry).length === 0
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// listEntries only depends on these fields; the page slug must stay out of the key.
|
|
355
|
+
function getContentTypeKey({
|
|
356
|
+
accountName,
|
|
357
|
+
storeId,
|
|
358
|
+
contentType,
|
|
359
|
+
locale,
|
|
360
|
+
}: EntryPathParams): string {
|
|
361
|
+
return JSON.stringify([accountName, storeId, contentType, locale])
|
|
362
|
+
}
|
|
363
|
+
|
|
361
364
|
function withoutContentAuthorMetadata<T extends object>(value: T): T {
|
|
362
365
|
const content = { ...value } as Record<string, unknown>
|
|
363
366
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { vi } from 'vitest'
|
|
2
|
-
import {
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
clientCMS,
|
|
4
|
+
cmsPageCache,
|
|
5
|
+
getCMSPage,
|
|
6
|
+
getPage,
|
|
7
|
+
} from '../../../src/server/cms'
|
|
3
8
|
import type { ContentData } from '@vtex/client-cms'
|
|
4
9
|
|
|
5
10
|
describe('CMS Integration', () => {
|
|
@@ -18,15 +23,22 @@ describe('CMS Integration', () => {
|
|
|
18
23
|
return data
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
const mockListing = (count = 1) =>
|
|
27
|
+
vi.fn((_contentType: string) =>
|
|
28
|
+
Promise.resolve({
|
|
29
|
+
data: mockData(count),
|
|
30
|
+
hasNextPage: false,
|
|
31
|
+
totalItems: count,
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
cmsPageCache.clear()
|
|
37
|
+
})
|
|
38
|
+
|
|
21
39
|
describe('getCMSPage', () => {
|
|
22
40
|
it('returns the first page if there is only one page', async () => {
|
|
23
|
-
const mockFunction =
|
|
24
|
-
return Promise.resolve({
|
|
25
|
-
data: mockData(3),
|
|
26
|
-
hasNextPage: false,
|
|
27
|
-
totalItems: 3,
|
|
28
|
-
})
|
|
29
|
-
})
|
|
41
|
+
const mockFunction = mockListing(3)
|
|
30
42
|
clientCMS.getCMSPagesByContentType = mockFunction
|
|
31
43
|
|
|
32
44
|
const result = await getCMSPage({ contentType: 'plp' }, clientCMS)
|
|
@@ -61,4 +73,85 @@ describe('CMS Integration', () => {
|
|
|
61
73
|
expect(result.data.length).toBe(15)
|
|
62
74
|
})
|
|
63
75
|
})
|
|
76
|
+
|
|
77
|
+
describe('cache', () => {
|
|
78
|
+
it('shares a content type listing across concurrent callers', async () => {
|
|
79
|
+
const mockFunction = mockListing(3)
|
|
80
|
+
clientCMS.getCMSPagesByContentType = mockFunction
|
|
81
|
+
|
|
82
|
+
await Promise.all([
|
|
83
|
+
getCMSPage({ contentType: 'plp' }),
|
|
84
|
+
getCMSPage({ contentType: 'plp' }),
|
|
85
|
+
getCMSPage({ contentType: 'plp' }),
|
|
86
|
+
])
|
|
87
|
+
await getCMSPage({ contentType: 'pdp' })
|
|
88
|
+
|
|
89
|
+
expect(
|
|
90
|
+
mockFunction.mock.calls.map(([contentType]) => contentType)
|
|
91
|
+
).toEqual(['plp', 'pdp'])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('does not cache filtered listings', async () => {
|
|
95
|
+
const mockFunction = mockListing(1)
|
|
96
|
+
clientCMS.getCMSPagesByContentType = mockFunction
|
|
97
|
+
const options = {
|
|
98
|
+
contentType: 'landingPage',
|
|
99
|
+
filters: { 'settings.seo.slug': '/summer-sale' },
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await getCMSPage(options)
|
|
103
|
+
await getCMSPage(options)
|
|
104
|
+
|
|
105
|
+
expect(mockFunction).toHaveBeenCalledTimes(2)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('fetches a locator on every call by default', async () => {
|
|
109
|
+
const mockFunction = vi.fn(() => Promise.resolve(mockData(1)[0]))
|
|
110
|
+
clientCMS.getCMSPage = mockFunction
|
|
111
|
+
const locator = {
|
|
112
|
+
contentType: 'globalSections',
|
|
113
|
+
documentId: 'doc-1',
|
|
114
|
+
versionId: 'version-1',
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await getPage(locator)
|
|
118
|
+
await getPage(locator)
|
|
119
|
+
|
|
120
|
+
expect(mockFunction).toHaveBeenCalledTimes(2)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('reuses a locator fetch when caching is enabled', async () => {
|
|
124
|
+
const mockFunction = vi.fn(() => Promise.resolve(mockData(1)[0]))
|
|
125
|
+
clientCMS.getCMSPage = mockFunction
|
|
126
|
+
const locator = {
|
|
127
|
+
contentType: 'globalSections',
|
|
128
|
+
documentId: 'doc-1',
|
|
129
|
+
versionId: 'version-1',
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const [first, second] = await Promise.all([
|
|
133
|
+
getPage(locator, { cache: true }),
|
|
134
|
+
getPage(locator, { cache: true }),
|
|
135
|
+
])
|
|
136
|
+
await getPage({ ...locator, versionId: 'version-2' }, { cache: true })
|
|
137
|
+
|
|
138
|
+
expect(first).toEqual(second)
|
|
139
|
+
expect(mockFunction).toHaveBeenCalledTimes(2)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does not cache failures', async () => {
|
|
143
|
+
const error = new Error('Request failed with status code 500')
|
|
144
|
+
const mockFunction = vi
|
|
145
|
+
.fn()
|
|
146
|
+
.mockRejectedValueOnce(error)
|
|
147
|
+
.mockResolvedValueOnce({ data: mockData(1), totalItems: 1 })
|
|
148
|
+
clientCMS.getCMSPagesByContentType = mockFunction
|
|
149
|
+
|
|
150
|
+
await expect(getCMSPage({ contentType: 'plp' })).rejects.toBe(error)
|
|
151
|
+
const result = await getCMSPage({ contentType: 'plp' })
|
|
152
|
+
|
|
153
|
+
expect(result.data).toHaveLength(1)
|
|
154
|
+
expect(mockFunction).toHaveBeenCalledTimes(2)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
64
157
|
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { createPromiseCache } from '../../../src/server/content/promiseCache'
|
|
4
|
+
|
|
5
|
+
describe('createPromiseCache', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.useRealTimers()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('shares one in-flight load across concurrent callers', async () => {
|
|
11
|
+
const cache = createPromiseCache<string>(1000)
|
|
12
|
+
const load = vi.fn(async () => 'value')
|
|
13
|
+
|
|
14
|
+
const results = await Promise.all([
|
|
15
|
+
cache.get('key', load),
|
|
16
|
+
cache.get('key', load),
|
|
17
|
+
cache.get('key', load),
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
expect(load).toHaveBeenCalledTimes(1)
|
|
21
|
+
expect(results).toEqual(['value', 'value', 'value'])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('keeps separate entries per key', async () => {
|
|
25
|
+
const cache = createPromiseCache<string>(1000)
|
|
26
|
+
const load = vi.fn(async () => 'value')
|
|
27
|
+
|
|
28
|
+
await cache.get('a', load)
|
|
29
|
+
await cache.get('b', load)
|
|
30
|
+
|
|
31
|
+
expect(load).toHaveBeenCalledTimes(2)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('loads again after the TTL', async () => {
|
|
35
|
+
vi.useFakeTimers()
|
|
36
|
+
const cache = createPromiseCache<string>(1000)
|
|
37
|
+
const load = vi.fn(async () => 'value')
|
|
38
|
+
|
|
39
|
+
await cache.get('key', load)
|
|
40
|
+
vi.advanceTimersByTime(999)
|
|
41
|
+
await cache.get('key', load)
|
|
42
|
+
expect(load).toHaveBeenCalledTimes(1)
|
|
43
|
+
|
|
44
|
+
vi.advanceTimersByTime(1)
|
|
45
|
+
await cache.get('key', load)
|
|
46
|
+
expect(load).toHaveBeenCalledTimes(2)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not cache failures', async () => {
|
|
50
|
+
const cache = createPromiseCache<string>(1000)
|
|
51
|
+
const error = new Error('boom')
|
|
52
|
+
const load = vi
|
|
53
|
+
.fn<() => Promise<string>>()
|
|
54
|
+
.mockRejectedValueOnce(error)
|
|
55
|
+
.mockResolvedValueOnce('value')
|
|
56
|
+
|
|
57
|
+
await expect(cache.get('key', load)).rejects.toBe(error)
|
|
58
|
+
await expect(cache.get('key', load)).resolves.toBe('value')
|
|
59
|
+
expect(load).toHaveBeenCalledTimes(2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not let a late failure evict a newer load', async () => {
|
|
63
|
+
vi.useFakeTimers()
|
|
64
|
+
const cache = createPromiseCache<string>(1000)
|
|
65
|
+
let rejectFirst: (err: Error) => void = () => {}
|
|
66
|
+
const load = vi
|
|
67
|
+
.fn<() => Promise<string>>()
|
|
68
|
+
.mockImplementationOnce(
|
|
69
|
+
() =>
|
|
70
|
+
new Promise((_, reject) => {
|
|
71
|
+
rejectFirst = reject
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
.mockResolvedValue('value')
|
|
75
|
+
|
|
76
|
+
const first = cache.get('key', load)
|
|
77
|
+
const firstResult = expect(first).rejects.toThrow('late failure')
|
|
78
|
+
vi.advanceTimersByTime(1000)
|
|
79
|
+
await cache.get('key', load)
|
|
80
|
+
|
|
81
|
+
rejectFirst(new Error('late failure'))
|
|
82
|
+
await firstResult
|
|
83
|
+
await cache.get('key', load)
|
|
84
|
+
|
|
85
|
+
expect(load).toHaveBeenCalledTimes(2)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not keep values rejected by shouldCache', async () => {
|
|
89
|
+
const cache = createPromiseCache<string>(1000)
|
|
90
|
+
const load = vi.fn(async () => '')
|
|
91
|
+
const shouldCache = (value: string) => value !== ''
|
|
92
|
+
|
|
93
|
+
const concurrent = await Promise.all([
|
|
94
|
+
cache.get('key', load, shouldCache),
|
|
95
|
+
cache.get('key', load, shouldCache),
|
|
96
|
+
])
|
|
97
|
+
await cache.get('key', load, shouldCache)
|
|
98
|
+
|
|
99
|
+
expect(concurrent).toEqual(['', ''])
|
|
100
|
+
expect(load).toHaveBeenCalledTimes(2)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('keeps values accepted by shouldCache', async () => {
|
|
104
|
+
const cache = createPromiseCache<string>(1000)
|
|
105
|
+
const load = vi.fn(async () => 'value')
|
|
106
|
+
|
|
107
|
+
await cache.get('key', load, (value) => value !== '')
|
|
108
|
+
await cache.get('key', load, (value) => value !== '')
|
|
109
|
+
|
|
110
|
+
expect(load).toHaveBeenCalledTimes(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('drops expired entries when a new key is stored', async () => {
|
|
114
|
+
vi.useFakeTimers()
|
|
115
|
+
const cache = createPromiseCache<string>(1000)
|
|
116
|
+
const load = vi.fn(async () => 'value')
|
|
117
|
+
|
|
118
|
+
await cache.get('old-version', load)
|
|
119
|
+
await cache.get('other', load)
|
|
120
|
+
expect(cache.size).toBe(2)
|
|
121
|
+
|
|
122
|
+
vi.advanceTimersByTime(1000)
|
|
123
|
+
await cache.get('new-version', load)
|
|
124
|
+
|
|
125
|
+
expect(cache.size).toBe(1)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps entries that have not expired when a new key is stored', async () => {
|
|
129
|
+
vi.useFakeTimers()
|
|
130
|
+
const cache = createPromiseCache<string>(1000)
|
|
131
|
+
const load = vi.fn(async () => 'value')
|
|
132
|
+
|
|
133
|
+
await cache.get('a', load)
|
|
134
|
+
vi.advanceTimersByTime(999)
|
|
135
|
+
await cache.get('b', load)
|
|
136
|
+
|
|
137
|
+
expect(cache.size).toBe(2)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('drops every entry on clear', async () => {
|
|
141
|
+
const cache = createPromiseCache<string>(1000)
|
|
142
|
+
const load = vi.fn(async () => 'value')
|
|
143
|
+
|
|
144
|
+
await cache.get('key', load)
|
|
145
|
+
cache.clear()
|
|
146
|
+
await cache.get('key', load)
|
|
147
|
+
|
|
148
|
+
expect(load).toHaveBeenCalledTimes(2)
|
|
149
|
+
})
|
|
150
|
+
})
|