@teleporthq/teleport-plugin-next-data-source 0.43.66 → 0.43.67

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 (179) hide show
  1. package/__tests__/array-mapper-cache-wiring.test.ts +11 -0
  2. package/__tests__/data-source-request-locale.test.ts +313 -0
  3. package/__tests__/gated-data-provider.test.ts +88 -0
  4. package/__tests__/localized-columns.test.ts +217 -0
  5. package/__tests__/numbered-pagination.test.ts +15 -0
  6. package/__tests__/product-filter-fields.ts +230 -0
  7. package/__tests__/product-filter-sql.fixture.json +7 -0
  8. package/__tests__/refetch-loading-state.test.ts +23 -0
  9. package/__tests__/search-clear-button.test.ts +122 -0
  10. package/dist/cjs/content-localization.d.ts +37 -0
  11. package/dist/cjs/content-localization.d.ts.map +1 -0
  12. package/dist/cjs/content-localization.js +29 -0
  13. package/dist/cjs/content-localization.js.map +1 -0
  14. package/dist/cjs/count-effect.d.ts +11 -1
  15. package/dist/cjs/count-effect.d.ts.map +1 -1
  16. package/dist/cjs/count-effect.js +30 -2
  17. package/dist/cjs/count-effect.js.map +1 -1
  18. package/dist/cjs/fetchers/airtable.d.ts.map +1 -1
  19. package/dist/cjs/fetchers/airtable.js +1 -1
  20. package/dist/cjs/fetchers/airtable.js.map +1 -1
  21. package/dist/cjs/fetchers/clickhouse.d.ts.map +1 -1
  22. package/dist/cjs/fetchers/clickhouse.js +1 -1
  23. package/dist/cjs/fetchers/clickhouse.js.map +1 -1
  24. package/dist/cjs/fetchers/firestore.d.ts.map +1 -1
  25. package/dist/cjs/fetchers/firestore.js +1 -1
  26. package/dist/cjs/fetchers/firestore.js.map +1 -1
  27. package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
  28. package/dist/cjs/fetchers/mariadb.js +1 -1
  29. package/dist/cjs/fetchers/mariadb.js.map +1 -1
  30. package/dist/cjs/fetchers/mongodb.d.ts.map +1 -1
  31. package/dist/cjs/fetchers/mongodb.js +1 -1
  32. package/dist/cjs/fetchers/mongodb.js.map +1 -1
  33. package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
  34. package/dist/cjs/fetchers/mysql.js +1 -1
  35. package/dist/cjs/fetchers/mysql.js.map +1 -1
  36. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  37. package/dist/cjs/fetchers/postgresql.js +2 -1
  38. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  39. package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/redshift.js +1 -1
  41. package/dist/cjs/fetchers/redshift.js.map +1 -1
  42. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  43. package/dist/cjs/fetchers/teleport.js +10 -3
  44. package/dist/cjs/fetchers/teleport.js.map +1 -1
  45. package/dist/cjs/fetchers/turso.d.ts.map +1 -1
  46. package/dist/cjs/fetchers/turso.js +1 -1
  47. package/dist/cjs/fetchers/turso.js.map +1 -1
  48. package/dist/cjs/index.d.ts +5 -0
  49. package/dist/cjs/index.d.ts.map +1 -1
  50. package/dist/cjs/index.js +60 -8
  51. package/dist/cjs/index.js.map +1 -1
  52. package/dist/cjs/loading-state.d.ts +16 -1
  53. package/dist/cjs/loading-state.d.ts.map +1 -1
  54. package/dist/cjs/loading-state.js +69 -3
  55. package/dist/cjs/loading-state.js.map +1 -1
  56. package/dist/cjs/localized-columns.d.ts +56 -0
  57. package/dist/cjs/localized-columns.d.ts.map +1 -0
  58. package/dist/cjs/localized-columns.js +124 -0
  59. package/dist/cjs/localized-columns.js.map +1 -0
  60. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  61. package/dist/cjs/pagination-plugin.js +123 -45
  62. package/dist/cjs/pagination-plugin.js.map +1 -1
  63. package/dist/cjs/product-filter-fields.d.ts +87 -0
  64. package/dist/cjs/product-filter-fields.d.ts.map +1 -0
  65. package/dist/cjs/product-filter-fields.js +161 -0
  66. package/dist/cjs/product-filter-fields.js.map +1 -0
  67. package/dist/cjs/request-locale.d.ts +49 -0
  68. package/dist/cjs/request-locale.d.ts.map +1 -0
  69. package/dist/cjs/request-locale.js +130 -0
  70. package/dist/cjs/request-locale.js.map +1 -0
  71. package/dist/cjs/search-clear.d.ts +31 -0
  72. package/dist/cjs/search-clear.d.ts.map +1 -0
  73. package/dist/cjs/search-clear.js +103 -0
  74. package/dist/cjs/search-clear.js.map +1 -0
  75. package/dist/cjs/transformations/index.d.ts +16 -2
  76. package/dist/cjs/transformations/index.d.ts.map +1 -1
  77. package/dist/cjs/transformations/index.js +14 -2
  78. package/dist/cjs/transformations/index.js.map +1 -1
  79. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  80. package/dist/cjs/utils.d.ts +11 -1
  81. package/dist/cjs/utils.d.ts.map +1 -1
  82. package/dist/cjs/utils.js +59 -15
  83. package/dist/cjs/utils.js.map +1 -1
  84. package/dist/esm/content-localization.d.ts +37 -0
  85. package/dist/esm/content-localization.d.ts.map +1 -0
  86. package/dist/esm/content-localization.js +24 -0
  87. package/dist/esm/content-localization.js.map +1 -0
  88. package/dist/esm/count-effect.d.ts +11 -1
  89. package/dist/esm/count-effect.d.ts.map +1 -1
  90. package/dist/esm/count-effect.js +28 -1
  91. package/dist/esm/count-effect.js.map +1 -1
  92. package/dist/esm/fetchers/airtable.d.ts.map +1 -1
  93. package/dist/esm/fetchers/airtable.js +1 -1
  94. package/dist/esm/fetchers/airtable.js.map +1 -1
  95. package/dist/esm/fetchers/clickhouse.d.ts.map +1 -1
  96. package/dist/esm/fetchers/clickhouse.js +1 -1
  97. package/dist/esm/fetchers/clickhouse.js.map +1 -1
  98. package/dist/esm/fetchers/firestore.d.ts.map +1 -1
  99. package/dist/esm/fetchers/firestore.js +1 -1
  100. package/dist/esm/fetchers/firestore.js.map +1 -1
  101. package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
  102. package/dist/esm/fetchers/mariadb.js +1 -1
  103. package/dist/esm/fetchers/mariadb.js.map +1 -1
  104. package/dist/esm/fetchers/mongodb.d.ts.map +1 -1
  105. package/dist/esm/fetchers/mongodb.js +1 -1
  106. package/dist/esm/fetchers/mongodb.js.map +1 -1
  107. package/dist/esm/fetchers/mysql.d.ts.map +1 -1
  108. package/dist/esm/fetchers/mysql.js +1 -1
  109. package/dist/esm/fetchers/mysql.js.map +1 -1
  110. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  111. package/dist/esm/fetchers/postgresql.js +2 -1
  112. package/dist/esm/fetchers/postgresql.js.map +1 -1
  113. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  114. package/dist/esm/fetchers/redshift.js +1 -1
  115. package/dist/esm/fetchers/redshift.js.map +1 -1
  116. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  117. package/dist/esm/fetchers/teleport.js +10 -3
  118. package/dist/esm/fetchers/teleport.js.map +1 -1
  119. package/dist/esm/fetchers/turso.d.ts.map +1 -1
  120. package/dist/esm/fetchers/turso.js +1 -1
  121. package/dist/esm/fetchers/turso.js.map +1 -1
  122. package/dist/esm/index.d.ts +5 -0
  123. package/dist/esm/index.d.ts.map +1 -1
  124. package/dist/esm/index.js +55 -7
  125. package/dist/esm/index.js.map +1 -1
  126. package/dist/esm/loading-state.d.ts +16 -1
  127. package/dist/esm/loading-state.d.ts.map +1 -1
  128. package/dist/esm/loading-state.js +69 -3
  129. package/dist/esm/loading-state.js.map +1 -1
  130. package/dist/esm/localized-columns.d.ts +56 -0
  131. package/dist/esm/localized-columns.d.ts.map +1 -0
  132. package/dist/esm/localized-columns.js +119 -0
  133. package/dist/esm/localized-columns.js.map +1 -0
  134. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  135. package/dist/esm/pagination-plugin.js +124 -46
  136. package/dist/esm/pagination-plugin.js.map +1 -1
  137. package/dist/esm/product-filter-fields.d.ts +87 -0
  138. package/dist/esm/product-filter-fields.d.ts.map +1 -0
  139. package/dist/esm/product-filter-fields.js +154 -0
  140. package/dist/esm/product-filter-fields.js.map +1 -0
  141. package/dist/esm/request-locale.d.ts +49 -0
  142. package/dist/esm/request-locale.d.ts.map +1 -0
  143. package/dist/esm/request-locale.js +98 -0
  144. package/dist/esm/request-locale.js.map +1 -0
  145. package/dist/esm/search-clear.d.ts +31 -0
  146. package/dist/esm/search-clear.d.ts.map +1 -0
  147. package/dist/esm/search-clear.js +74 -0
  148. package/dist/esm/search-clear.js.map +1 -0
  149. package/dist/esm/transformations/index.d.ts +16 -2
  150. package/dist/esm/transformations/index.d.ts.map +1 -1
  151. package/dist/esm/transformations/index.js +14 -2
  152. package/dist/esm/transformations/index.js.map +1 -1
  153. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  154. package/dist/esm/utils.d.ts +11 -1
  155. package/dist/esm/utils.d.ts.map +1 -1
  156. package/dist/esm/utils.js +59 -15
  157. package/dist/esm/utils.js.map +1 -1
  158. package/package.json +5 -5
  159. package/src/content-localization.ts +53 -0
  160. package/src/count-effect.ts +53 -2
  161. package/src/fetchers/airtable.ts +4 -2
  162. package/src/fetchers/clickhouse.ts +4 -2
  163. package/src/fetchers/firestore.ts +4 -2
  164. package/src/fetchers/mariadb.ts +4 -2
  165. package/src/fetchers/mongodb.ts +4 -2
  166. package/src/fetchers/mysql.ts +4 -2
  167. package/src/fetchers/postgresql.ts +16 -2
  168. package/src/fetchers/redshift.ts +4 -2
  169. package/src/fetchers/teleport.ts +48 -9
  170. package/src/fetchers/turso.ts +4 -2
  171. package/src/index.ts +79 -11
  172. package/src/loading-state.ts +134 -3
  173. package/src/localized-columns.ts +133 -0
  174. package/src/pagination-plugin.ts +177 -53
  175. package/src/product-filter-fields.ts +173 -0
  176. package/src/request-locale.ts +113 -0
  177. package/src/search-clear.ts +102 -0
  178. package/src/transformations/index.ts +30 -5
  179. package/src/utils.ts +83 -15
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  FileType,
5
5
  ChunkType,
6
6
  UIDLComponentSEO,
7
+ UIDLDependency,
7
8
  UIDLDynamicReference,
8
9
  } from '@teleporthq/teleport-types'
9
10
  import { UIDLUtils, StringUtils } from '@teleporthq/teleport-shared'
@@ -16,6 +17,7 @@ import {
16
17
  import { createNextArrayMapperPaginationPlugin } from './pagination-plugin'
17
18
  import { buildProductTransformOptions } from './transformations'
18
19
  import { DATA_SOURCE_ISR_REVALIDATE_SECONDS } from './isr'
20
+ import { ensureRouterDeclaration } from './request-locale'
19
21
  import * as types from '@babel/types'
20
22
 
21
23
  interface SearchConfig {
@@ -55,6 +57,41 @@ interface WrapContext {
55
57
  // from user-defined or standard renderPropIdentifier values
56
58
  const WRAPPED_DS_EXPR_PREFIX = '__dsExpr_'
57
59
 
60
+ /**
61
+ * The statements of the component function the `jsx-component` chunk declares
62
+ * (`const Page = (props) => { … }`), or `null` when the chunk holds another
63
+ * shape — a test may hand the plugin a bare element.
64
+ */
65
+ const findComponentBody = (componentChunk: { content: unknown }): types.Statement[] | null => {
66
+ const content = componentChunk.content as types.Node | undefined
67
+ if (content?.type !== 'VariableDeclaration') {
68
+ return null
69
+ }
70
+ const init = content.declarations[0]?.init
71
+ if (
72
+ (init?.type !== 'ArrowFunctionExpression' && init?.type !== 'FunctionExpression') ||
73
+ init.body.type !== 'BlockStatement'
74
+ ) {
75
+ return null
76
+ }
77
+ return init.body.body
78
+ }
79
+
80
+ /**
81
+ * A client-side fetch in a localized project reads `router.locale` (see
82
+ * `extractDataSourceIntoNextAPIFolder`), so the router has to be declared in
83
+ * the component that fetches.
84
+ */
85
+ const ensureRouterForClientFetches = (
86
+ componentChunk: { content: unknown },
87
+ dependencies: Record<string, UIDLDependency>
88
+ ): void => {
89
+ const body = findComponentBody(componentChunk)
90
+ if (body) {
91
+ ensureRouterDeclaration(body, dependencies)
92
+ }
93
+ }
94
+
58
95
  function containsDataSourceDataReference(astNode: any): boolean {
59
96
  if (!astNode || typeof astNode !== 'object') {
60
97
  return false
@@ -490,6 +527,11 @@ export const createNextPagesDataSourcePlugin: ComponentPluginFactory<{}> = () =>
490
527
 
491
528
  let getStaticPropsChunk = chunks.find((chunk) => chunk.name === 'getStaticProps')
492
529
 
530
+ const transformOptions = buildProductTransformOptions(options)
531
+ // Whether a provider on this page fetches client-side — in a localized
532
+ // project that fetch reads `router.locale`.
533
+ let wiredClientFetch = false
534
+
493
535
  // Track the first data source info for wrapping dataSourceData expressions
494
536
  let firstDataSourceInfo: DataSourceInfo | null = null
495
537
  // Track fetcher import name for wrapped providers
@@ -609,7 +651,7 @@ export const createNextPagesDataSourcePlugin: ComponentPluginFactory<{}> = () =>
609
651
  dependencies,
610
652
  dynamicRouteAttr,
611
653
  uidl.outputOptions?.folderPath,
612
- buildProductTransformOptions(options)
654
+ transformOptions
613
655
  )
614
656
 
615
657
  if (result.success && result.chunk) {
@@ -641,17 +683,23 @@ export const createNextPagesDataSourcePlugin: ComponentPluginFactory<{}> = () =>
641
683
  }
642
684
  }
643
685
  }
644
- } else {
686
+ } else if (
645
687
  extractDataSourceIntoNextAPIFolder(
646
688
  dataSourceNode,
647
689
  dataSources,
648
690
  componentChunk,
649
691
  options.extractedResources,
650
- buildProductTransformOptions(options)
692
+ transformOptions
651
693
  )
694
+ ) {
695
+ wiredClientFetch = true
652
696
  }
653
697
  })
654
698
 
699
+ if (wiredClientFetch && transformOptions.localization) {
700
+ ensureRouterForClientFetches(componentChunk, dependencies)
701
+ }
702
+
655
703
  // After processing all data source nodes, wrap any element props containing dataSourceData
656
704
  // expressions in a DataProvider
657
705
  if (firstDataSourceInfo && componentChunk.content) {
@@ -1207,6 +1255,11 @@ export const createNextComponentDataSourcePlugin: ComponentPluginFactory<{}> = (
1207
1255
  return structure
1208
1256
  }
1209
1257
 
1258
+ const transformOptions = buildProductTransformOptions(options)
1259
+ // Whether a provider in this component fetches client-side — in a
1260
+ // localized project that fetch reads `router.locale`.
1261
+ let wiredClientFetch = false
1262
+
1210
1263
  // Track the first data source info for wrapping dataSourceData expressions
1211
1264
  let firstDataSourceInfo: DataSourceInfo | null = null
1212
1265
 
@@ -1264,15 +1317,23 @@ export const createNextComponentDataSourcePlugin: ComponentPluginFactory<{}> = (
1264
1317
  }
1265
1318
  }
1266
1319
 
1267
- extractDataSourceIntoNextAPIFolder(
1268
- dataSourceNode,
1269
- dataSources,
1270
- componentChunk,
1271
- options.extractedResources,
1272
- buildProductTransformOptions(options)
1273
- )
1320
+ if (
1321
+ extractDataSourceIntoNextAPIFolder(
1322
+ dataSourceNode,
1323
+ dataSources,
1324
+ componentChunk,
1325
+ options.extractedResources,
1326
+ transformOptions
1327
+ )
1328
+ ) {
1329
+ wiredClientFetch = true
1330
+ }
1274
1331
  })
1275
1332
 
1333
+ if (wiredClientFetch && transformOptions.localization) {
1334
+ ensureRouterForClientFetches(componentChunk, dependencies)
1335
+ }
1336
+
1276
1337
  // If no data source nodes were found but there are dataSources available,
1277
1338
  // use the first available dataSource to create firstDataSourceInfo
1278
1339
  // This handles the case where a component has element props with dataSourceData expressions
@@ -1355,7 +1416,7 @@ export const createNextComponentDataSourcePlugin: ComponentPluginFactory<{}> = (
1355
1416
  dataSource,
1356
1417
  firstDataSourceInfo.tableName,
1357
1418
  false,
1358
- buildProductTransformOptions(options)
1419
+ transformOptions
1359
1420
  )
1360
1421
  options.extractedResources[`utils/${fileName}`] = {
1361
1422
  fileName,
@@ -1399,8 +1460,15 @@ export * from './utils'
1399
1460
  export * from './array-mapper-pagination'
1400
1461
  export * from './pagination-plugin'
1401
1462
  export * from './count-fetchers'
1463
+ export * from './product-price-sort'
1464
+ export * from './product-filter-fields'
1402
1465
  // The product transform's build-time options (category taxonomy + storefront
1403
1466
  // tax rate) — every package that emits a data-source fetcher has to derive
1404
1467
  // them the same way.
1405
1468
  export { buildProductTransformOptions } from './transformations'
1406
1469
  export type { EcommerceProductTransformOptions, EntityTransformOptions } from './transformations'
1470
+ // How a fetch says which language it is for, and when a project needs it to —
1471
+ // shared with every other package that emits a data-source fetch.
1472
+ export { isLocalizedProject, resolveContentLocalization } from './content-localization'
1473
+ export type { ContentLocalization } from './content-localization'
1474
+ export { REQUEST_LOCALE_PARAM, buildServerLocaleParam } from './request-locale'
@@ -67,6 +67,19 @@ export interface LoadingStateVars {
67
67
  * stale rows back for the rest of the second request.
68
68
  */
69
69
  inFlightRefVar: string
70
+ /**
71
+ * Ref counting the requests STARTED, so a response can tell whether a newer
72
+ * request has overtaken it. `DataProvider` keeps whatever resolves last:
73
+ * two controls changed in quick succession (a "Clear all" emptying several
74
+ * filter states, one state write after another) start two requests, and
75
+ * when the older, slower one settles after the newer it painted the OLDER
76
+ * rows over the newer — a list showing two products for a URL that filters
77
+ * nothing. Each request notes its sequence number and, if overtaken, hands
78
+ * over the newest settled rows instead of its own.
79
+ */
80
+ fetchSeqRefVar: string
81
+ /** Ref holding the rows of the newest request that settled — what an overtaken one resolves with. */
82
+ latestDataRefVar: string
70
83
  }
71
84
 
72
85
  export function getLoadingStateVars(index: number): LoadingStateVars {
@@ -74,14 +87,18 @@ export function getLoadingStateVars(index: number): LoadingStateVars {
74
87
  isFetchingVar: `ds_${index}_isFetching`,
75
88
  setIsFetchingVar: `setDs_${index}_isFetching`,
76
89
  inFlightRefVar: `ds_${index}_fetchesInFlight`,
90
+ fetchSeqRefVar: `ds_${index}_fetchSeq`,
91
+ latestDataRefVar: `ds_${index}_latestData`,
77
92
  }
78
93
  }
79
94
 
80
95
  /**
81
96
  * `const ds_N_fetchesInFlight = useRef(0)`
97
+ * `const ds_N_fetchSeq = useRef(0)`
98
+ * `const ds_N_latestData = useRef(undefined)`
82
99
  * `const [ds_N_isFetching, setDs_N_isFetching] = useState(false)`
83
100
  *
84
- * Both are stable across renders (a ref object and a `useState` setter), which
101
+ * All are stable across renders (ref objects and a `useState` setter), which
85
102
  * is what lets the wrapped `fetchData` keep its empty `useCallback` dependency
86
103
  * array — a changing `fetchData` identity would retrigger the provider's fetch
87
104
  * effect on every render.
@@ -94,6 +111,18 @@ export function buildLoadingStateDeclarations(vars: LoadingStateVars): types.Sta
94
111
  types.callExpression(types.identifier('useRef'), [types.numericLiteral(0)])
95
112
  ),
96
113
  ]),
114
+ types.variableDeclaration('const', [
115
+ types.variableDeclarator(
116
+ types.identifier(vars.fetchSeqRefVar),
117
+ types.callExpression(types.identifier('useRef'), [types.numericLiteral(0)])
118
+ ),
119
+ ]),
120
+ types.variableDeclaration('const', [
121
+ types.variableDeclarator(
122
+ types.identifier(vars.latestDataRefVar),
123
+ types.callExpression(types.identifier('useRef'), [types.identifier('undefined')])
124
+ ),
125
+ ]),
97
126
  types.variableDeclaration('const', [
98
127
  types.variableDeclarator(
99
128
  types.arrayPattern([
@@ -183,10 +212,54 @@ function injectTrackingIntoCachedBody(body: types.BlockStatement, vars: LoadingS
183
212
 
184
213
  const tracked = buildTrackedFetchBody(networkChain.statement.argument as types.Expression, vars)
185
214
  body.body.splice(networkChain.index, 1, ...tracked.body)
215
+ markCacheHitAsNewest(body, vars)
186
216
 
187
217
  return true
188
218
  }
189
219
 
220
+ /**
221
+ * A cache hit answers synchronously with the rows of THIS request, so it is
222
+ * the newest settled result too: it advances the sequence and records its rows,
223
+ * or an older network request still in flight would resolve after it and paint
224
+ * over it. Adds the two statements at the head of the hit branch — before its
225
+ * `return Promise.resolve(__tqHit)` — and nothing else, so the flag the branch
226
+ * deliberately never raises stays untouched.
227
+ */
228
+ function markCacheHitAsNewest(body: types.BlockStatement, vars: LoadingStateVars): void {
229
+ const hit = body.body.find(
230
+ (statement): statement is types.IfStatement =>
231
+ statement.type === 'IfStatement' &&
232
+ statement.test.type === 'BinaryExpression' &&
233
+ statement.test.left.type === 'Identifier' &&
234
+ statement.test.left.name === CACHE_HIT_IDENTIFIER &&
235
+ statement.consequent.type === 'BlockStatement'
236
+ )
237
+ if (!hit || hit.consequent.type !== 'BlockStatement') {
238
+ return
239
+ }
240
+ hit.consequent.body.unshift(
241
+ types.expressionStatement(types.updateExpression('++', refCurrent(vars.fetchSeqRefVar), true)),
242
+ types.expressionStatement(
243
+ types.assignmentExpression(
244
+ '=',
245
+ refCurrent(vars.latestDataRefVar),
246
+ types.identifier(CACHE_HIT_IDENTIFIER)
247
+ )
248
+ )
249
+ )
250
+ }
251
+
252
+ /** The identifier the cache wiring binds a hit to — see `cache/ast.ts`. */
253
+ const CACHE_HIT_IDENTIFIER = '__tqHit'
254
+ /** The rows a tracked request resolved with, inside its own `then`. */
255
+ const TRACKED_ROWS_IDENTIFIER = '__tqRows'
256
+ /** This request's sequence number. */
257
+ const TRACKED_SEQ_IDENTIFIER = '__tqSeq'
258
+
259
+ function refCurrent(refVar: string): types.MemberExpression {
260
+ return types.memberExpression(types.identifier(refVar), types.identifier('current'))
261
+ }
262
+
190
263
  // tslint:disable-next-line:no-any
191
264
  function containsIdentifier(node: any, name: string): boolean {
192
265
  if (!node || typeof node !== 'object') {
@@ -257,9 +330,16 @@ function findMemoizedFetchDataArrow(
257
330
  * Turns `(params) => fetch(...).then(...)` into
258
331
  *
259
332
  * (params) => {
333
+ * const __tqSeq = ++ds_N_fetchSeq.current
260
334
  * ds_N_fetchesInFlight.current += 1
261
335
  * setDs_N_isFetching(true)
262
- * return fetch(...).then(...).finally(() => {
336
+ * return fetch(...).then(...).then((__tqRows) => {
337
+ * if (__tqSeq !== ds_N_fetchSeq.current) {
338
+ * return ds_N_latestData.current !== undefined ? ds_N_latestData.current : __tqRows
339
+ * }
340
+ * ds_N_latestData.current = __tqRows
341
+ * return __tqRows
342
+ * }).finally(() => {
263
343
  * ds_N_fetchesInFlight.current -= 1
264
344
  * if (ds_N_fetchesInFlight.current <= 0) {
265
345
  * ds_N_fetchesInFlight.current = 0
@@ -268,6 +348,12 @@ function findMemoizedFetchDataArrow(
268
348
  * })
269
349
  * }
270
350
  *
351
+ * The `then` is the stale guard (see `fetchSeqRefVar`): a request overtaken by
352
+ * a newer one resolves with the newest settled rows rather than its own, so
353
+ * the provider — which keeps whatever resolves LAST — never paints older rows
354
+ * over newer ones. An overtaken request whose successor has not settled yet
355
+ * resolves with its own rows; the successor then replaces them.
356
+ *
271
357
  * `finally` (rather than a `then` pair) keeps the flag honest when the request
272
358
  * rejects: the provider switches to its error status and the loading state must
273
359
  * not stay on screen. The counter is clamped at 0 so a late settle from a
@@ -317,7 +403,52 @@ function buildTrackedFetchBody(
317
403
  ])
318
404
  )
319
405
 
406
+ const latestData = refCurrent(vars.latestDataRefVar)
407
+ const staleGuard = types.arrowFunctionExpression(
408
+ [types.identifier(TRACKED_ROWS_IDENTIFIER)],
409
+ types.blockStatement([
410
+ types.ifStatement(
411
+ types.binaryExpression(
412
+ '!==',
413
+ types.identifier(TRACKED_SEQ_IDENTIFIER),
414
+ refCurrent(vars.fetchSeqRefVar)
415
+ ),
416
+ types.blockStatement([
417
+ types.returnStatement(
418
+ types.conditionalExpression(
419
+ types.binaryExpression(
420
+ '!==',
421
+ types.cloneNode(latestData, true),
422
+ types.identifier('undefined')
423
+ ),
424
+ types.cloneNode(latestData, true),
425
+ types.identifier(TRACKED_ROWS_IDENTIFIER)
426
+ )
427
+ ),
428
+ ])
429
+ ),
430
+ types.expressionStatement(
431
+ types.assignmentExpression(
432
+ '=',
433
+ types.cloneNode(latestData, true),
434
+ types.identifier(TRACKED_ROWS_IDENTIFIER)
435
+ )
436
+ ),
437
+ types.returnStatement(types.identifier(TRACKED_ROWS_IDENTIFIER)),
438
+ ])
439
+ )
440
+ const guardedFetch = types.callExpression(
441
+ types.memberExpression(fetchExpression, types.identifier('then')),
442
+ [staleGuard]
443
+ )
444
+
320
445
  return types.blockStatement([
446
+ types.variableDeclaration('const', [
447
+ types.variableDeclarator(
448
+ types.identifier(TRACKED_SEQ_IDENTIFIER),
449
+ types.updateExpression('++', refCurrent(vars.fetchSeqRefVar), true)
450
+ ),
451
+ ]),
321
452
  types.expressionStatement(
322
453
  types.assignmentExpression(
323
454
  '+=',
@@ -329,7 +460,7 @@ function buildTrackedFetchBody(
329
460
  types.callExpression(types.identifier(vars.setIsFetchingVar), [types.booleanLiteral(true)])
330
461
  ),
331
462
  types.returnStatement(
332
- types.callExpression(types.memberExpression(fetchExpression, types.identifier('finally')), [
463
+ types.callExpression(types.memberExpression(guardedFetch, types.identifier('finally')), [
333
464
  settleHandler,
334
465
  ])
335
466
  ),
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Filtering and sorting a translatable column in the visitor's language.
3
+ *
4
+ * A translatable column of a platform table exists once per language:
5
+ * `slug` holds the main language and `es_slug` the Spanish copy (see
6
+ * `ContentLocalization`). The row transform resolves what a page DISPLAYS —
7
+ * a Spanish listing shows `es_name` and links to `es_slug` — but the SQL a
8
+ * fetch runs still names the base column, so without this file:
9
+ *
10
+ * - the details page a Spanish card links to (`/es/products/<es_slug>`)
11
+ * looks the row up with `slug = $1` and answers 404;
12
+ * - "sort by name" on the Spanish listing orders the Spanish names by
13
+ * their ENGLISH counterparts.
14
+ *
15
+ * The teleport fetcher (`fetchers/teleport.ts`) therefore embeds the helper
16
+ * emitted here:
17
+ *
18
+ * - equality on a translatable column matches the value in ANY language:
19
+ * `("slug" = $1 OR "es_slug" = $1)`. Deliberately every language, not the
20
+ * request's: the language switcher keeps the URL and changes the locale,
21
+ * so `/products/<es_slug>` (English page, Spanish slug) has to resolve as
22
+ * well as `/es/products/<slug>`. Only `=` (and the `in` it is normalised
23
+ * to) — a translatable column is never compared with `<`, and `!=` on one
24
+ * has no sensible cross-language reading;
25
+ * - ordering by a translatable column orders by the REQUEST's language,
26
+ * falling back to the base column exactly like the transform does:
27
+ * `COALESCE(NULLIF("es_name", ''), "name")`.
28
+ *
29
+ * Which columns take part is decided at GENERATION time from the table's
30
+ * schema and the project's languages — never by prefix-sniffing column names,
31
+ * which would mistake `og_title` for a translation. A copy the schema does not
32
+ * list (a language added before a column became translatable) is simply not
33
+ * referenced, so the SQL can only ever name columns that exist.
34
+ */
35
+
36
+ import type { ContentLocalization } from './content-localization'
37
+
38
+ /**
39
+ * Base column → `{ locale: copy column }`, for every translatable column of a
40
+ * table that has at least one per-language copy in the schema.
41
+ */
42
+ export type LocalizedColumnMap = Record<string, Record<string, string>>
43
+
44
+ /** The only identifiers the generated SQL is allowed to quote — same rule as the search columns. */
45
+ const SQL_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
46
+
47
+ /** The column holding `column` in `locale` — the editor's `getI18nColumnName`. */
48
+ const localizedColumnName = (locale: string, column: string): string => `${locale}_${column}`
49
+
50
+ /**
51
+ * Pairs every column with the per-language copies the schema actually holds.
52
+ * Empty (no families) when the project has one language or the table has no
53
+ * translated column — the emitted helper is then inert.
54
+ */
55
+ export const collectLocalizedColumns = (
56
+ columnNames: string[],
57
+ localization: ContentLocalization | undefined
58
+ ): LocalizedColumnMap => {
59
+ const families: LocalizedColumnMap = {}
60
+ if (!localization || localization.secondaryLocales.length === 0) {
61
+ return families
62
+ }
63
+ const present = new Set(columnNames.filter((name) => SQL_IDENTIFIER_PATTERN.test(name)))
64
+ present.forEach((column) => {
65
+ localization.secondaryLocales.forEach((locale) => {
66
+ const copy = localizedColumnName(locale, column)
67
+ if (!present.has(copy)) {
68
+ return
69
+ }
70
+ if (!families[column]) {
71
+ families[column] = {}
72
+ }
73
+ families[column][locale] = copy
74
+ })
75
+ })
76
+ return families
77
+ }
78
+
79
+ /**
80
+ * The `localizedEqualityClause` / `localizedSortFieldSql` helpers a fetcher
81
+ * embeds, with the column map baked in.
82
+ *
83
+ * Emitted as plain source with no backtick and no `${`, because the fetcher
84
+ * interpolates it into a template literal. Every identifier it quotes comes
85
+ * from the baked map, so a request can only ever name a column the schema had.
86
+ */
87
+ export const generateLocalizedColumnsHelper = (families: LocalizedColumnMap): string => {
88
+ const lines = [
89
+ '// Per-language copies of the translatable columns, base column -> locale -> copy.',
90
+ '// Baked from the table schema and the project languages at generation time.',
91
+ `var LOCALIZED_COLUMNS = ${JSON.stringify(families)}`,
92
+ '',
93
+ 'function quoteLocalizedColumn(column) {',
94
+ " return '\"' + column + '\"'",
95
+ '}',
96
+ '',
97
+ '// Equality on a translatable column matches the value in ANY language, so a',
98
+ '// localized slug resolves whichever locale the URL is under. Answers null',
99
+ '// for every other column and operand, leaving them to the ordinary branches.',
100
+ 'function localizedEqualityClause(field, operand, value, pushParam) {',
101
+ ' var copies = LOCALIZED_COLUMNS[field]',
102
+ " if (!copies || operand !== '=' || value === null || value === undefined) return null",
103
+ ' // An empty set means "no filter", as it does in the ordinary array branch.',
104
+ ' if (Array.isArray(value) && value.length === 0) return null',
105
+ ' var columns = [field]',
106
+ ' for (var locale in copies) {',
107
+ ' if (Object.prototype.hasOwnProperty.call(copies, locale)) columns.push(copies[locale])',
108
+ ' }',
109
+ ' var param = Array.isArray(value) ? pushParam(value.map(String)) : pushParam(value)',
110
+ ' var clauses = []',
111
+ ' for (var i = 0; i < columns.length; i++) {',
112
+ ' clauses.push(',
113
+ ' Array.isArray(value)',
114
+ " ? quoteLocalizedColumn(columns[i]) + '::text = ANY(' + param + '::text[])'",
115
+ " : quoteLocalizedColumn(columns[i]) + ' = ' + param",
116
+ ' )',
117
+ ' }',
118
+ " return '(' + clauses.join(' OR ') + ')'",
119
+ '}',
120
+ '',
121
+ "// ORDER BY a translatable column in the request's language, falling back to",
122
+ '// the base column for a row with no translation — the same resolution the',
123
+ '// row transform applies to what is displayed. Null when the field or the',
124
+ '// locale has no copy, so the ordinary sort runs.',
125
+ 'function localizedSortFieldSql(field, locale) {',
126
+ ' var copies = LOCALIZED_COLUMNS[field]',
127
+ " var copy = copies && typeof locale === 'string' ? copies[locale] : null",
128
+ ' if (!copy) return null',
129
+ " return 'COALESCE(NULLIF(' + quoteLocalizedColumn(copy) + \", ''), \" + quoteLocalizedColumn(field) + ')'",
130
+ '}',
131
+ ]
132
+ return lines.join('\n')
133
+ }