@faststore/core 4.10.1-dev.1 → 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.
@@ -3,6 +3,7 @@ import ClientCMS from '@vtex/client-cms'
3
3
 
4
4
  import MultipleContentError from 'src/sdk/error/MultipleContentError'
5
5
  import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
6
+ import { createPromiseCache } from 'src/server/content/promiseCache'
6
7
  import { sanitizeHost } from 'src/utils/utilities'
7
8
  import config from '../../../discovery.config'
8
9
 
@@ -79,16 +80,64 @@ export const clientCMS = new ClientCMS({
79
80
  host: sanitizeHost(new URL(getStoreURL()).origin),
80
81
  })
81
82
 
83
+ type CMSPageResult = { data: ContentData[] }
84
+
85
+ export const cmsPageCache = createPromiseCache<CMSPageResult>()
86
+
87
+ type GetCMSPageOptions = {
88
+ /**
89
+ * Reuse a document fetched by locator. Only safe when the locator does not
90
+ * come from a preview, since preview versions change between requests.
91
+ */
92
+ cache?: boolean
93
+ }
94
+
95
+ /**
96
+ * Listings by content type never carry preview data (previews always resolve
97
+ * to a locator), so they are shared across pages. Filtered listings depend on
98
+ * the page (e.g. landing page slug) and stay uncached.
99
+ */
82
100
  export const getCMSPage = async (
83
101
  options: Options,
84
- cmsClient: ClientCMS = clientCMS
85
- ) => {
102
+ cmsClient: ClientCMS = clientCMS,
103
+ { cache = false }: GetCMSPageOptions = {}
104
+ ): Promise<CMSPageResult> => {
105
+ const shareable = cmsClient === clientCMS
106
+
86
107
  if (isLocator(options)) {
87
- return await cmsClient
88
- .getCMSPage(options)
89
- .then((page) => ({ data: [page] }))
108
+ const fetchByLocator = () =>
109
+ cmsClient.getCMSPage(options).then((page) => ({ data: [page] }))
110
+
111
+ if (!cache || !shareable) {
112
+ return fetchByLocator()
113
+ }
114
+
115
+ const { contentType, documentId, versionId, releaseId } = options
116
+ return cmsPageCache.get(
117
+ JSON.stringify([
118
+ 'locator',
119
+ contentType,
120
+ documentId,
121
+ versionId,
122
+ releaseId,
123
+ ]),
124
+ fetchByLocator
125
+ )
126
+ }
127
+
128
+ if (options.filters || !shareable) {
129
+ return listCMSPages(options, cmsClient)
90
130
  }
91
131
 
132
+ return cmsPageCache.get(JSON.stringify(['list', options.contentType]), () =>
133
+ listCMSPages(options, cmsClient)
134
+ )
135
+ }
136
+
137
+ const listCMSPages = async (
138
+ options: Exclude<Options, Locator>,
139
+ cmsClient: ClientCMS
140
+ ): Promise<CMSPageResult> => {
92
141
  const pages = []
93
142
  const page = 1
94
143
  const perPage = 10
@@ -124,8 +173,11 @@ export const getCMSPage = async (
124
173
  return { data: pages }
125
174
  }
126
175
 
127
- export const getPage = async <T extends ContentData>(options: Options) => {
128
- const result = await getCMSPage(options)
176
+ export const getPage = async <T extends ContentData>(
177
+ options: Options,
178
+ getOptions?: GetCMSPageOptions
179
+ ) => {
180
+ const result = await getCMSPage(options, clientCMS, getOptions)
129
181
 
130
182
  const pages = result.data
131
183
 
@@ -58,11 +58,16 @@ const getPDPFromCmsEnvData = async (
58
58
 
59
59
  const template = findBestPDPTemplate(pages, product)
60
60
 
61
- return getPage<PDPContentType>({
62
- contentType: 'pdp',
63
- documentId: template.documentId as string,
64
- versionId: template.versionId,
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 (
@@ -74,11 +74,16 @@ const getPLPFromCmsEnvData = async (
74
74
 
75
75
  const template = findBestPLPTemplate(pages, slug, rewrites)
76
76
 
77
- return getPage<PLPContentType>({
78
- contentType: 'plp',
79
- documentId: template.documentId as string,
80
- versionId: template.versionId,
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,31 +14,29 @@ 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
 
20
- type ContentResult = ContentData | (ContentEntry & PageContentType)
21
+ type ContentResult = ContentData | PageContentType
21
22
 
22
- type ContentListCacheEntry = {
23
- expiresAt: number
24
- promise: Promise<{ data: ContentResult[] }>
25
- }
23
+ const CONTENT_AUTHOR_FIELDS = ['createdBy', 'updatedBy'] as const
26
24
 
27
25
  const OPTIONAL_CONTENT_TYPES = [
28
26
  'globalHeaderSections',
29
27
  'globalFooterSections',
30
28
  ] as const
31
29
 
32
- // Bounds how stale the template list can get in long-lived processes (ISR);
33
- // a CMS publish already triggers a new build.
34
- const CONTENT_LIST_CACHE_TTL_MS = 5 * 60 * 1000
35
-
36
30
  export class ContentService {
37
31
  private readonly clientCP = new ClientCP({
38
32
  tenant: config.api.storeId,
39
33
  })
40
34
 
41
- private readonly contentListCache = new Map<string, ContentListCacheEntry>()
35
+ private readonly contentListCache = createPromiseCache<{
36
+ data: ContentResult[]
37
+ }>()
38
+
39
+ private readonly firstEntryCache = createPromiseCache<PageContentType>()
42
40
 
43
41
  async getSingleContent<T extends ContentData>(
44
42
  params: ContentParams
@@ -48,7 +46,7 @@ export class ContentService {
48
46
  if (isContentPlatformSource()) {
49
47
  return this.getFromCP<T>(options)
50
48
  }
51
- return getPage(options.cmsOptions)
49
+ return getPage(options.cmsOptions, { cache: !options.isPreview })
52
50
  }
53
51
 
54
52
  async getMultipleContent(
@@ -134,36 +132,15 @@ export class ContentService {
134
132
  private getCachedContentList(
135
133
  serviceParams: EntryPathParams
136
134
  ): Promise<{ data: ContentResult[] }> {
137
- // listEntries only depends on these fields; the page slug must stay out of the key.
138
- const { accountName, storeId, contentType, locale } = serviceParams
139
- const key = JSON.stringify([accountName, storeId, contentType, locale])
140
-
141
- const cached = this.contentListCache.get(key)
142
- if (cached && cached.expiresAt > Date.now()) {
143
- return cached.promise
144
- }
145
-
146
- const promise = this.listAndFillEntries(serviceParams, false).catch(
147
- (err: unknown) => {
148
- if (this.contentListCache.get(key)?.promise === promise) {
149
- this.contentListCache.delete(key)
150
- }
151
- throw err
152
- }
135
+ return this.contentListCache.get(getContentTypeKey(serviceParams), () =>
136
+ this.listAndFillEntries(serviceParams, false)
153
137
  )
154
-
155
- this.contentListCache.set(key, {
156
- expiresAt: Date.now() + CONTENT_LIST_CACHE_TTL_MS,
157
- promise,
158
- })
159
-
160
- return promise
161
138
  }
162
139
 
163
140
  private async listAndFillEntries(
164
141
  serviceParams: EntryPathParams,
165
142
  isPreview: boolean
166
- ): Promise<{ data: (ContentEntry & PageContentType)[] }> {
143
+ ): Promise<{ data: PageContentType[] }> {
167
144
  // TODO: This should be temporary until the cp data-plane have a search engine.
168
145
  const MAX_PAGES = 25
169
146
  const allEntries: ContentEntry[] = []
@@ -189,7 +166,7 @@ export class ContentService {
189
166
  entries: ContentEntry[],
190
167
  serviceParams: EntryPathParams,
191
168
  isPreview: boolean
192
- ): Promise<{ data: (ContentEntry & PageContentType)[] }> {
169
+ ): Promise<{ data: PageContentType[] }> {
193
170
  const data = await Promise.all(
194
171
  entries.map(async (entry) => {
195
172
  const entryData = await this.getEntryData(
@@ -206,9 +183,21 @@ export class ContentService {
206
183
  params: EntryPathParams,
207
184
  isPreview: boolean
208
185
  ): Promise<PageContentType> {
209
- return params.entryId || params.slug
210
- ? await this.getEntryData(params, isPreview)
211
- : await this.fetchFirstEntryFromList(params, isPreview)
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
+ )
212
201
  }
213
202
 
214
203
  private async getEntryData(
@@ -220,15 +209,21 @@ export class ContentService {
220
209
  throw new Error(`${operation} requires entryId or slug`)
221
210
  }
222
211
 
212
+ const entry = await this.loadEntry(params, isPreview)
213
+
214
+ return withoutContentAuthorMetadata(entry as PageContentType)
215
+ }
216
+
217
+ private loadEntry(params: EntryPathParams, isPreview: boolean) {
223
218
  if (isPreview) {
224
219
  return params.entryId
225
- ? (this.clientCP.previewEntryById(params) as Promise<PageContentType>)
226
- : (this.clientCP.previewEntryBySlug(params) as Promise<PageContentType>)
220
+ ? this.clientCP.previewEntryById(params)
221
+ : this.clientCP.previewEntryBySlug(params)
227
222
  }
228
223
 
229
224
  return params.entryId
230
- ? (this.clientCP.getEntry(params) as Promise<PageContentType>)
231
- : (this.clientCP.getEntryBySlug(params) as Promise<PageContentType>)
225
+ ? this.clientCP.getEntry(params)
226
+ : this.clientCP.getEntryBySlug(params)
232
227
  }
233
228
 
234
229
  private async fetchFirstEntryFromList(
@@ -237,11 +232,7 @@ export class ContentService {
237
232
  ): Promise<PageContentType> {
238
233
  const { entries } = await this.clientCP.listEntries(params)
239
234
  if (!entries || entries.length === 0) {
240
- const isOptional = OPTIONAL_CONTENT_TYPES.includes(
241
- params.contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
242
- )
243
-
244
- if (!isOptional) {
235
+ if (!isOptionalContentType(params)) {
245
236
  console.warn('No entries found for params', params)
246
237
  }
247
238
  return {} as PageContentType
@@ -341,16 +332,45 @@ export class ContentService {
341
332
  private mergeEntryWithData(
342
333
  entry: ContentEntry,
343
334
  data: PageContentType
344
- ): ContentEntry & PageContentType {
335
+ ): PageContentType {
345
336
  return {
346
- ...entry,
347
- ...data,
337
+ ...withoutContentAuthorMetadata(data),
348
338
  id: entry.id,
349
339
  name: entry.name ?? '',
350
340
  }
351
341
  }
352
342
  }
353
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
+
364
+ function withoutContentAuthorMetadata<T extends object>(value: T): T {
365
+ const content = { ...value } as Record<string, unknown>
366
+
367
+ for (const field of CONTENT_AUTHOR_FIELDS) {
368
+ delete content[field]
369
+ }
370
+
371
+ return content as T
372
+ }
373
+
354
374
  function isNotFoundError(err: unknown): boolean {
355
375
  if (err instanceof MissingContentError) return true
356
376
  if (err instanceof Error && /\b404\b/.test(err.message)) return true
@@ -1,5 +1,10 @@
1
- import { vi } from 'vitest'
2
- import { clientCMS, getCMSPage } from '../../../src/server/cms'
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 = vi.fn(() => {
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
+ })