@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.
- package/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +150 -160
- package/CHANGELOG.md +14 -0
- package/package.json +6 -6
- package/src/pages/[slug]/p.tsx +7 -2
- 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 +71 -51
- 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 +277 -11
package/src/server/cms/index.ts
CHANGED
|
@@ -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
|
-
|
|
88
|
-
.getCMSPage(options)
|
|
89
|
-
|
|
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>(
|
|
128
|
-
|
|
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
|
|
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,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 |
|
|
21
|
+
type ContentResult = ContentData | PageContentType
|
|
21
22
|
|
|
22
|
-
|
|
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 =
|
|
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
|
-
|
|
138
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
?
|
|
226
|
-
:
|
|
220
|
+
? this.clientCP.previewEntryById(params)
|
|
221
|
+
: this.clientCP.previewEntryBySlug(params)
|
|
227
222
|
}
|
|
228
223
|
|
|
229
224
|
return params.entryId
|
|
230
|
-
?
|
|
231
|
-
:
|
|
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
|
-
|
|
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
|
-
):
|
|
335
|
+
): PageContentType {
|
|
345
336
|
return {
|
|
346
|
-
...
|
|
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 {
|
|
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
|
+
})
|