spree_core 5.4.3 → 5.5.0.rc2

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 (239) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +0 -82
  3. data/app/helpers/spree/currency_helper.rb +0 -12
  4. data/app/helpers/spree/products_helper.rb +0 -8
  5. data/app/jobs/spree/base_job.rb +18 -0
  6. data/app/jobs/spree/events/subscriber_job.rb +2 -1
  7. data/app/jobs/spree/exports/generate_job.rb +11 -0
  8. data/app/jobs/spree/images/save_from_url_job.rb +23 -8
  9. data/app/jobs/spree/imports/assign_tags_job.rb +11 -0
  10. data/app/jobs/spree/imports/base_job.rb +15 -0
  11. data/app/jobs/spree/imports/create_categories_job.rb +37 -0
  12. data/app/jobs/spree/imports/create_rows_job.rb +1 -3
  13. data/app/jobs/spree/imports/process_group_job.rb +8 -6
  14. data/app/jobs/spree/imports/process_rows_job.rb +1 -3
  15. data/app/jobs/spree/media/migrate_product_assets_job.rb +83 -0
  16. data/app/jobs/spree/products/refresh_metrics_job.rb +15 -4
  17. data/app/jobs/spree/reports/generate_job.rb +11 -0
  18. data/app/jobs/spree/search_provider/index_job.rb +5 -1
  19. data/app/jobs/spree/search_provider/remove_job.rb +4 -0
  20. data/app/jobs/spree/stock_reservations/expire_job.rb +11 -0
  21. data/app/models/concerns/spree/calculated_adjustments.rb +34 -1
  22. data/app/models/concerns/spree/display_on.rb +31 -0
  23. data/app/models/concerns/spree/metafields.rb +167 -5
  24. data/app/models/concerns/spree/preference_schema.rb +191 -0
  25. data/app/models/concerns/spree/prefixed_id.rb +94 -11
  26. data/app/models/concerns/spree/product_scopes.rb +36 -17
  27. data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
  28. data/app/models/concerns/spree/search_indexable.rb +8 -7
  29. data/app/models/concerns/spree/searchable.rb +11 -2
  30. data/app/models/concerns/spree/stores/channels.rb +20 -0
  31. data/app/models/concerns/spree/stores/markets.rb +21 -5
  32. data/app/models/concerns/spree/typed_associations.rb +120 -0
  33. data/app/models/concerns/spree/user_methods.rb +71 -12
  34. data/app/models/spree/ability.rb +4 -117
  35. data/app/models/spree/api_key.rb +60 -0
  36. data/app/models/spree/asset.rb +28 -5
  37. data/app/models/spree/authentication/strategy_registry.rb +72 -0
  38. data/app/models/spree/base.rb +18 -1
  39. data/app/models/spree/channel.rb +159 -0
  40. data/app/models/spree/country.rb +2 -0
  41. data/app/models/spree/current.rb +7 -3
  42. data/app/models/spree/custom_field.rb +9 -0
  43. data/app/models/spree/custom_field_definition.rb +7 -0
  44. data/app/models/spree/customer_group.rb +8 -2
  45. data/app/models/spree/export.rb +45 -3
  46. data/app/models/spree/exports/coupon_codes.rb +4 -0
  47. data/app/models/spree/exports/newsletter_subscribers.rb +4 -0
  48. data/app/models/spree/exports/product_translations.rb +4 -0
  49. data/app/models/spree/gateway.rb +25 -0
  50. data/app/models/spree/gift_card.rb +1 -1
  51. data/app/models/spree/gift_card_batch.rb +4 -1
  52. data/app/models/spree/import.rb +5 -0
  53. data/app/models/spree/import_row.rb +12 -0
  54. data/app/models/spree/line_item.rb +6 -1
  55. data/app/models/spree/market.rb +32 -1
  56. data/app/models/spree/metafield.rb +38 -0
  57. data/app/models/spree/metafield_definition.rb +29 -6
  58. data/app/models/spree/metafields/json.rb +10 -0
  59. data/app/models/spree/newsletter_subscriber.rb +19 -3
  60. data/app/models/spree/option_type.rb +48 -7
  61. data/app/models/spree/order/checkout.rb +3 -3
  62. data/app/models/spree/order.rb +102 -6
  63. data/app/models/spree/order_approval.rb +19 -0
  64. data/app/models/spree/order_cancellation.rb +19 -0
  65. data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
  66. data/app/models/spree/order_routing/rules/default_location.rb +16 -0
  67. data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
  68. data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
  69. data/app/models/spree/order_routing/strategy/base.rb +47 -0
  70. data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
  71. data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
  72. data/app/models/spree/order_routing/strategy/rules.rb +83 -0
  73. data/app/models/spree/order_routing_rule.rb +75 -0
  74. data/app/models/spree/permission_sets/configuration_management.rb +16 -0
  75. data/app/models/spree/permission_sets/product_display.rb +2 -0
  76. data/app/models/spree/permission_sets/product_management.rb +2 -0
  77. data/app/models/spree/price.rb +14 -1
  78. data/app/models/spree/price_list.rb +129 -17
  79. data/app/models/spree/price_rule.rb +11 -1
  80. data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
  81. data/app/models/spree/price_rules/market_rule.rb +16 -1
  82. data/app/models/spree/price_rules/user_rule.rb +21 -2
  83. data/app/models/spree/product/channels.rb +149 -0
  84. data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
  85. data/app/models/spree/product/slugs.rb +1 -1
  86. data/app/models/spree/product.rb +172 -31
  87. data/app/models/spree/product_publication.rb +43 -0
  88. data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
  89. data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
  90. data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
  91. data/app/models/spree/promotion/rules/country.rb +40 -18
  92. data/app/models/spree/promotion/rules/customer_group.rb +10 -1
  93. data/app/models/spree/promotion/rules/product.rb +4 -0
  94. data/app/models/spree/promotion/rules/taxon.rb +24 -1
  95. data/app/models/spree/promotion/rules/user.rb +21 -0
  96. data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
  97. data/app/models/spree/promotion.rb +22 -1
  98. data/app/models/spree/promotion_action.rb +17 -11
  99. data/app/models/spree/promotion_rule.rb +17 -18
  100. data/app/models/spree/search_provider/meilisearch.rb +12 -2
  101. data/app/models/spree/stock/availability_validator.rb +1 -1
  102. data/app/models/spree/stock/quantifier.rb +89 -9
  103. data/app/models/spree/stock_item.rb +36 -0
  104. data/app/models/spree/stock_location.rb +52 -0
  105. data/app/models/spree/stock_reservation.rb +38 -0
  106. data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
  107. data/app/models/spree/store.rb +18 -72
  108. data/app/models/spree/store_credit.rb +0 -8
  109. data/app/models/spree/store_product.rb +11 -23
  110. data/app/models/spree/taxon.rb +0 -5
  111. data/app/models/spree/user_identity.rb +1 -2
  112. data/app/models/spree/variant.rb +132 -18
  113. data/app/models/spree/variant_media.rb +46 -0
  114. data/app/models/spree/webhook_delivery.rb +1 -1
  115. data/app/models/spree/webhook_endpoint.rb +24 -0
  116. data/app/models/spree/wished_item.rb +0 -13
  117. data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
  118. data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
  119. data/app/presenters/spree/variant_presenter.rb +4 -3
  120. data/app/services/spree/addresses/update.rb +6 -8
  121. data/app/services/spree/cart/add_item.rb +10 -0
  122. data/app/services/spree/cart/empty.rb +2 -0
  123. data/app/services/spree/cart/remove_line_item.rb +10 -0
  124. data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
  125. data/app/services/spree/cart/set_quantity.rb +10 -0
  126. data/app/services/spree/carts/complete.rb +1 -0
  127. data/app/services/spree/carts/create.rb +1 -0
  128. data/app/services/spree/carts/update.rb +18 -2
  129. data/app/services/spree/carts/upsert_items.rb +6 -6
  130. data/app/services/spree/imports/row_processors/customer.rb +4 -1
  131. data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
  132. data/app/services/spree/newsletter/link_user.rb +53 -0
  133. data/app/services/spree/newsletter/subscribe.rb +31 -9
  134. data/app/services/spree/orders/approve.rb +27 -6
  135. data/app/services/spree/orders/build_shipments.rb +29 -0
  136. data/app/services/spree/orders/cancel.rb +34 -3
  137. data/app/services/spree/orders/complete.rb +53 -0
  138. data/app/services/spree/orders/create.rb +156 -0
  139. data/app/services/spree/orders/update.rb +51 -0
  140. data/app/services/spree/orders/upsert_items.rb +70 -0
  141. data/app/services/spree/prices/bulk_upsert.rb +201 -0
  142. data/app/services/spree/products/duplicator.rb +1 -1
  143. data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
  144. data/app/services/spree/sample_data/loader.rb +30 -0
  145. data/app/services/spree/stock_reservations/extend.rb +19 -0
  146. data/app/services/spree/stock_reservations/release.rb +12 -0
  147. data/app/services/spree/stock_reservations/reserve.rb +103 -0
  148. data/app/services/spree/taxons/remove_products.rb +7 -1
  149. data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
  150. data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
  151. data/config/locales/en.yml +28 -10
  152. data/config/routes.rb +9 -0
  153. data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
  154. data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
  155. data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
  156. data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
  157. data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
  158. data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
  159. data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
  160. data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
  161. data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
  162. data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
  163. data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
  164. data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
  165. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
  166. data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
  167. data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
  168. data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
  169. data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
  170. data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
  171. data/db/migrate/20260612000001_change_spree_user_identities_info_to_jsonb.rb +13 -0
  172. data/db/sample_data/channels.rb +12 -0
  173. data/db/sample_data/orders.rb +1 -1
  174. data/db/sample_data/products.csv +212 -212
  175. data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
  176. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
  177. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
  178. data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
  179. data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
  180. data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
  181. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
  182. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
  183. data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +17 -0
  184. data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
  185. data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
  186. data/lib/generators/spree/model/model_generator.rb +73 -7
  187. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
  188. data/lib/generators/spree/model/templates/model.rb.tt +28 -2
  189. data/lib/generators/spree/subscriber/subscriber_generator.rb +116 -0
  190. data/lib/generators/spree/subscriber/templates/subscriber.rb.tt +17 -0
  191. data/lib/generators/spree/subscriber/templates/subscriber_spec.rb.tt +9 -0
  192. data/lib/spree/core/configuration.rb +7 -0
  193. data/lib/spree/core/controller_helpers/auth.rb +0 -12
  194. data/lib/spree/core/controller_helpers/currency.rb +0 -17
  195. data/lib/spree/core/controller_helpers/order.rb +0 -19
  196. data/lib/spree/core/dependencies.rb +5 -2
  197. data/lib/spree/core/engine.rb +54 -7
  198. data/lib/spree/core/permission_configuration.rb +15 -0
  199. data/lib/spree/core/preferences/masking.rb +47 -0
  200. data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
  201. data/lib/spree/core/version.rb +1 -1
  202. data/lib/spree/core.rb +56 -5
  203. data/lib/spree/permitted_attributes.rb +9 -7
  204. data/lib/spree/testing_support/factories/address_factory.rb +16 -9
  205. data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
  206. data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
  207. data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
  208. data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
  209. data/lib/spree/testing_support/factories/product_factory.rb +16 -7
  210. data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
  211. data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
  212. data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
  213. data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
  214. data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
  215. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  216. data/lib/spree/testing_support/store.rb +10 -0
  217. data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
  218. data/lib/tasks/channels.rake +94 -0
  219. data/lib/tasks/cli.rake +2 -1
  220. data/lib/tasks/core.rake +1 -0
  221. data/lib/tasks/media.rake +27 -0
  222. data/lib/tasks/products.rake +4 -6
  223. data/lib/tasks/publications.rake +60 -0
  224. data/lib/tasks/upgrade.rake +211 -0
  225. metadata +87 -18
  226. data/app/finders/spree/variants/visible_finder.rb +0 -23
  227. data/app/paginators/spree/shared/paginate.rb +0 -30
  228. data/app/presenters/spree/filters/price_presenter.rb +0 -23
  229. data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
  230. data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
  231. data/app/presenters/spree/product_summary_presenter.rb +0 -27
  232. data/app/presenters/spree/variants/options_presenter.rb +0 -82
  233. data/app/services/spree/classifications/reposition.rb +0 -23
  234. data/app/sorters/spree/orders/sort.rb +0 -10
  235. data/lib/spree/core/controller_helpers/common.rb +0 -14
  236. data/lib/spree/core/token_generator.rb +0 -23
  237. data/lib/spree/database_type_utilities.rb +0 -22
  238. data/lib/spree/testing_support/bar_ability.rb +0 -14
  239. data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
data/lib/tasks/core.rake CHANGED
@@ -43,6 +43,7 @@ namespace :core do
43
43
  user_id: id,
44
44
  verified_at: updated_at,
45
45
  verification_token: nil,
46
+ store_id: Spree::Store.default&.id,
46
47
  updated_at: DateTime.current,
47
48
  created_at: DateTime.current
48
49
  }
data/lib/tasks/media.rake CHANGED
@@ -16,5 +16,32 @@ namespace :spree do
16
16
 
17
17
  puts 'Done!'
18
18
  end
19
+
20
+ # Enqueues Spree::Media::MigrateProductAssetsJob for every product that
21
+ # still has at least one variant-pinned asset. The job is idempotent, so
22
+ # re-running this task is safe.
23
+ #
24
+ # ENV vars:
25
+ # BATCH_SIZE — products fetched per scope batch (default: 500)
26
+ desc 'Enqueue jobs to migrate legacy variant-pinned images to product-level media (opt-in, 5.5)'
27
+ task migrate_master_images_to_product_media: :environment do
28
+ batch_size = ENV.fetch('BATCH_SIZE', 500).to_i
29
+ batch_size = 500 if batch_size < 1
30
+
31
+ # Subquery (not pluck) so the product set doesn't materialize in Ruby —
32
+ # important for catalogs with millions of products.
33
+ variant_product_ids = Spree::Variant
34
+ .joins("INNER JOIN #{Spree::Asset.table_name} ON " \
35
+ "#{Spree::Asset.table_name}.viewable_id = #{Spree::Variant.table_name}.id " \
36
+ "AND #{Spree::Asset.table_name}.viewable_type = 'Spree::Variant'")
37
+ .select(:product_id)
38
+
39
+ relation = Spree::Product.where(id: variant_product_ids)
40
+ relation.find_each(batch_size: batch_size) do |product|
41
+ Spree::Media::MigrateProductAssetsJob.perform_later(product.id)
42
+ end
43
+
44
+ puts "Enqueued migration jobs for #{relation.count} products on the #{Spree.queues.images} queue."
45
+ end
19
46
  end
20
47
  end
@@ -19,21 +19,19 @@ namespace :spree do
19
19
  puts "\nDone!"
20
20
  end
21
21
 
22
- desc 'Enqueue background jobs to populate product metrics for all store products'
22
+ desc 'Enqueue background jobs to populate product metrics for every product'
23
23
  task populate_metrics: :environment do
24
24
  total_count = 0
25
25
 
26
- Spree::StoreProduct.in_batches(of: 100) do |batch|
27
- jobs = batch.pluck(:product_id, :store_id).map do |product_id, store_id|
28
- Spree::Products::RefreshMetricsJob.new(product_id, store_id)
29
- end
26
+ Spree::Product.in_batches(of: 100) do |batch|
27
+ jobs = batch.pluck(:id).map { |product_id| Spree::Products::RefreshMetricsJob.new(product_id) }
30
28
  ActiveJob.perform_all_later(jobs)
31
29
  total_count += jobs.size
32
30
  print '.'
33
31
  end
34
32
 
35
33
  if total_count.zero?
36
- puts 'No store products found.'
34
+ puts 'No products found.'
37
35
  else
38
36
  puts "\nEnqueued #{total_count} jobs to refresh product metrics."
39
37
  end
@@ -0,0 +1,60 @@
1
+ namespace :spree do
2
+ namespace :upgrade do
3
+ desc <<~DESC
4
+ Populates +spree_products.store_id+ and +spree_product_publications+ from the legacy
5
+ +spree_products_stores+ join. Idempotent — re-running skips products that
6
+ already have a +store_id+ and channels that already have a publication for
7
+ the product.
8
+
9
+ Run once after upgrading to Spree 5.5+. Multi-store merchants must install
10
+ +spree_multi_store+ before running; running on a multi-store catalog without
11
+ the extension picks the earliest +spree_products_stores+ row (by
12
+ +created_at+) as the product's home store.
13
+ DESC
14
+ task populate_publications: :environment do
15
+ unless ActiveRecord::Base.connection.table_exists?(Spree::StoreProduct.table_name)
16
+ puts " #{Spree::StoreProduct.table_name} table not found — nothing to migrate."
17
+ next
18
+ end
19
+
20
+ batch_size = (ENV['BATCH_SIZE'] || 1_000).to_i
21
+ publications_created = 0
22
+
23
+ # Pass 1: per store, batch-publish products onto the store's default
24
+ # channel via +Channel#add_products+. One upsert + one touch_all per
25
+ # batch beats the previous per-product loop by orders of magnitude on
26
+ # large catalogs. +add_products+ is upsert-based with +on_duplicate:
27
+ # :skip+, so existing publications on re-run are no-ops.
28
+ Spree::Store.find_each do |store|
29
+ channel = store.default_channel
30
+ unless channel
31
+ puts " Store '#{store.name}' has no default channel — skipping."
32
+ next
33
+ end
34
+
35
+ store_publications = 0
36
+ Spree::StoreProduct.where(store_id: store.id).in_batches(of: batch_size) do |batch|
37
+ store_publications += channel.add_products(batch.pluck(:product_id))
38
+ end
39
+
40
+ publications_created += store_publications
41
+ puts " Store '#{store.name}': created #{store_publications} publication(s)" if store_publications.positive?
42
+ end
43
+
44
+ # Pass 2: assign +store_id+ on products that still don't have one,
45
+ # using the earliest legacy row per product.
46
+ products_processed = 0
47
+
48
+ Spree::Product.where(store_id: nil).find_each(batch_size: batch_size) do |product|
49
+ store_id = Spree::StoreProduct.where(product_id: product.id).order(:created_at).limit(1).pick(:store_id)
50
+ next unless store_id
51
+
52
+ product.update_column(:store_id, store_id)
53
+ products_processed += 1
54
+ end
55
+
56
+ puts " Processed #{products_processed} products"
57
+ puts " Created #{publications_created} publications"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ # spree:upgrade — runs the data-backfill rake tasks shipped with the
4
+ # currently-installed spree_core. Intended to run on production (or any
5
+ # environment) after `bundle update` + `db:migrate` have already happened
6
+ # in your deploy pipeline.
7
+ #
8
+ # Walks EVERY upgrade manifest whose `to` is ≤ the installed minor version,
9
+ # in ascending order. So an app that's been on 5.3 and jumps straight to
10
+ # 5.5 still gets the 5.3→5.4 data backfills before the 5.4→5.5 ones.
11
+ # Every step in every manifest is required to be idempotent — re-running
12
+ # the task on an already-upgraded app is a safe no-op.
13
+ #
14
+ # Usage:
15
+ # bundle exec rake spree:upgrade # walk all eligible manifests
16
+ # bundle exec rake spree:upgrade DRY_RUN=1 # print plan, run nothing
17
+ # bundle exec rake spree:upgrade STEP=channels # run one step by id (any manifest)
18
+ # bundle exec rake spree:upgrade TO=5.4 # cap the walk at this version
19
+ #
20
+ # Manifests live alongside this file at lib/spree/upgrades/<from>_to_<to>/
21
+ # manifest.yml and ship inside the spree_core gem.
22
+
23
+ require 'yaml'
24
+
25
+ namespace :spree do
26
+ desc 'Run the post-deploy upgrade tasks for the installed Spree version'
27
+ task upgrade: :environment do
28
+ Spree::Upgrade::Runner.new(
29
+ target_version: ENV['TO'],
30
+ step_id: ENV['STEP'],
31
+ dry_run: ENV['DRY_RUN'] == '1'
32
+ ).call
33
+ end
34
+ end
35
+
36
+ module Spree
37
+ module Upgrade
38
+ # Two-segment "5.5" form of the installed Spree version.
39
+ def self.installed_minor_version
40
+ Spree.version.split('.').first(2).join('.')
41
+ end
42
+
43
+ # Root directory containing N_M_to_O_P/manifest.yml files inside
44
+ # the spree_core gem.
45
+ def self.manifests_root
46
+ File.expand_path('../spree/upgrades', __dir__)
47
+ end
48
+
49
+ # All available manifest directories, parsed into { from:, to:, dir: }.
50
+ # Sorted by `to` ascending, with `from` as a tiebreaker (smallest first)
51
+ # for the rare case where two manifests share a `to` boundary.
52
+ def self.available_manifests
53
+ Dir.glob(File.join(manifests_root, '*_to_*')).filter_map do |dir|
54
+ name = File.basename(dir)
55
+ match = name.match(/\A([\d_]+)_to_([\d_]+)\z/)
56
+ next unless match
57
+
58
+ { from: match[1].tr('_', '.'), to: match[2].tr('_', '.'), dir: dir }
59
+ end.sort_by { |m| version_parts(m[:to]) + version_parts(m[:from]) }
60
+ end
61
+
62
+ def self.version_parts(v)
63
+ v.split('.').map { |s| Integer(s, 10) rescue 0 }
64
+ end
65
+
66
+ # Compare two dotted-version strings (returns -1, 0, +1).
67
+ def self.compare(a, b)
68
+ version_parts(a) <=> version_parts(b)
69
+ end
70
+
71
+ # The runner is a class (not a method) so individual concerns (selection,
72
+ # rendering, invocation) stay separable and the plan can be inspected
73
+ # without execution.
74
+ class Runner
75
+ attr_reader :target_version, :step_id, :dry_run, :target_explicit
76
+
77
+ def initialize(target_version: nil, step_id: nil, dry_run: false)
78
+ @target_explicit = !target_version.nil?
79
+ @target_version = target_version || Spree::Upgrade.installed_minor_version
80
+ @step_id = step_id
81
+ @dry_run = dry_run
82
+ end
83
+
84
+ def call
85
+ manifests = eligible_manifests
86
+
87
+ if manifests.empty?
88
+ puts " No upgrade manifests apply to Spree #{target_version} (installed: #{Spree::Upgrade.installed_minor_version})."
89
+ return
90
+ end
91
+
92
+ if step_id
93
+ run_single_step(manifests)
94
+ else
95
+ run_full_walk(manifests)
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ # In plan-mode without explicit TO, show manifests ahead of the installed
102
+ # version — the path the operator is about to walk. Otherwise filter to
103
+ # manifests whose `to` is ≤ target.
104
+ def eligible_manifests
105
+ Spree::Upgrade.available_manifests
106
+ .select { |manifest| manifest_eligible?(manifest) }
107
+ .map { |entry| load_manifest_yaml(entry) }
108
+ end
109
+
110
+ def manifest_eligible?(manifest)
111
+ if dry_run && !target_explicit
112
+ Spree::Upgrade.compare(manifest[:from], Spree::Upgrade.installed_minor_version) >= 0
113
+ else
114
+ Spree::Upgrade.compare(manifest[:to], target_version) <= 0
115
+ end
116
+ end
117
+
118
+ def load_manifest_yaml(entry)
119
+ YAML.safe_load_file(File.join(entry[:dir], 'manifest.yml'))
120
+ end
121
+
122
+ def run_full_walk(manifests)
123
+ total_steps = manifests.sum { |m| m['steps'].size }
124
+ reported_target = if dry_run && !target_explicit
125
+ manifests.map { |m| m['to'] }.compact.max_by { |v| Spree::Upgrade.version_parts(v) } || target_version
126
+ else
127
+ target_version
128
+ end
129
+ puts
130
+ puts " Walking #{manifests.size} manifest(s), #{total_steps} step(s) total. Target: Spree #{reported_target}."
131
+
132
+ manifests.each do |manifest|
133
+ print_manifest_header(manifest)
134
+ manifest['steps'].each_with_index do |step, i|
135
+ print_step(step, i + 1, manifest['steps'].size)
136
+ unless dry_run
137
+ invoke(step)
138
+ print_step_complete(step)
139
+ end
140
+ end
141
+ end
142
+
143
+ puts
144
+ puts dry_run ? ' (dry run — nothing executed)' : ' Upgrade tasks complete.'
145
+ end
146
+
147
+ # STEP=<id> looks across every eligible manifest; we need exactly one
148
+ # match. Multiple matches are likely a manifest bug (two manifests
149
+ # referencing the same step id) but we surface it rather than picking
150
+ # silently.
151
+ def run_single_step(manifests)
152
+ matches = manifests.flat_map do |manifest|
153
+ manifest['steps'].select { |s| s['id'] == step_id }.map { |s| [manifest, s] }
154
+ end
155
+
156
+ if matches.empty?
157
+ available = manifests.flat_map { |m| m['steps'] }.map { |s| s['id'] }.uniq.join(', ')
158
+ abort " No step with id '#{step_id}' in any eligible manifest. Available: #{available}"
159
+ elsif matches.size > 1
160
+ locations = matches.map { |m, _| "#{m['from']} → #{m['to']}" }.join(', ')
161
+ abort " Step id '#{step_id}' is ambiguous — defined in: #{locations}. Pass TO=<version> to narrow."
162
+ end
163
+
164
+ manifest, step = matches.first
165
+ print_manifest_header(manifest)
166
+ print_step(step, 1, 1)
167
+ unless dry_run
168
+ invoke(step)
169
+ print_step_complete(step)
170
+ end
171
+
172
+ puts
173
+ puts dry_run ? ' (dry run — nothing executed)' : " Step '#{step_id}' complete."
174
+ end
175
+
176
+ def print_manifest_header(manifest)
177
+ puts
178
+ puts " ── Spree #{manifest['from']} → #{manifest['to']} ──"
179
+ puts " Docs: #{manifest['docs']}" if manifest['docs']
180
+ end
181
+
182
+ def print_step(step, index, total)
183
+ puts
184
+ puts " Step #{index}/#{total} [#{step['id']}]"
185
+ puts " #{step['name']}"
186
+ puts " > bin/rake #{step['task']}"
187
+ return unless step['notes']
188
+
189
+ step['notes'].each_line { |line| puts " #{line.chomp}" }
190
+ end
191
+
192
+ def print_step_complete(step)
193
+ puts " ✓ #{step['task']} done."
194
+ end
195
+
196
+ def invoke(step)
197
+ task = step.fetch('task')
198
+ puts " Running #{task}..."
199
+
200
+ # Rake caches invoked tasks in-process; explicit reenable lets a
201
+ # single `rake spree:upgrade` run re-invoke aggregators that share
202
+ # subtasks across multiple manifests (e.g. two manifests both
203
+ # depending on `spree:install:migrations` would otherwise only
204
+ # invoke it once).
205
+ rake_task = Rake::Task[task]
206
+ rake_task.reenable
207
+ rake_task.invoke
208
+ end
209
+ end
210
+ end
211
+ end