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

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 (334) hide show
  1. package/__tests__/array-mapper-cache-wiring.test.ts +11 -0
  2. package/__tests__/array-overlap-sql.test.ts +53 -0
  3. package/__tests__/blog-post-page-enrichment.test.ts +214 -0
  4. package/__tests__/browser-row-policy.test.ts +503 -0
  5. package/__tests__/data-source-cache-wrapping.test.ts +18 -2
  6. package/__tests__/data-source-request-locale.test.ts +313 -0
  7. package/__tests__/ecommerce-product-options.test.ts +206 -0
  8. package/__tests__/ecommerce-product-pages.test.ts +82 -0
  9. package/__tests__/ecommerce-product-pre-options.golden.json +505 -0
  10. package/__tests__/ecommerce-product-storefront-contract.test.ts +293 -0
  11. package/__tests__/ecommerce-product-subscriptions.test.ts +121 -0
  12. package/__tests__/fetchers.test.ts +2 -2
  13. package/__tests__/gated-data-provider.test.ts +88 -0
  14. package/__tests__/items-path.test.ts +150 -0
  15. package/__tests__/localized-columns.test.ts +217 -0
  16. package/__tests__/numbered-pagination.test.ts +15 -0
  17. package/__tests__/product-filter-fields.ts +230 -0
  18. package/__tests__/product-filter-sql.fixture.json +7 -0
  19. package/__tests__/product-options-storefront.fixture.json +2510 -0
  20. package/__tests__/product-page-url-fixtures.json +25 -0
  21. package/__tests__/product-price-sort.ts +4 -2
  22. package/__tests__/raw-rows-mode.test.ts +111 -0
  23. package/__tests__/refetch-loading-state.test.ts +23 -0
  24. package/__tests__/rest-api-response-cache.test.ts +434 -0
  25. package/__tests__/search-clear-button.test.ts +122 -0
  26. package/__tests__/table-read-route-protected-tables.test.ts +493 -0
  27. package/dist/cjs/array-overlap-sql.d.ts +21 -0
  28. package/dist/cjs/array-overlap-sql.d.ts.map +1 -0
  29. package/dist/cjs/array-overlap-sql.js +25 -0
  30. package/dist/cjs/array-overlap-sql.js.map +1 -0
  31. package/dist/cjs/content-localization.d.ts +37 -0
  32. package/dist/cjs/content-localization.d.ts.map +1 -0
  33. package/dist/cjs/content-localization.js +29 -0
  34. package/dist/cjs/content-localization.js.map +1 -0
  35. package/dist/cjs/count-effect.d.ts +11 -1
  36. package/dist/cjs/count-effect.d.ts.map +1 -1
  37. package/dist/cjs/count-effect.js +30 -2
  38. package/dist/cjs/count-effect.js.map +1 -1
  39. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  40. package/dist/cjs/data-source-fetchers.js +10 -4
  41. package/dist/cjs/data-source-fetchers.js.map +1 -1
  42. package/dist/cjs/fetchers/airtable.d.ts.map +1 -1
  43. package/dist/cjs/fetchers/airtable.js +1 -1
  44. package/dist/cjs/fetchers/airtable.js.map +1 -1
  45. package/dist/cjs/fetchers/clickhouse.d.ts.map +1 -1
  46. package/dist/cjs/fetchers/clickhouse.js +1 -1
  47. package/dist/cjs/fetchers/clickhouse.js.map +1 -1
  48. package/dist/cjs/fetchers/firestore.d.ts.map +1 -1
  49. package/dist/cjs/fetchers/firestore.js +1 -1
  50. package/dist/cjs/fetchers/firestore.js.map +1 -1
  51. package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
  52. package/dist/cjs/fetchers/javascript.js +1 -1
  53. package/dist/cjs/fetchers/javascript.js.map +1 -1
  54. package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
  55. package/dist/cjs/fetchers/mariadb.js +1 -1
  56. package/dist/cjs/fetchers/mariadb.js.map +1 -1
  57. package/dist/cjs/fetchers/mongodb.d.ts.map +1 -1
  58. package/dist/cjs/fetchers/mongodb.js +1 -1
  59. package/dist/cjs/fetchers/mongodb.js.map +1 -1
  60. package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
  61. package/dist/cjs/fetchers/mysql.js +1 -1
  62. package/dist/cjs/fetchers/mysql.js.map +1 -1
  63. package/dist/cjs/fetchers/postgresql.d.ts +1 -1
  64. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  65. package/dist/cjs/fetchers/postgresql.js +9 -4
  66. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  67. package/dist/cjs/fetchers/raw-query.d.ts +8 -1
  68. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  69. package/dist/cjs/fetchers/raw-query.js +42 -4
  70. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  71. package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
  72. package/dist/cjs/fetchers/redshift.js +1 -1
  73. package/dist/cjs/fetchers/redshift.js.map +1 -1
  74. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  75. package/dist/cjs/fetchers/rest-api.js +7 -2
  76. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  77. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  78. package/dist/cjs/fetchers/teleport.js +16 -6
  79. package/dist/cjs/fetchers/teleport.js.map +1 -1
  80. package/dist/cjs/fetchers/turso.d.ts.map +1 -1
  81. package/dist/cjs/fetchers/turso.js +1 -1
  82. package/dist/cjs/fetchers/turso.js.map +1 -1
  83. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +43 -0
  84. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -0
  85. package/dist/cjs/fetchers/utils/browser-row-policy.js +66 -0
  86. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -0
  87. package/dist/cjs/fetchers/utils/request-sql-guards.d.ts +8 -0
  88. package/dist/cjs/fetchers/utils/request-sql-guards.d.ts.map +1 -0
  89. package/dist/cjs/fetchers/utils/request-sql-guards.js +12 -0
  90. package/dist/cjs/fetchers/utils/request-sql-guards.js.map +1 -0
  91. package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts +40 -0
  92. package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
  93. package/dist/cjs/fetchers/utils/rest-api-response-cache.js +64 -0
  94. package/dist/cjs/fetchers/utils/rest-api-response-cache.js.map +1 -0
  95. package/dist/cjs/fetchers/utils/table-access-guard.d.ts +23 -0
  96. package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -0
  97. package/dist/cjs/fetchers/utils/table-access-guard.js +34 -0
  98. package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -0
  99. package/dist/cjs/index.d.ts +5 -0
  100. package/dist/cjs/index.d.ts.map +1 -1
  101. package/dist/cjs/index.js +60 -8
  102. package/dist/cjs/index.js.map +1 -1
  103. package/dist/cjs/items-path.d.ts +10 -0
  104. package/dist/cjs/items-path.d.ts.map +1 -0
  105. package/dist/cjs/items-path.js +86 -0
  106. package/dist/cjs/items-path.js.map +1 -0
  107. package/dist/cjs/loading-state.d.ts +16 -1
  108. package/dist/cjs/loading-state.d.ts.map +1 -1
  109. package/dist/cjs/loading-state.js +69 -3
  110. package/dist/cjs/loading-state.js.map +1 -1
  111. package/dist/cjs/localized-columns.d.ts +56 -0
  112. package/dist/cjs/localized-columns.d.ts.map +1 -0
  113. package/dist/cjs/localized-columns.js +137 -0
  114. package/dist/cjs/localized-columns.js.map +1 -0
  115. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  116. package/dist/cjs/pagination-plugin.js +140 -50
  117. package/dist/cjs/pagination-plugin.js.map +1 -1
  118. package/dist/cjs/product-filter-fields.d.ts +87 -0
  119. package/dist/cjs/product-filter-fields.d.ts.map +1 -0
  120. package/dist/cjs/product-filter-fields.js +161 -0
  121. package/dist/cjs/product-filter-fields.js.map +1 -0
  122. package/dist/cjs/request-locale.d.ts +49 -0
  123. package/dist/cjs/request-locale.d.ts.map +1 -0
  124. package/dist/cjs/request-locale.js +130 -0
  125. package/dist/cjs/request-locale.js.map +1 -0
  126. package/dist/cjs/search-clear.d.ts +31 -0
  127. package/dist/cjs/search-clear.d.ts.map +1 -0
  128. package/dist/cjs/search-clear.js +103 -0
  129. package/dist/cjs/search-clear.js.map +1 -0
  130. package/dist/cjs/transformations/blog-adjacent-posts.d.ts +16 -0
  131. package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -0
  132. package/dist/cjs/transformations/blog-adjacent-posts.js +25 -0
  133. package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -0
  134. package/dist/cjs/transformations/blog-comments.d.ts +30 -0
  135. package/dist/cjs/transformations/blog-comments.d.ts.map +1 -0
  136. package/dist/cjs/transformations/blog-comments.js +34 -0
  137. package/dist/cjs/transformations/blog-comments.js.map +1 -0
  138. package/dist/cjs/transformations/blog-post.d.ts +2 -0
  139. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  140. package/dist/cjs/transformations/blog-post.js +4 -1
  141. package/dist/cjs/transformations/blog-post.js.map +1 -1
  142. package/dist/cjs/transformations/ecommerce-product.d.ts +9 -1
  143. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  144. package/dist/cjs/transformations/ecommerce-product.js +28 -1
  145. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  146. package/dist/cjs/transformations/heading-anchors.d.ts +25 -0
  147. package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -0
  148. package/dist/cjs/transformations/heading-anchors.js +29 -0
  149. package/dist/cjs/transformations/heading-anchors.js.map +1 -0
  150. package/dist/cjs/transformations/index.d.ts +61 -4
  151. package/dist/cjs/transformations/index.d.ts.map +1 -1
  152. package/dist/cjs/transformations/index.js +69 -19
  153. package/dist/cjs/transformations/index.js.map +1 -1
  154. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  155. package/dist/cjs/transformations/shared-utils.js +1 -1
  156. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  157. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  158. package/dist/cjs/utils.d.ts +11 -1
  159. package/dist/cjs/utils.d.ts.map +1 -1
  160. package/dist/cjs/utils.js +60 -16
  161. package/dist/cjs/utils.js.map +1 -1
  162. package/dist/esm/array-overlap-sql.d.ts +21 -0
  163. package/dist/esm/array-overlap-sql.d.ts.map +1 -0
  164. package/dist/esm/array-overlap-sql.js +21 -0
  165. package/dist/esm/array-overlap-sql.js.map +1 -0
  166. package/dist/esm/content-localization.d.ts +37 -0
  167. package/dist/esm/content-localization.d.ts.map +1 -0
  168. package/dist/esm/content-localization.js +24 -0
  169. package/dist/esm/content-localization.js.map +1 -0
  170. package/dist/esm/count-effect.d.ts +11 -1
  171. package/dist/esm/count-effect.d.ts.map +1 -1
  172. package/dist/esm/count-effect.js +28 -1
  173. package/dist/esm/count-effect.js.map +1 -1
  174. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  175. package/dist/esm/data-source-fetchers.js +11 -5
  176. package/dist/esm/data-source-fetchers.js.map +1 -1
  177. package/dist/esm/fetchers/airtable.d.ts.map +1 -1
  178. package/dist/esm/fetchers/airtable.js +1 -1
  179. package/dist/esm/fetchers/airtable.js.map +1 -1
  180. package/dist/esm/fetchers/clickhouse.d.ts.map +1 -1
  181. package/dist/esm/fetchers/clickhouse.js +1 -1
  182. package/dist/esm/fetchers/clickhouse.js.map +1 -1
  183. package/dist/esm/fetchers/firestore.d.ts.map +1 -1
  184. package/dist/esm/fetchers/firestore.js +1 -1
  185. package/dist/esm/fetchers/firestore.js.map +1 -1
  186. package/dist/esm/fetchers/javascript.d.ts.map +1 -1
  187. package/dist/esm/fetchers/javascript.js +1 -1
  188. package/dist/esm/fetchers/javascript.js.map +1 -1
  189. package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
  190. package/dist/esm/fetchers/mariadb.js +1 -1
  191. package/dist/esm/fetchers/mariadb.js.map +1 -1
  192. package/dist/esm/fetchers/mongodb.d.ts.map +1 -1
  193. package/dist/esm/fetchers/mongodb.js +1 -1
  194. package/dist/esm/fetchers/mongodb.js.map +1 -1
  195. package/dist/esm/fetchers/mysql.d.ts.map +1 -1
  196. package/dist/esm/fetchers/mysql.js +1 -1
  197. package/dist/esm/fetchers/mysql.js.map +1 -1
  198. package/dist/esm/fetchers/postgresql.d.ts +1 -1
  199. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  200. package/dist/esm/fetchers/postgresql.js +9 -4
  201. package/dist/esm/fetchers/postgresql.js.map +1 -1
  202. package/dist/esm/fetchers/raw-query.d.ts +8 -1
  203. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  204. package/dist/esm/fetchers/raw-query.js +42 -4
  205. package/dist/esm/fetchers/raw-query.js.map +1 -1
  206. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  207. package/dist/esm/fetchers/redshift.js +1 -1
  208. package/dist/esm/fetchers/redshift.js.map +1 -1
  209. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  210. package/dist/esm/fetchers/rest-api.js +7 -2
  211. package/dist/esm/fetchers/rest-api.js.map +1 -1
  212. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  213. package/dist/esm/fetchers/teleport.js +16 -6
  214. package/dist/esm/fetchers/teleport.js.map +1 -1
  215. package/dist/esm/fetchers/turso.d.ts.map +1 -1
  216. package/dist/esm/fetchers/turso.js +1 -1
  217. package/dist/esm/fetchers/turso.js.map +1 -1
  218. package/dist/esm/fetchers/utils/browser-row-policy.d.ts +43 -0
  219. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -0
  220. package/dist/esm/fetchers/utils/browser-row-policy.js +60 -0
  221. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -0
  222. package/dist/esm/fetchers/utils/request-sql-guards.d.ts +8 -0
  223. package/dist/esm/fetchers/utils/request-sql-guards.d.ts.map +1 -0
  224. package/dist/esm/fetchers/utils/request-sql-guards.js +8 -0
  225. package/dist/esm/fetchers/utils/request-sql-guards.js.map +1 -0
  226. package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts +40 -0
  227. package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
  228. package/dist/esm/fetchers/utils/rest-api-response-cache.js +59 -0
  229. package/dist/esm/fetchers/utils/rest-api-response-cache.js.map +1 -0
  230. package/dist/esm/fetchers/utils/table-access-guard.d.ts +23 -0
  231. package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -0
  232. package/dist/esm/fetchers/utils/table-access-guard.js +29 -0
  233. package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -0
  234. package/dist/esm/index.d.ts +5 -0
  235. package/dist/esm/index.d.ts.map +1 -1
  236. package/dist/esm/index.js +55 -7
  237. package/dist/esm/index.js.map +1 -1
  238. package/dist/esm/items-path.d.ts +10 -0
  239. package/dist/esm/items-path.d.ts.map +1 -0
  240. package/dist/esm/items-path.js +58 -0
  241. package/dist/esm/items-path.js.map +1 -0
  242. package/dist/esm/loading-state.d.ts +16 -1
  243. package/dist/esm/loading-state.d.ts.map +1 -1
  244. package/dist/esm/loading-state.js +69 -3
  245. package/dist/esm/loading-state.js.map +1 -1
  246. package/dist/esm/localized-columns.d.ts +56 -0
  247. package/dist/esm/localized-columns.d.ts.map +1 -0
  248. package/dist/esm/localized-columns.js +132 -0
  249. package/dist/esm/localized-columns.js.map +1 -0
  250. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  251. package/dist/esm/pagination-plugin.js +141 -51
  252. package/dist/esm/pagination-plugin.js.map +1 -1
  253. package/dist/esm/product-filter-fields.d.ts +87 -0
  254. package/dist/esm/product-filter-fields.d.ts.map +1 -0
  255. package/dist/esm/product-filter-fields.js +154 -0
  256. package/dist/esm/product-filter-fields.js.map +1 -0
  257. package/dist/esm/request-locale.d.ts +49 -0
  258. package/dist/esm/request-locale.d.ts.map +1 -0
  259. package/dist/esm/request-locale.js +98 -0
  260. package/dist/esm/request-locale.js.map +1 -0
  261. package/dist/esm/search-clear.d.ts +31 -0
  262. package/dist/esm/search-clear.d.ts.map +1 -0
  263. package/dist/esm/search-clear.js +74 -0
  264. package/dist/esm/search-clear.js.map +1 -0
  265. package/dist/esm/transformations/blog-adjacent-posts.d.ts +16 -0
  266. package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -0
  267. package/dist/esm/transformations/blog-adjacent-posts.js +21 -0
  268. package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -0
  269. package/dist/esm/transformations/blog-comments.d.ts +30 -0
  270. package/dist/esm/transformations/blog-comments.d.ts.map +1 -0
  271. package/dist/esm/transformations/blog-comments.js +30 -0
  272. package/dist/esm/transformations/blog-comments.js.map +1 -0
  273. package/dist/esm/transformations/blog-post.d.ts +2 -0
  274. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  275. package/dist/esm/transformations/blog-post.js +4 -1
  276. package/dist/esm/transformations/blog-post.js.map +1 -1
  277. package/dist/esm/transformations/ecommerce-product.d.ts +9 -1
  278. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  279. package/dist/esm/transformations/ecommerce-product.js +29 -2
  280. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  281. package/dist/esm/transformations/heading-anchors.d.ts +25 -0
  282. package/dist/esm/transformations/heading-anchors.d.ts.map +1 -0
  283. package/dist/esm/transformations/heading-anchors.js +25 -0
  284. package/dist/esm/transformations/heading-anchors.js.map +1 -0
  285. package/dist/esm/transformations/index.d.ts +61 -4
  286. package/dist/esm/transformations/index.d.ts.map +1 -1
  287. package/dist/esm/transformations/index.js +69 -19
  288. package/dist/esm/transformations/index.js.map +1 -1
  289. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  290. package/dist/esm/transformations/shared-utils.js +1 -1
  291. package/dist/esm/transformations/shared-utils.js.map +1 -1
  292. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  293. package/dist/esm/utils.d.ts +11 -1
  294. package/dist/esm/utils.d.ts.map +1 -1
  295. package/dist/esm/utils.js +60 -16
  296. package/dist/esm/utils.js.map +1 -1
  297. package/package.json +5 -5
  298. package/src/array-overlap-sql.ts +41 -0
  299. package/src/content-localization.ts +53 -0
  300. package/src/count-effect.ts +53 -2
  301. package/src/data-source-fetchers.ts +41 -5
  302. package/src/fetchers/airtable.ts +4 -2
  303. package/src/fetchers/clickhouse.ts +4 -2
  304. package/src/fetchers/firestore.ts +4 -2
  305. package/src/fetchers/javascript.ts +9 -1
  306. package/src/fetchers/mariadb.ts +4 -2
  307. package/src/fetchers/mongodb.ts +4 -2
  308. package/src/fetchers/mysql.ts +4 -2
  309. package/src/fetchers/postgresql.ts +53 -12
  310. package/src/fetchers/raw-query.ts +60 -5
  311. package/src/fetchers/redshift.ts +4 -2
  312. package/src/fetchers/rest-api.ts +27 -5
  313. package/src/fetchers/teleport.ts +85 -22
  314. package/src/fetchers/turso.ts +4 -2
  315. package/src/fetchers/utils/browser-row-policy.ts +212 -0
  316. package/src/fetchers/utils/request-sql-guards.ts +25 -0
  317. package/src/fetchers/utils/rest-api-response-cache.ts +188 -0
  318. package/src/fetchers/utils/table-access-guard.ts +39 -0
  319. package/src/index.ts +79 -11
  320. package/src/items-path.ts +74 -0
  321. package/src/loading-state.ts +134 -3
  322. package/src/localized-columns.ts +146 -0
  323. package/src/pagination-plugin.ts +206 -60
  324. package/src/product-filter-fields.ts +173 -0
  325. package/src/request-locale.ts +113 -0
  326. package/src/search-clear.ts +102 -0
  327. package/src/transformations/blog-adjacent-posts.ts +74 -0
  328. package/src/transformations/blog-comments.ts +132 -0
  329. package/src/transformations/blog-post.ts +38 -5
  330. package/src/transformations/ecommerce-product.ts +568 -5
  331. package/src/transformations/heading-anchors.ts +85 -0
  332. package/src/transformations/index.ts +191 -23
  333. package/src/transformations/shared-utils.ts +6 -0
  334. package/src/utils.ts +109 -19
@@ -0,0 +1,25 @@
1
+ {
2
+ "config": {
3
+ "standard": { "route": "/products", "attribute": "slug" },
4
+ "pages": {
5
+ "personalise": { "route": "/personalise", "attribute": "slug", "name": "Personalise" },
6
+ "root-page": { "route": "/", "attribute": "slug", "name": "Root" },
7
+ "by-id": { "route": "/studio/items", "attribute": "id", "name": "By id" },
8
+ "by-sku": { "route": "/engrave", "attribute": "sku", "name": "By SKU" }
9
+ }
10
+ },
11
+ "cases": [
12
+ { "name": "no page", "raw": null, "values": { "slug": "mug", "id": "7" }, "key": "", "url": "/products/mug" },
13
+ { "name": "blank page", "raw": "", "values": { "slug": "mug", "id": "7" }, "key": "", "url": "/products/mug" },
14
+ { "name": "spaces around the key", "raw": " personalise ", "values": { "slug": "postcard", "id": "1" }, "key": "personalise", "url": "/personalise/postcard" },
15
+ { "name": "custom page", "raw": "personalise", "values": { "slug": "postcard", "id": "1" }, "key": "personalise", "url": "/personalise/postcard" },
16
+ { "name": "key no page carries", "raw": "deleted-page", "values": { "slug": "mug", "id": "7" }, "key": "", "url": "/products/mug" },
17
+ { "name": "case matters", "raw": "Personalise", "values": { "slug": "mug", "id": "7" }, "key": "", "url": "/products/mug" },
18
+ { "name": "page at the site root", "raw": "root-page", "values": { "slug": "tee", "id": "3" }, "key": "root-page", "url": "/tee" },
19
+ { "name": "page keyed by id", "raw": "by-id", "values": { "slug": "tee", "id": "3" }, "key": "by-id", "url": "/studio/items/3" },
20
+ { "name": "value is encoded", "raw": "personalise", "values": { "slug": "café crème", "id": "4" }, "key": "personalise", "url": "/personalise/caf%C3%A9%20cr%C3%A8me" },
21
+ { "name": "no value, no address", "raw": "personalise", "values": { "slug": "", "id": "4" }, "key": "personalise", "url": "" },
22
+ { "name": "a number is not a key", "raw": 5, "values": { "slug": "mug", "id": "7" }, "key": "", "url": "/products/mug" },
23
+ { "name": "page keyed by another column", "raw": "by-sku", "values": { "slug": "ring", "id": "9", "sku": "RING-01" }, "key": "by-sku", "url": "/engrave/RING-01" }
24
+ ]
25
+ }
@@ -130,7 +130,9 @@ describe('the generated Postgres route', () => {
130
130
  const ordersRoute = generatePostgreSQLFetcher(PG_CONFIG, 'teleport_orders')
131
131
  expect(ordersRoute).toContain('sortFieldSql(sort.field)')
132
132
  expect(ordersRoute).not.toContain('if (field ===')
133
- expect(ordersRoute).not.toContain('jsonb_array_elements')
133
+ // The discount sub-select's expansion — not `jsonb_array_elements_text`,
134
+ // which the list-column filter (array-overlap-sql.ts) uses on every table.
135
+ expect(ordersRoute).not.toContain('jsonb_array_elements(')
134
136
  })
135
137
 
136
138
  it('still parses for a non-products table', () => {
@@ -263,7 +265,7 @@ describe('the fetcher a generated storefront actually gets', () => {
263
265
  'teleport_orders'
264
266
  )
265
267
  expect(orders).not.toContain('if (field ===')
266
- expect(orders).not.toContain('jsonb_array_elements')
268
+ expect(orders).not.toContain('jsonb_array_elements(')
267
269
  expect(() => parse(orders, { sourceType: 'module', plugins: ['jsx'] })).not.toThrow()
268
270
  })
269
271
 
@@ -0,0 +1,111 @@
1
+ import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
2
+ import {
3
+ getTransformExpression,
4
+ getTransformWrapperCode,
5
+ RAW_ROWS_PARAM,
6
+ } from '../src/transformations'
7
+
8
+ // The generated admin's edit forms write every column they load back, so they
9
+ // ask a transformed table (products, blog posts, custom pages) for its rows AS
10
+ // STORED. Fed the product view model instead, a digital product's form showed
11
+ // it as not digital, ticked the gift-card switch of a product that is not one,
12
+ // lost its discounts, and every save wrote `discounts`, `category_ids`,
13
+ // `download_limit`, … back blank. These evaluate the EMITTED code.
14
+
15
+ type Runtime = {
16
+ rawRecords: (records: unknown, getClient: unknown) => Promise<Array<Record<string, unknown>>>
17
+ wantsRawRows: (query: unknown) => boolean
18
+ }
19
+
20
+ const ASSET_ID = 'asset-7f3c'
21
+ const ASSET_URL = 'https://cdn.example.com/uploads/cover.png'
22
+
23
+ // `getAssetMap` reads the project's asset table through `getClient()`; the
24
+ // fake client answers that one query with a single uploaded asset.
25
+ const fakeGetClient = () => ({
26
+ connect: async () => undefined,
27
+ end: async () => undefined,
28
+ query: async () => ({ rows: [{ id: ASSET_ID, remote_src: ASSET_URL, src: ASSET_URL }] }),
29
+ })
30
+
31
+ const load = (tableName: string): Runtime => {
32
+ const bundle = generateSharedTransformationCode() + '\n' + getTransformWrapperCode(tableName)
33
+ return new Function(
34
+ bundle + '\nreturn { rawRecords: rawRecords, wantsRawRows: __tqWantsRawRows };'
35
+ )() as Runtime
36
+ }
37
+
38
+ const storedProduct = () => ({
39
+ id: 'p-1',
40
+ name: 'test',
41
+ status: null,
42
+ is_digital: true,
43
+ is_gift_card: false,
44
+ discounts: '[{"id":"d1","type":"fixed","value":10,"startsAt":null,"endsAt":null}]',
45
+ category_ids: '["cat_1"]',
46
+ download_limit: 3,
47
+ image_url: ASSET_ID,
48
+ gallery_images: `["https://img.example.com/a.png","${ASSET_ID}"]`,
49
+ metadata: '{"note":"asset-7f3c is only text here"}',
50
+ })
51
+
52
+ describe('raw-rows mode of a transformed table', () => {
53
+ it('is chosen by the request parameter, as sent by a server call or over HTTP', () => {
54
+ const { wantsRawRows } = load('teleport_products')
55
+ expect(wantsRawRows({ [RAW_ROWS_PARAM]: true })).toBe(true)
56
+ expect(wantsRawRows({ [RAW_ROWS_PARAM]: 'true' })).toBe(true)
57
+ expect(wantsRawRows({ [RAW_ROWS_PARAM]: 'false' })).toBe(false)
58
+ expect(wantsRawRows({})).toBe(false)
59
+ expect(wantsRawRows(undefined)).toBe(false)
60
+ })
61
+
62
+ it('returns every stored column untouched — booleans, NULLs and the columns the view model drops', async () => {
63
+ const [row] = await load('teleport_products').rawRecords([storedProduct()], fakeGetClient)
64
+ expect(row.is_digital).toBe(true)
65
+ expect(row.is_gift_card).toBe(false)
66
+ expect(row.status).toBeNull()
67
+ expect(row.discounts).toBe(storedProduct().discounts)
68
+ expect(row.category_ids).toBe('["cat_1"]')
69
+ expect(row.download_limit).toBe(3)
70
+ expect(Object.keys(row).sort()).toEqual(Object.keys(storedProduct()).sort())
71
+ })
72
+
73
+ it('turns an uploaded asset id into its URL, alone or inside a JSON list, and nothing else', async () => {
74
+ const [row] = await load('teleport_products').rawRecords([storedProduct()], fakeGetClient)
75
+ expect(row.image_url).toBe(ASSET_URL)
76
+ expect(JSON.parse(row.gallery_images as string)).toEqual([
77
+ 'https://img.example.com/a.png',
78
+ ASSET_URL,
79
+ ])
80
+ // An object column that merely mentions the id is not a reference.
81
+ expect(row.metadata).toBe(storedProduct().metadata)
82
+ })
83
+
84
+ it('passes the rows through when the asset lookup fails', async () => {
85
+ const failingClient = () => ({
86
+ connect: async () => {
87
+ throw new Error('down')
88
+ },
89
+ end: async () => undefined,
90
+ query: async () => ({ rows: [] }),
91
+ })
92
+ const [row] = await load('teleport_products').rawRecords([storedProduct()], failingClient)
93
+ expect(row.image_url).toBe(ASSET_ID)
94
+ })
95
+
96
+ it('is carried by every transformed table, and the handler picks it per request', async () => {
97
+ for (const table of ['teleport_products', 'teleport_blog_posts', 'teleport_pages']) {
98
+ expect(getTransformWrapperCode(table)).toContain('async function rawRecords(')
99
+ const expression = getTransformExpression(table)!
100
+ expect(expression).toContain('__tqWantsRawRows(req.query)')
101
+ expect(expression).toContain('rawRecords(safeData, getClient)')
102
+ expect(expression).toContain('transformRecords(safeData, getClient, req.query)')
103
+ }
104
+ expect(getTransformExpression('orders')).toBeNull()
105
+ const [page] = await load('teleport_pages').rawRecords(
106
+ [{ id: 'pg', blocks: '[]', no_index: 'true' }],
107
+ fakeGetClient
108
+ )
109
+ expect(page).toEqual({ id: 'pg', blocks: '[]', no_index: 'true' })
110
+ })
111
+ })
@@ -178,6 +178,28 @@ describe('pagination plugin — refetch loading state', () => {
178
178
  expect(code).toContain('ds_0_fetchesInFlight.current = 0')
179
179
  })
180
180
 
181
+ it('hands an overtaken response the newest rows, so a slow older request never paints over a newer one', async () => {
182
+ const code = await runPlugin({})
183
+
184
+ expect(code).toContain('const ds_0_fetchSeq = useRef(0)')
185
+ expect(code).toContain('const ds_0_latestData = useRef(undefined)')
186
+ // Numbered when it STARTS, before the flag goes up.
187
+ expect(code).toMatch(
188
+ /const __tqSeq = \+\+ds_0_fetchSeq\.current;?\s*ds_0_fetchesInFlight\.current \+= 1/
189
+ )
190
+ // Checked when it settles: overtaken → the newest settled rows (or its own
191
+ // while the successor is still in flight); newest → recorded and returned.
192
+ expect(code).toContain('if (__tqSeq !== ds_0_fetchSeq.current)')
193
+ expect(code).toContain(
194
+ 'return ds_0_latestData.current !== undefined ? ds_0_latestData.current : __tqRows'
195
+ )
196
+ expect(code).toContain('ds_0_latestData.current = __tqRows')
197
+ // The guard sits on the chain BEFORE the settle handler.
198
+ expect(code.indexOf('__tqSeq !== ds_0_fetchSeq.current')).toBeLessThan(
199
+ code.indexOf('.finally(() => {')
200
+ )
201
+ })
202
+
181
203
  it('keeps fetchData referentially stable so the provider does not refetch in a loop', async () => {
182
204
  const code = await runPlugin({})
183
205
 
@@ -249,6 +271,7 @@ describe('pagination plugin — refetch loading state', () => {
249
271
  const code = generator(chunk.content as types.Node).code
250
272
  expect(code).not.toContain('ds_0_isFetching')
251
273
  expect(code).not.toContain('ds_0_fetchesInFlight')
274
+ expect(code).not.toContain('ds_0_fetchSeq')
252
275
  })
253
276
 
254
277
  it('leaves a provider without a designed loading state untouched', async () => {
@@ -0,0 +1,434 @@
1
+ import { DataCache } from '@teleporthq/teleport-shared'
2
+ import { UIDLDataSource } from '@teleporthq/teleport-types'
3
+ import { generateRESTAPIFetcher } from '../src/fetchers/rest-api'
4
+ import { generateDataSourceFetcherWithCore } from '../src/data-source-fetchers'
5
+ import { resolveRESTAPICacheTTLSeconds } from '../src/fetchers/utils/rest-api-response-cache'
6
+
7
+ /**
8
+ * The REST API data source's response cache, asserted on BEHAVIOUR: every test
9
+ * runs the generated handler against a counting `fetch`, the same way the
10
+ * published app runs it.
11
+ */
12
+
13
+ type Handler = (req: Record<string, unknown>, res: Record<string, unknown>) => Promise<unknown>
14
+
15
+ interface Captured {
16
+ status: number
17
+ payload: Record<string, unknown>
18
+ headers: Record<string, string>
19
+ }
20
+
21
+ const URL = 'https://api.test/items'
22
+ const cachedConfig = (ttlSeconds: number, extra: Record<string, unknown> = {}) => ({
23
+ url: URL,
24
+ method: 'GET',
25
+ cache: { enabled: true, ttlSeconds },
26
+ ...extra,
27
+ })
28
+
29
+ const upstreamResponse = (body: unknown, status = 200) => ({
30
+ ok: status >= 200 && status < 300,
31
+ status,
32
+ statusText: status === 200 ? 'OK' : 'Service Unavailable',
33
+ text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
34
+ json: () => Promise.resolve(body),
35
+ })
36
+
37
+ const loadHandler = (code: string, fetchImpl: jest.Mock): Handler => {
38
+ const withoutImports = code.replace(/^import\s+.*?$/gm, '')
39
+ const factory = new Function(
40
+ 'fetch',
41
+ `${withoutImports.replace('export default async function handler', 'async function handler')}
42
+ return handler`
43
+ )
44
+ return factory(fetchImpl)
45
+ }
46
+
47
+ /** A generated `utils/data-sources` module, with its ES exports turned into a return. */
48
+ const loadDataSourceModule = (code: string, fetchImpl: jest.Mock) => {
49
+ const body = code.replace(/^import\s+.*?$/gm, '').replace(/^export\s+.*$/gm, '')
50
+ const factory = new Function('fetch', `${body}\nreturn { fetchData, fetchCount }`)
51
+ return factory(fetchImpl) as {
52
+ fetchData: (params?: Record<string, unknown>) => Promise<unknown>
53
+ fetchCount: (params?: Record<string, unknown>) => Promise<number>
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Calls the handler like a Next API route does. `withSetHeader: false` is the
59
+ * bare `{ status, json }` object fetchData, fetchCount and tqWithCache pass.
60
+ */
61
+ const call = async (
62
+ handler: Handler,
63
+ req: Record<string, unknown> = {},
64
+ withSetHeader = true
65
+ ): Promise<Captured> => {
66
+ const captured: Captured = { status: 200, payload: {}, headers: {} }
67
+ const res: Record<string, unknown> = {
68
+ status: (code: number) => {
69
+ captured.status = code
70
+ return res
71
+ },
72
+ json: (data: Record<string, unknown>) => {
73
+ captured.payload = data
74
+ return res
75
+ },
76
+ }
77
+ if (withSetHeader) {
78
+ res.setHeader = (name: string, value: string) => {
79
+ captured.headers[name] = value
80
+ }
81
+ }
82
+ await handler({ query: {}, method: 'GET', ...req }, res)
83
+ return captured
84
+ }
85
+
86
+ const ROWS = [
87
+ { id: 1, name: 'b' },
88
+ { id: 2, name: 'c' },
89
+ { id: 3, name: 'a' },
90
+ ]
91
+
92
+ let now = 1_000_000
93
+
94
+ beforeEach(() => {
95
+ delete (globalThis as Record<string, unknown>).__tqRestApiCache
96
+ now = 1_000_000
97
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
98
+ jest.spyOn(console, 'error').mockImplementation(() => undefined)
99
+ })
100
+
101
+ afterEach(() => {
102
+ jest.restoreAllMocks()
103
+ delete (globalThis as Record<string, unknown>).__tqRestApiCache
104
+ })
105
+
106
+ describe('resolveRESTAPICacheTTLSeconds', () => {
107
+ it('is off unless the cache is explicitly enabled', () => {
108
+ expect(resolveRESTAPICacheTTLSeconds(undefined)).toBeUndefined()
109
+ expect(resolveRESTAPICacheTTLSeconds({ enabled: false, ttlSeconds: 60 })).toBeUndefined()
110
+ expect(resolveRESTAPICacheTTLSeconds({ ttlSeconds: 60 })).toBeUndefined()
111
+ })
112
+
113
+ it('uses the configured window in whole seconds', () => {
114
+ expect(resolveRESTAPICacheTTLSeconds({ enabled: true, ttlSeconds: 90 })).toBe(90)
115
+ expect(resolveRESTAPICacheTTLSeconds({ enabled: true, ttlSeconds: 90.9 })).toBe(90)
116
+ expect(resolveRESTAPICacheTTLSeconds({ enabled: true, ttlSeconds: 0.2 })).toBe(1)
117
+ })
118
+
119
+ it('falls back to the shared default when the window is missing or unusable', () => {
120
+ const fallback = DataCache.DEFAULT_CACHE_TTL_SECONDS
121
+ expect(resolveRESTAPICacheTTLSeconds({ enabled: true })).toBe(fallback)
122
+ expect(resolveRESTAPICacheTTLSeconds({ enabled: true, ttlSeconds: 0 })).toBe(fallback)
123
+ expect(resolveRESTAPICacheTTLSeconds({ enabled: true, ttlSeconds: -5 })).toBe(fallback)
124
+ expect(resolveRESTAPICacheTTLSeconds({ enabled: true, ttlSeconds: NaN })).toBe(fallback)
125
+ expect(
126
+ resolveRESTAPICacheTTLSeconds({ enabled: true, ttlSeconds: '60' as unknown as number })
127
+ ).toBe(fallback)
128
+ })
129
+
130
+ it('never emits a window longer than a year', () => {
131
+ expect(resolveRESTAPICacheTTLSeconds({ enabled: true, ttlSeconds: 10 ** 12 })).toBe(31_536_000)
132
+ })
133
+ })
134
+
135
+ describe('generateRESTAPIFetcher — response cache', () => {
136
+ /** The regression pin: a data source that does not cache generates what it always did. */
137
+ it('emits byte-identical output when caching is absent or disabled', () => {
138
+ const base = { url: URL, method: 'GET' }
139
+ const uncached = generateRESTAPIFetcher(base)
140
+
141
+ expect(generateRESTAPIFetcher({ ...base, cache: { enabled: false, ttlSeconds: 60 } })).toBe(
142
+ uncached
143
+ )
144
+ expect(uncached).not.toContain('__tqRestApiCache')
145
+ expect(uncached).toContain('const response = await fetch(url, options)')
146
+ expect(uncached).toContain('let data = await response.json()')
147
+ })
148
+
149
+ it('emits the configured window in milliseconds', () => {
150
+ const code = generateRESTAPIFetcher(cachedConfig(90))
151
+
152
+ expect(code).toContain('const REST_API_CACHE_TTL_MS = 90000')
153
+ expect(code).toContain('const response = await fetchRestApiWithCache(url, options)')
154
+ expect(code).toContain('let data = JSON.parse(response.bodyText)')
155
+ })
156
+
157
+ it('calls the URL once and serves every later request in the window from memory', async () => {
158
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
159
+ const handler = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
160
+
161
+ const first = await call(handler)
162
+ now += 59_000
163
+ const second = await call(handler, { query: { page: '1', perPage: '2' } })
164
+ const third = await call(handler, { query: { query: 'a', queryColumns: '["name"]' } })
165
+
166
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
167
+ expect(first.payload).toMatchObject({ success: true, data: ROWS })
168
+ expect(second.payload.data).toEqual(ROWS.slice(0, 2))
169
+ expect(third.payload.data).toEqual([{ id: 3, name: 'a' }])
170
+ })
171
+
172
+ it('calls the URL again once the window has passed', async () => {
173
+ const fetchImpl = jest
174
+ .fn()
175
+ .mockResolvedValueOnce(upstreamResponse(ROWS))
176
+ .mockResolvedValueOnce(upstreamResponse([{ id: 9, name: 'fresh' }]))
177
+ const handler = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
178
+
179
+ await call(handler)
180
+ now += 60_000
181
+ const refreshed = await call(handler)
182
+ now += 1_000
183
+ const servedFromNewEntry = await call(handler)
184
+
185
+ expect(fetchImpl).toHaveBeenCalledTimes(2)
186
+ expect(refreshed.payload.data).toEqual([{ id: 9, name: 'fresh' }])
187
+ expect(servedFromNewEntry.payload.data).toEqual([{ id: 9, name: 'fresh' }])
188
+ })
189
+
190
+ /** A clock stepped back (NTP) must not make an entry look fresh for longer than its window. */
191
+ it('calls the URL again when the clock has moved backwards', async () => {
192
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
193
+ const handler = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
194
+
195
+ await call(handler)
196
+ now -= 10_000
197
+ await call(handler)
198
+
199
+ expect(fetchImpl).toHaveBeenCalledTimes(2)
200
+ })
201
+
202
+ it('shares one upstream call between concurrent requests', async () => {
203
+ let resolveUpstream: (value: unknown) => void = () => undefined
204
+ const fetchImpl = jest.fn(
205
+ () =>
206
+ new Promise((resolve) => {
207
+ resolveUpstream = resolve
208
+ })
209
+ )
210
+ const handler = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
211
+
212
+ const pending = [call(handler), call(handler, { query: { page: '2', perPage: '1' } })]
213
+ resolveUpstream(upstreamResponse(ROWS))
214
+ const [all, pageTwo] = await Promise.all(pending)
215
+
216
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
217
+ expect(all.payload.data).toEqual(ROWS)
218
+ expect(pageTwo.payload.data).toEqual([ROWS[1]])
219
+ })
220
+
221
+ it('stops waiting on an upstream call that has been pending for too long', async () => {
222
+ const fetchImpl = jest
223
+ .fn()
224
+ .mockImplementationOnce(() => new Promise(() => undefined))
225
+ .mockResolvedValueOnce(upstreamResponse(ROWS))
226
+ const handler = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
227
+
228
+ // Never settles: stands in for a request stuck on a dead socket.
229
+ void call(handler)
230
+ now += 10_000
231
+ const next = await call(handler)
232
+
233
+ expect(fetchImpl).toHaveBeenCalledTimes(2)
234
+ expect(next.payload.data).toEqual(ROWS)
235
+ })
236
+
237
+ it('answers an upstream error as before and never caches it', async () => {
238
+ const fetchImpl = jest
239
+ .fn()
240
+ .mockResolvedValueOnce(upstreamResponse({ message: 'down' }, 503))
241
+ .mockResolvedValueOnce(upstreamResponse(ROWS))
242
+ const handler = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
243
+
244
+ const failed = await call(handler)
245
+ const recovered = await call(handler)
246
+
247
+ expect(failed.status).toBe(503)
248
+ expect(failed.payload).toMatchObject({
249
+ success: false,
250
+ error: 'HTTP 503: Service Unavailable',
251
+ })
252
+ expect(failed.headers).toEqual({})
253
+ expect(recovered.payload.data).toEqual(ROWS)
254
+ expect(fetchImpl).toHaveBeenCalledTimes(2)
255
+ })
256
+
257
+ it('never caches a network failure or a body that is not JSON', async () => {
258
+ const fetchImpl = jest
259
+ .fn()
260
+ .mockRejectedValueOnce(new Error('socket hang up'))
261
+ .mockResolvedValueOnce(upstreamResponse('<html>maintenance</html>'))
262
+ .mockResolvedValueOnce(upstreamResponse(ROWS))
263
+ const handler = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
264
+
265
+ const networkFailure = await call(handler)
266
+ const notJson = await call(handler)
267
+ const recovered = await call(handler)
268
+
269
+ expect(networkFailure.status).toBe(500)
270
+ expect(networkFailure.payload).toMatchObject({ success: false, error: 'socket hang up' })
271
+ expect(notJson.status).toBe(500)
272
+ expect(notJson.payload.success).toBe(false)
273
+ expect(recovered.payload.data).toEqual(ROWS)
274
+ expect(fetchImpl).toHaveBeenCalledTimes(3)
275
+ })
276
+
277
+ /**
278
+ * The handler sorts in place. Were the cached payload handed out by
279
+ * reference, one visitor's sort would reorder every later visitor's rows.
280
+ */
281
+ it('gives every request its own copy of the cached payload', async () => {
282
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
283
+ const handler = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
284
+
285
+ const sorted = await call(handler, { query: { sortBy: 'name', sortOrder: 'asc' } })
286
+ const unsorted = await call(handler)
287
+
288
+ expect((sorted.payload.data as typeof ROWS).map((row) => row.name)).toEqual(['a', 'b', 'c'])
289
+ expect(unsorted.payload.data).toEqual(ROWS)
290
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
291
+ })
292
+
293
+ it('keys entries by the request body, so different bodies never share a response', async () => {
294
+ const fetchImpl = jest.fn((_url: string, options: { body?: string }) =>
295
+ Promise.resolve(upstreamResponse([{ echo: options.body }]))
296
+ )
297
+ const handler = loadHandler(
298
+ generateRESTAPIFetcher(cachedConfig(60, { method: 'POST', bodyType: 'json' })),
299
+ fetchImpl
300
+ )
301
+
302
+ const first = await call(handler, { method: 'POST', body: { q: 'first' } })
303
+ const second = await call(handler, { method: 'POST', body: { q: 'second' } })
304
+ const firstAgain = await call(handler, { method: 'POST', body: { q: 'first' } })
305
+
306
+ expect(first.payload.data).toEqual([{ echo: '{"q":"first"}' }])
307
+ expect(second.payload.data).toEqual([{ echo: '{"q":"second"}' }])
308
+ expect(firstAgain.payload.data).toEqual([{ echo: '{"q":"first"}' }])
309
+ expect(fetchImpl).toHaveBeenCalledTimes(2)
310
+ })
311
+
312
+ it('shares the store between every REST API data source module in the process', async () => {
313
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
314
+ const productsRoute = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
315
+ const globalStateRoute = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
316
+
317
+ await call(productsRoute)
318
+ const fromOtherModule = await call(globalStateRoute)
319
+
320
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
321
+ expect(fromOtherModule.payload.data).toEqual(ROWS)
322
+ })
323
+
324
+ it("judges a shared entry by the reader's own window", async () => {
325
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
326
+ const shortWindow = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
327
+ const longWindow = loadHandler(generateRESTAPIFetcher(cachedConfig(3_600)), fetchImpl)
328
+
329
+ await call(longWindow)
330
+ now += 120_000
331
+ await call(longWindow)
332
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
333
+
334
+ await call(shortWindow)
335
+ expect(fetchImpl).toHaveBeenCalledTimes(2)
336
+ })
337
+
338
+ it('drops the least recently used entry once the store is full', async () => {
339
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
340
+ const handler = loadHandler(
341
+ generateRESTAPIFetcher(cachedConfig(3_600, { method: 'POST', bodyType: 'json' })),
342
+ fetchImpl
343
+ )
344
+ const request = (index: number) => call(handler, { method: 'POST', body: { index } })
345
+
346
+ await request(0)
347
+ await request(1)
348
+ // A hit makes entry 0 recent again, which leaves entry 1 the oldest.
349
+ await request(0)
350
+ for (let index = 2; index <= 100; index++) {
351
+ await request(index)
352
+ }
353
+ expect(fetchImpl).toHaveBeenCalledTimes(101)
354
+
355
+ await request(0)
356
+ expect(fetchImpl).toHaveBeenCalledTimes(101)
357
+ await request(1)
358
+ expect(fetchImpl).toHaveBeenCalledTimes(102)
359
+ })
360
+
361
+ describe('Cache-Control', () => {
362
+ it('lets the CDN keep a GET response for what is left of the window', async () => {
363
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
364
+ const handler = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
365
+
366
+ const fresh = await call(handler)
367
+ now += 45_500
368
+ const aged = await call(handler)
369
+
370
+ expect(fresh.headers['Cache-Control']).toBe('public, max-age=0, s-maxage=60')
371
+ expect(aged.headers['Cache-Control']).toBe('public, max-age=0, s-maxage=14')
372
+ })
373
+
374
+ it('sends nothing for a request that is not a GET', async () => {
375
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
376
+ const handler = loadHandler(
377
+ generateRESTAPIFetcher(cachedConfig(60, { method: 'POST' })),
378
+ fetchImpl
379
+ )
380
+
381
+ const response = await call(handler, { method: 'POST', body: { q: 1 } })
382
+
383
+ expect(response.payload.success).toBe(true)
384
+ expect(response.headers).toEqual({})
385
+ })
386
+
387
+ it('tolerates the bare response object server-side callers pass', async () => {
388
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
389
+ const handler = loadHandler(generateRESTAPIFetcher(cachedConfig(60)), fetchImpl)
390
+
391
+ const response = await call(handler, {}, false)
392
+
393
+ expect(response.payload).toMatchObject({ success: true, data: ROWS })
394
+ })
395
+
396
+ it('is never sent by a data source that does not cache', async () => {
397
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
398
+ const handler = loadHandler(generateRESTAPIFetcher({ url: URL, method: 'GET' }), fetchImpl)
399
+
400
+ const response = await call(handler)
401
+
402
+ expect(response.payload.data).toEqual(ROWS)
403
+ expect(response.headers).toEqual({})
404
+ })
405
+ })
406
+ })
407
+
408
+ describe('generateDataSourceFetcherWithCore — cached REST API source', () => {
409
+ const dataSource: UIDLDataSource = {
410
+ id: 'ds_rest_0001',
411
+ name: 'Catalog',
412
+ type: 'rest-api',
413
+ config: cachedConfig(60),
414
+ }
415
+
416
+ /**
417
+ * A paginated page asks for its rows (getStaticProps -> fetchData) and its
418
+ * total (fetchCount) — two handler runs that must cost one upstream call.
419
+ */
420
+ it('serves fetchData and fetchCount from one upstream call', async () => {
421
+ const fetchImpl = jest.fn(() => Promise.resolve(upstreamResponse(ROWS)))
422
+ const code = generateDataSourceFetcherWithCore(dataSource, 'data', false, {})
423
+ const dataSourceModule = loadDataSourceModule(code, fetchImpl)
424
+
425
+ const [rows, count] = await Promise.all([
426
+ dataSourceModule.fetchData({ limit: '2' }),
427
+ dataSourceModule.fetchCount({}),
428
+ ])
429
+
430
+ expect(rows).toEqual(ROWS.slice(0, 2))
431
+ expect(count).toBe(3)
432
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
433
+ })
434
+ })