@teleporthq/teleport-plugin-next-data-source 0.43.55 → 0.43.59

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.
Files changed (110) hide show
  1. package/__tests__/array-mapper-cache-wiring.test.ts +425 -0
  2. package/__tests__/data-source-cache-wrapping.test.ts +152 -0
  3. package/__tests__/ecommerce-product-allow-backorders.test.ts +156 -0
  4. package/__tests__/ecommerce-product-model.test.ts +179 -0
  5. package/__tests__/ecommerce-product-storefront-tax.test.ts +118 -0
  6. package/dist/cjs/cache/ast.d.ts +71 -0
  7. package/dist/cjs/cache/ast.d.ts.map +1 -0
  8. package/dist/cjs/cache/ast.js +229 -0
  9. package/dist/cjs/cache/ast.js.map +1 -0
  10. package/dist/cjs/cache/config.d.ts +22 -0
  11. package/dist/cjs/cache/config.d.ts.map +1 -0
  12. package/dist/cjs/cache/config.js +67 -0
  13. package/dist/cjs/cache/config.js.map +1 -0
  14. package/dist/cjs/cache/types.d.ts +31 -0
  15. package/dist/cjs/cache/types.d.ts.map +1 -0
  16. package/dist/cjs/cache/types.js +3 -0
  17. package/dist/cjs/cache/types.js.map +1 -0
  18. package/dist/cjs/data-source-fetchers.d.ts +5 -3
  19. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  20. package/dist/cjs/data-source-fetchers.js +51 -8
  21. package/dist/cjs/data-source-fetchers.js.map +1 -1
  22. package/dist/cjs/fetchers/teleport.d.ts +2 -2
  23. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  24. package/dist/cjs/fetchers/teleport.js +3 -2
  25. package/dist/cjs/fetchers/teleport.js.map +1 -1
  26. package/dist/cjs/index.d.ts +2 -0
  27. package/dist/cjs/index.d.ts.map +1 -1
  28. package/dist/cjs/index.js +14 -9
  29. package/dist/cjs/index.js.map +1 -1
  30. package/dist/cjs/loading-state.d.ts.map +1 -1
  31. package/dist/cjs/loading-state.js +74 -2
  32. package/dist/cjs/loading-state.js.map +1 -1
  33. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  34. package/dist/cjs/pagination-plugin.js +204 -64
  35. package/dist/cjs/pagination-plugin.js.map +1 -1
  36. package/dist/cjs/transformations/ecommerce-product.d.ts +27 -1
  37. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  38. package/dist/cjs/transformations/ecommerce-product.js +8 -3
  39. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  40. package/dist/cjs/transformations/index.d.ts +14 -2
  41. package/dist/cjs/transformations/index.d.ts.map +1 -1
  42. package/dist/cjs/transformations/index.js +37 -3
  43. package/dist/cjs/transformations/index.js.map +1 -1
  44. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  45. package/dist/cjs/transformations/shared-utils.js +1 -1
  46. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  47. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  48. package/dist/cjs/utils.d.ts +4 -3
  49. package/dist/cjs/utils.d.ts.map +1 -1
  50. package/dist/cjs/utils.js +6 -4
  51. package/dist/cjs/utils.js.map +1 -1
  52. package/dist/esm/cache/ast.d.ts +71 -0
  53. package/dist/esm/cache/ast.d.ts.map +1 -0
  54. package/dist/esm/cache/ast.js +196 -0
  55. package/dist/esm/cache/ast.js.map +1 -0
  56. package/dist/esm/cache/config.d.ts +22 -0
  57. package/dist/esm/cache/config.d.ts.map +1 -0
  58. package/dist/esm/cache/config.js +62 -0
  59. package/dist/esm/cache/config.js.map +1 -0
  60. package/dist/esm/cache/types.d.ts +31 -0
  61. package/dist/esm/cache/types.d.ts.map +1 -0
  62. package/dist/esm/cache/types.js +2 -0
  63. package/dist/esm/cache/types.js.map +1 -0
  64. package/dist/esm/data-source-fetchers.d.ts +5 -3
  65. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  66. package/dist/esm/data-source-fetchers.js +51 -8
  67. package/dist/esm/data-source-fetchers.js.map +1 -1
  68. package/dist/esm/fetchers/teleport.d.ts +2 -2
  69. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  70. package/dist/esm/fetchers/teleport.js +3 -2
  71. package/dist/esm/fetchers/teleport.js.map +1 -1
  72. package/dist/esm/index.d.ts +2 -0
  73. package/dist/esm/index.d.ts.map +1 -1
  74. package/dist/esm/index.js +12 -8
  75. package/dist/esm/index.js.map +1 -1
  76. package/dist/esm/loading-state.d.ts.map +1 -1
  77. package/dist/esm/loading-state.js +74 -2
  78. package/dist/esm/loading-state.js.map +1 -1
  79. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  80. package/dist/esm/pagination-plugin.js +205 -65
  81. package/dist/esm/pagination-plugin.js.map +1 -1
  82. package/dist/esm/transformations/ecommerce-product.d.ts +27 -1
  83. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  84. package/dist/esm/transformations/ecommerce-product.js +8 -3
  85. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  86. package/dist/esm/transformations/index.d.ts +14 -2
  87. package/dist/esm/transformations/index.d.ts.map +1 -1
  88. package/dist/esm/transformations/index.js +36 -3
  89. package/dist/esm/transformations/index.js.map +1 -1
  90. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  91. package/dist/esm/transformations/shared-utils.js +1 -1
  92. package/dist/esm/transformations/shared-utils.js.map +1 -1
  93. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  94. package/dist/esm/utils.d.ts +4 -3
  95. package/dist/esm/utils.d.ts.map +1 -1
  96. package/dist/esm/utils.js +6 -4
  97. package/dist/esm/utils.js.map +1 -1
  98. package/package.json +5 -5
  99. package/src/cache/ast.ts +265 -0
  100. package/src/cache/config.ts +79 -0
  101. package/src/cache/types.ts +31 -0
  102. package/src/data-source-fetchers.ts +66 -12
  103. package/src/fetchers/teleport.ts +3 -3
  104. package/src/index.ts +10 -4
  105. package/src/loading-state.ts +65 -2
  106. package/src/pagination-plugin.ts +315 -116
  107. package/src/transformations/ecommerce-product.ts +161 -5
  108. package/src/transformations/index.ts +43 -4
  109. package/src/transformations/shared-utils.ts +14 -0
  110. package/src/utils.ts +10 -5
@@ -0,0 +1,425 @@
1
+ import * as types from '@babel/types'
2
+ import generator from '@babel/generator'
3
+ import { parse } from '@babel/parser'
4
+ import { ASTStatementOrder } from '@teleporthq/teleport-shared'
5
+ import {
6
+ ChunkType,
7
+ FileType,
8
+ ComponentStructure,
9
+ ChunkDefinition,
10
+ UIDLDataCacheConfig,
11
+ UIDLDependency,
12
+ } from '@teleporthq/teleport-types'
13
+ import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
14
+
15
+ type Category = 'paginated+search' | 'paginated-only' | 'search-only' | 'plain'
16
+
17
+ const CACHE: UIDLDataCacheConfig = {
18
+ enabled: true,
19
+ ttlSeconds: 60,
20
+ client: true,
21
+ server: true,
22
+ versionScope: 'ds1:products',
23
+ }
24
+
25
+ const makeDataProviderJSX = (): types.JSXElement => {
26
+ const repeater = types.jsxElement(
27
+ types.jsxOpeningElement(
28
+ types.jsxIdentifier('Repeater'),
29
+ [
30
+ types.jsxAttribute(
31
+ types.jsxIdentifier('renderItem'),
32
+ types.jsxExpressionContainer(
33
+ types.arrowFunctionExpression(
34
+ [types.identifier('product')],
35
+ types.jsxElement(
36
+ types.jsxOpeningElement(types.jsxIdentifier('div'), [], true),
37
+ null,
38
+ [],
39
+ true
40
+ )
41
+ )
42
+ )
43
+ ),
44
+ ],
45
+ true
46
+ ),
47
+ null,
48
+ [],
49
+ true
50
+ )
51
+
52
+ return types.jsxElement(
53
+ types.jsxOpeningElement(
54
+ types.jsxIdentifier('DataProvider'),
55
+ [
56
+ types.jsxAttribute(
57
+ types.jsxIdentifier('name'),
58
+ types.jsxExpressionContainer(types.stringLiteral('items'))
59
+ ),
60
+ types.jsxAttribute(
61
+ types.jsxIdentifier('renderSuccess'),
62
+ types.jsxExpressionContainer(
63
+ types.arrowFunctionExpression([types.identifier('items')], repeater)
64
+ )
65
+ ),
66
+ types.jsxAttribute(
67
+ types.jsxIdentifier('renderLoading'),
68
+ types.jsxExpressionContainer(
69
+ types.arrowFunctionExpression(
70
+ [],
71
+ types.jsxElement(
72
+ types.jsxOpeningElement(types.jsxIdentifier('p'), [], true),
73
+ null,
74
+ [],
75
+ true
76
+ )
77
+ )
78
+ )
79
+ ),
80
+ ],
81
+ true
82
+ ),
83
+ null,
84
+ [],
85
+ true
86
+ )
87
+ }
88
+
89
+ const makeComponentChunk = (dataProvider: types.JSXElement): ChunkDefinition => ({
90
+ name: 'jsx-component',
91
+ type: ChunkType.AST,
92
+ fileType: FileType.JS,
93
+ linkAfter: [],
94
+ content: types.variableDeclaration('const', [
95
+ types.variableDeclarator(
96
+ types.identifier('ProductsList'),
97
+ types.arrowFunctionExpression(
98
+ [types.identifier('props')],
99
+ types.blockStatement([types.returnStatement(dataProvider)])
100
+ )
101
+ ),
102
+ ]),
103
+ meta: {},
104
+ })
105
+
106
+ // tslint:disable-next-line:no-any
107
+ const makeUidlNode = (
108
+ category: Category,
109
+ cache?: UIDLDataCacheConfig,
110
+ dynamicSort = false
111
+ ): any => ({
112
+ type: 'data-source-list',
113
+ content: {
114
+ renderPropIdentifier: 'items',
115
+ resourceDefinition: {
116
+ dataSourceId: 'ds1',
117
+ tableName: 'products',
118
+ dataSourceType: 'postgresql',
119
+ },
120
+ resource: { params: { queryColumns: { content: ['name'] } } },
121
+ nodes: {
122
+ success: {
123
+ type: 'cms-list-repeater',
124
+ content: {
125
+ renderPropIdentifier: 'product',
126
+ paginated: category === 'paginated+search' || category === 'paginated-only',
127
+ perPage: 20,
128
+ searchEnabled: category === 'paginated+search' || category === 'search-only',
129
+ searchDebounce: 300,
130
+ nodes: { list: { type: 'element', content: { elementType: 'div' } } },
131
+ ...(cache ? { cache } : {}),
132
+ ...(dynamicSort
133
+ ? {
134
+ sort: { type: 'expr', content: 'sortBy.split("-")[0]' },
135
+ sortDirection: { type: 'expr', content: 'sortBy.split("-")[1]' },
136
+ }
137
+ : {}),
138
+ },
139
+ },
140
+ },
141
+ },
142
+ })
143
+
144
+ const runPlugin = async (options: {
145
+ category?: Category
146
+ cache?: UIDLDataCacheConfig
147
+ dynamicSort?: boolean
148
+ }) => {
149
+ const chunk = makeComponentChunk(makeDataProviderJSX())
150
+ const dependencies: Record<string, UIDLDependency> = {}
151
+ // tslint:disable-next-line:no-any
152
+ const extractedResources: Record<string, any> = {}
153
+ const structure: ComponentStructure = {
154
+ uidl: {
155
+ name: 'ProductsList',
156
+ node: makeUidlNode(
157
+ options.category || 'paginated+search',
158
+ options.cache,
159
+ options.dynamicSort
160
+ ),
161
+ },
162
+ chunks: [chunk],
163
+ dependencies,
164
+ options: {
165
+ dataSources: {
166
+ ds1: {
167
+ id: 'ds1',
168
+ name: 'Store',
169
+ type: 'postgresql',
170
+ config: { host: 'h', database: 'd', user: 'u', password: 'p' },
171
+ },
172
+ },
173
+ extractedResources,
174
+ },
175
+ } as never
176
+
177
+ await createNextArrayMapperPaginationPlugin()(structure)
178
+ return { code: generator(chunk.content as types.Node).code, dependencies, extractedResources }
179
+ }
180
+
181
+ const CATEGORIES: Category[] = ['paginated+search', 'paginated-only', 'search-only', 'plain']
182
+
183
+ describe('array-mapper cache — client wiring', () => {
184
+ /**
185
+ * The regression pin that matters most: a project that never asked for
186
+ * caching has to generate exactly what it generated before.
187
+ */
188
+ describe.each(CATEGORIES)('with caching OFF (%s)', (category) => {
189
+ it('emits no cache code at all', async () => {
190
+ const { code, dependencies } = await runPlugin({ category })
191
+
192
+ expect(code).not.toContain('tqCache')
193
+ expect(code).not.toContain('ds_0_cached')
194
+ expect(code).not.toContain('ds_0_params')
195
+ expect(Object.keys(dependencies)).not.toContain('tqCacheGet')
196
+ })
197
+ })
198
+
199
+ describe.each(CATEGORIES)('with caching ON (%s)', (category) => {
200
+ it('checks the cache before touching the network', async () => {
201
+ const { code } = await runPlugin({ category, cache: CACHE })
202
+
203
+ expect(code).toContain('const __tqKey = tqCacheKey(params)')
204
+ expect(code).toContain('const __tqHit = tqCacheGet("ds1:products", __tqKey)')
205
+ expect(code).toContain('return Promise.resolve(__tqHit)')
206
+ })
207
+
208
+ it('stores the rows and the version the server answered with', async () => {
209
+ const { code } = await runPlugin({ category, cache: CACHE })
210
+
211
+ expect(code).toContain('tqCacheSetVersion("ds1:products", response?.version)')
212
+ expect(code).toContain(
213
+ 'return tqCacheSet("ds1:products", __tqKey, response?.data, 60, response?.version)'
214
+ )
215
+ })
216
+
217
+ it('registers the runtime import once', async () => {
218
+ const { dependencies } = await runPlugin({ category, cache: CACHE })
219
+
220
+ expect(dependencies.tqCacheGet).toMatchObject({
221
+ type: 'local',
222
+ path: '../utils/tq-cache/client',
223
+ meta: { namedImport: true },
224
+ })
225
+ })
226
+
227
+ it('flips the hydration latch and revalidates once per page', async () => {
228
+ const { code } = await runPlugin({ category, cache: CACHE })
229
+
230
+ expect(code).toContain('tqMarkHydrated()')
231
+ expect(code).toContain('tqCacheRevalidate(["ds1:products"])')
232
+ expect(code.match(/tqCacheRevalidate/g)).toHaveLength(1)
233
+ })
234
+
235
+ /**
236
+ * The two ordering defects the /blog page shipped with. Source-text
237
+ * `toContain` assertions cannot see either of them, so they are pinned
238
+ * structurally: parse the emitted component and prove it executes.
239
+ */
240
+ it('emits the hydration effect BEFORE the return — an appended hook never runs', async () => {
241
+ const { code } = await runPlugin({ category, cache: CACHE })
242
+
243
+ const file = parse(code, { sourceType: 'module', plugins: ['jsx'] })
244
+ const declaration = file.program.body.find(
245
+ (statement) => statement.type === 'VariableDeclaration'
246
+ ) as types.VariableDeclaration
247
+ const body = (declaration.declarations[0].init as types.ArrowFunctionExpression)
248
+ .body as types.BlockStatement
249
+
250
+ const returnIndex = body.body.findIndex((statement) => statement.type === 'ReturnStatement')
251
+ const hydrationIndex = body.body.findIndex((statement) =>
252
+ generator(statement).code.includes('tqMarkHydrated')
253
+ )
254
+
255
+ expect(hydrationIndex).toBeGreaterThan(-1)
256
+ expect(hydrationIndex).toBeLessThan(returnIndex)
257
+ // Nothing may trail the component's return — trailing statements are
258
+ // unreachable and silently disable whole features.
259
+ expect(returnIndex).toBe(body.body.length - 1)
260
+ })
261
+
262
+ it('emits statements in an executable order (no TDZ, nothing unreachable)', async () => {
263
+ const { code } = await runPlugin({ category, cache: CACHE })
264
+
265
+ const ast = parse(code, { sourceType: 'module', plugins: ['jsx'] })
266
+ // Zero repairs needed: the plugin placed everything correctly on its
267
+ // own, without leaning on the emit-time safety net.
268
+ expect(ASTStatementOrder.normalizeStatementOrder(ast)).toBe(0)
269
+ })
270
+ })
271
+
272
+ /**
273
+ * The flash this feature exists to remove. A cache HIT must return before the
274
+ * in-flight flag is raised, or `persistDataDuringLoading={!ds_0_isFetching}`
275
+ * drops the provider into its loading slot for a frame.
276
+ */
277
+ it('never raises the in-flight flag on a cache hit', async () => {
278
+ const { code } = await runPlugin({ cache: CACHE })
279
+
280
+ const hitReturn = code.indexOf('return Promise.resolve(__tqHit)')
281
+ const raise = code.indexOf('setDs_0_isFetching(true)')
282
+
283
+ expect(hitReturn).toBeGreaterThan(-1)
284
+ expect(raise).toBeGreaterThan(-1)
285
+ expect(hitReturn).toBeLessThan(raise)
286
+ })
287
+
288
+ it('still tracks in-flight fetches on a cache MISS', async () => {
289
+ const { code } = await runPlugin({ cache: CACHE })
290
+
291
+ expect(code).toContain('ds_0_fetchesInFlight.current += 1')
292
+ expect(code).toContain('setDs_0_isFetching(true)')
293
+ expect(code).toContain('persistDataDuringLoading={!ds_0_isFetching}')
294
+ // The settle handler belongs to the network chain, not to the early return.
295
+ expect(code).toContain('.finally(')
296
+ })
297
+
298
+ it('feeds a cache hit into initialData so a remount paints instantly', async () => {
299
+ const { code } = await runPlugin({ category: 'paginated+search', cache: CACHE })
300
+
301
+ expect(code).toContain('const ds_0_params = useMemo(')
302
+ expect(code).toContain('params={ds_0_params}')
303
+ expect(code).toContain('const ds_0_cached = useMemo(')
304
+ expect(code).toContain(
305
+ 'tqCacheGet("ds1:products", tqCacheKey(ds_0_params), {\n sticky: true\n })'
306
+ )
307
+ expect(code).toContain(': ds_0_cached')
308
+ })
309
+
310
+ /**
311
+ * The exact crash this suite failed to see the first time:
312
+ * `ds_0_params` reads `ds_0_state` in its dependency array, so hoisting it
313
+ * ABOVE the `useState` throws
314
+ * `ReferenceError: Cannot access 'ds_0_state' before initialization`
315
+ * the moment the page renders.
316
+ */
317
+ it('declares the hoisted params/peek AFTER the state they read', async () => {
318
+ const { code } = await runPlugin({ category: 'paginated+search', cache: CACHE })
319
+
320
+ const stateIndex = code.indexOf('const [ds_0_state')
321
+ const paramsIndex = code.indexOf('const ds_0_params')
322
+ const cachedIndex = code.indexOf('const ds_0_cached')
323
+
324
+ expect(stateIndex).toBeGreaterThan(-1)
325
+ expect(paramsIndex).toBeGreaterThan(stateIndex)
326
+ expect(cachedIndex).toBeGreaterThan(paramsIndex)
327
+ })
328
+
329
+ /**
330
+ * A state-bound sort already suppresses `initialData`, because the prefetch
331
+ * ran without sort params. The peek is `initialData` by another name, so
332
+ * leaving it in would let a stale first page mask the chosen sort.
333
+ */
334
+ it('suppresses the peek when a state-bound sort is active', async () => {
335
+ const { code } = await runPlugin({ cache: CACHE, dynamicSort: true })
336
+
337
+ expect(code).not.toContain('ds_0_cached')
338
+ // The fetch-level cache still applies — only the mount-time peek is dropped.
339
+ expect(code).toContain('const __tqHit = tqCacheGet("ds1:products", __tqKey)')
340
+ })
341
+
342
+ it('does not cache in the browser when only the server layer is on', async () => {
343
+ const { code } = await runPlugin({ cache: { ...CACHE, client: false } })
344
+
345
+ expect(code).not.toContain('tqCacheGet')
346
+ expect(code).not.toContain('ds_0_cached')
347
+ })
348
+ })
349
+
350
+ describe('array-mapper cache — server wiring', () => {
351
+ it('wraps the generated data-source module when the server layer is on', async () => {
352
+ const { extractedResources } = await runPlugin({ cache: CACHE })
353
+ const moduleKey = Object.keys(extractedResources).find((key) => key.startsWith('utils/'))
354
+
355
+ expect(moduleKey).toBeDefined()
356
+ const content = extractedResources[moduleKey as string].content as string
357
+ expect(content).toContain("import { tqWithCache } from '../tq-cache/server'")
358
+ expect(content).toContain('"scope":"ds1:products"')
359
+ expect(content).toContain('"scope":"ds1:products:count"')
360
+ expect(content).toContain('"ttl":60')
361
+ })
362
+
363
+ it('leaves the module untouched when only the browser layer is on', async () => {
364
+ const { extractedResources } = await runPlugin({ cache: { ...CACHE, server: false } })
365
+ const moduleKey = Object.keys(extractedResources).find((key) => key.startsWith('utils/'))
366
+
367
+ expect(moduleKey).toBeDefined()
368
+ expect(extractedResources[moduleKey as string].content).not.toContain('tq-cache')
369
+ })
370
+
371
+ it('leaves the module untouched when caching is off entirely', async () => {
372
+ const { extractedResources } = await runPlugin({})
373
+ const moduleKey = Object.keys(extractedResources).find((key) => key.startsWith('utils/'))
374
+
375
+ expect(moduleKey).toBeDefined()
376
+ expect(extractedResources[moduleKey as string].content).not.toContain('tq-cache')
377
+ })
378
+ })
379
+
380
+ /**
381
+ * The generated data-source module is shared project-wide, so two pages caching
382
+ * the same table have to agree. If the merge were per-page, page ORDER would
383
+ * decide the TTL — and a page asking for 300s processed second would silently
384
+ * widen the window the 60s page was configured with.
385
+ */
386
+ describe('array-mapper cache — one module, many pages', () => {
387
+ const runTwoPages = async (firstTtl: number, secondTtl: number) => {
388
+ // tslint:disable-next-line:no-any
389
+ const extractedResources: Record<string, any> = {}
390
+ // tslint:disable-next-line:no-any
391
+ const sharedOptions: any = {
392
+ dataSources: {
393
+ ds1: {
394
+ id: 'ds1',
395
+ name: 'Store',
396
+ type: 'postgresql',
397
+ config: { host: 'h', database: 'd', user: 'u', password: 'p' },
398
+ },
399
+ },
400
+ extractedResources,
401
+ }
402
+
403
+ for (const ttlSeconds of [firstTtl, secondTtl]) {
404
+ const chunk = makeComponentChunk(makeDataProviderJSX())
405
+ const structure: ComponentStructure = {
406
+ uidl: {
407
+ name: 'ProductsList',
408
+ node: makeUidlNode('paginated+search', { ...CACHE, ttlSeconds }),
409
+ },
410
+ chunks: [chunk],
411
+ dependencies: {},
412
+ options: sharedOptions,
413
+ } as never
414
+ await createNextArrayMapperPaginationPlugin()(structure)
415
+ }
416
+
417
+ const moduleKey = Object.keys(extractedResources).find((key) => key.startsWith('utils/'))
418
+ return extractedResources[moduleKey as string].content as string
419
+ }
420
+
421
+ it('takes the shortest TTL whichever page is generated first', async () => {
422
+ expect(await runTwoPages(300, 60)).toContain('"ttl":60')
423
+ expect(await runTwoPages(60, 300)).toContain('"ttl":60')
424
+ })
425
+ })
@@ -0,0 +1,152 @@
1
+ import { UIDLDataSource } from '@teleporthq/teleport-types'
2
+ import { generateDataSourceFetcherWithCore } from '../src/data-source-fetchers'
3
+ import { mergeServerCacheOptions } from '../src/cache/config'
4
+
5
+ const dataSource: UIDLDataSource = {
6
+ id: 'ds_1',
7
+ name: 'Store',
8
+ type: 'teleport',
9
+ config: {},
10
+ }
11
+
12
+ const CACHE = { scope: 'ds_1:teleport_products', ttlSeconds: 60 }
13
+
14
+ describe('generateDataSourceFetcherWithCore — server cache wrapping', () => {
15
+ /**
16
+ * The regression pin. A project that never asked for caching must generate
17
+ * byte-identical output, or this feature is a breaking change for everyone.
18
+ */
19
+ it('emits byte-identical output when no cache is configured', () => {
20
+ const before = generateDataSourceFetcherWithCore(dataSource, 'teleport_products', false, {})
21
+ const after = generateDataSourceFetcherWithCore(
22
+ dataSource,
23
+ 'teleport_products',
24
+ false,
25
+ {},
26
+ undefined
27
+ )
28
+
29
+ expect(after).toBe(before)
30
+ expect(before).not.toContain('tq-cache')
31
+ expect(before).toContain('export { fetchData, fetchCount, handler, getCount }')
32
+ })
33
+
34
+ it('wraps both the data handler and the count handler', () => {
35
+ const code = generateDataSourceFetcherWithCore(
36
+ dataSource,
37
+ 'teleport_products',
38
+ false,
39
+ {},
40
+ CACHE
41
+ )
42
+
43
+ expect(code).toContain("import { tqWithCache } from '../tq-cache/server'")
44
+ expect(code).toContain('const cachedHandler = tqWithCache(handler, __tqDataCache)')
45
+ expect(code).toContain('const cachedGetCount = tqWithCache(getCount, __tqCountCache)')
46
+ expect(code).toContain(
47
+ 'export { fetchData, fetchCount, cachedHandler as handler, cachedGetCount as getCount }'
48
+ )
49
+ })
50
+
51
+ /**
52
+ * `getStaticProps` reaches the database through `fetchData`, not through the
53
+ * API route, so pointing it at the raw handler would leave the prefetch —
54
+ * which is page 1 of every list — permanently uncached.
55
+ */
56
+ it('routes the getStaticProps prefetch through the cache too', () => {
57
+ const code = generateDataSourceFetcherWithCore(
58
+ dataSource,
59
+ 'teleport_products',
60
+ false,
61
+ {},
62
+ CACHE
63
+ )
64
+
65
+ expect(code).toContain('await cachedHandler(req, res)')
66
+ expect(code).toContain('await cachedGetCount(req, res)')
67
+ })
68
+
69
+ it('gives the count endpoint its own sub-scope but the same version', () => {
70
+ const code = generateDataSourceFetcherWithCore(
71
+ dataSource,
72
+ 'teleport_products',
73
+ false,
74
+ {},
75
+ CACHE
76
+ )
77
+
78
+ expect(code).toContain('"scope":"ds_1:teleport_products"')
79
+ expect(code).toContain('"scope":"ds_1:teleport_products:count"')
80
+ })
81
+
82
+ /** The inline API-route variant sits one directory deeper. */
83
+ it('uses the right import depth for the inlined API-route variant', () => {
84
+ const code = generateDataSourceFetcherWithCore(dataSource, 'teleport_products', true, {}, CACHE)
85
+
86
+ expect(code).toContain("import { tqWithCache } from '../../tq-cache/server'")
87
+ expect(code).toContain('export default cachedHandler')
88
+ })
89
+
90
+ it('keeps the response private unless a shared window was granted', () => {
91
+ const withoutCdn = generateDataSourceFetcherWithCore(
92
+ dataSource,
93
+ 'teleport_products',
94
+ false,
95
+ {},
96
+ CACHE
97
+ )
98
+ expect(withoutCdn).toContain('"sMaxAge":0')
99
+
100
+ const withCdn = generateDataSourceFetcherWithCore(
101
+ dataSource,
102
+ 'teleport_products',
103
+ false,
104
+ {},
105
+ {
106
+ ...CACHE,
107
+ sMaxAge: 30,
108
+ staleWhileRevalidate: 300,
109
+ }
110
+ )
111
+ expect(withCdn).toContain('"sMaxAge":30')
112
+ expect(withCdn).toContain('"swr":300')
113
+ })
114
+ })
115
+
116
+ describe('mergeServerCacheOptions', () => {
117
+ it('returns nothing when no mapper wants a server cache', () => {
118
+ expect(mergeServerCacheOptions([{ scope: 's', ttlSeconds: 0 }])).toBeUndefined()
119
+ })
120
+
121
+ /**
122
+ * Two mappers can read the same table from different pages and land in the
123
+ * same generated file. The one that asked for fresher data must win, or a
124
+ * sibling silently makes its list staler than it was configured to be.
125
+ */
126
+ it('takes the SHORTEST requested TTL', () => {
127
+ expect(
128
+ mergeServerCacheOptions([
129
+ { scope: 's', ttlSeconds: 600 },
130
+ { scope: 's', ttlSeconds: 60 },
131
+ ])
132
+ ).toMatchObject({ ttlSeconds: 60 })
133
+ })
134
+
135
+ it('ignores disabled entries when picking the TTL', () => {
136
+ expect(
137
+ mergeServerCacheOptions([
138
+ { scope: 's', ttlSeconds: 0 },
139
+ { scope: 's', ttlSeconds: 120 },
140
+ ])
141
+ ).toMatchObject({ ttlSeconds: 120 })
142
+ })
143
+
144
+ it('takes the shortest shared-cache window', () => {
145
+ expect(
146
+ mergeServerCacheOptions([
147
+ { scope: 's', ttlSeconds: 60, sMaxAge: 300 },
148
+ { scope: 's', ttlSeconds: 60, sMaxAge: 30 },
149
+ ])
150
+ ).toMatchObject({ sMaxAge: 30 })
151
+ })
152
+ })