spree_core 6.0.0.rc6 → 6.0.0

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/maintenance_tasks/run_job.rb +321 -0
  3. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/backfill_order_markets.rb +48 -0
  4. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/capture_methods.rb +40 -0
  5. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/deliveries.rb +15 -0
  6. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/order_coupon_codes.rb +15 -0
  7. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/promotion_option_value_rules.rb +15 -0
  8. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/rake_step.rb +83 -0
  9. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/returns.rb +20 -0
  10. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/users_to_customers.rb +28 -0
  11. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade_step.rb +44 -0
  12. data/app/models/spree/ability.rb +9 -0
  13. data/app/models/spree/maintenance_task.rb +221 -0
  14. data/app/models/spree/maintenance_task_run.rb +78 -0
  15. data/app/models/spree/upgrade_record.rb +62 -0
  16. data/app/services/spree/maintenance_tasks/argument_parser.rb +23 -0
  17. data/app/services/spree/maintenance_tasks/cancel.rb +51 -0
  18. data/app/services/spree/maintenance_tasks/start.rb +127 -0
  19. data/app/services/spree/seeds/all.rb +22 -0
  20. data/config/locales/en.yml +1 -0
  21. data/db/migrate/20260813130001_add_capture_method_to_payment_methods.rb +1 -1
  22. data/db/migrate/20260815000001_create_spree_maintenance_task_runs.rb +40 -0
  23. data/db/migrate/20260816000001_create_spree_upgrade_records.rb +17 -0
  24. data/lib/generators/spree/api_resource/api_resource_generator.rb +2 -2
  25. data/lib/spree/core/configuration.rb +6 -0
  26. data/lib/spree/core/engine.rb +10 -1
  27. data/lib/spree/core/permissions/default_catalog.rb +7 -0
  28. data/lib/spree/core/version.rb +1 -1
  29. data/lib/spree/core.rb +16 -1
  30. data/lib/spree/testing_support/factories/maintenance_task_run_factory.rb +7 -0
  31. data/lib/spree/upgrade.rb +189 -0
  32. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +17 -10
  33. data/lib/tasks/maintenance_tasks.rake +42 -0
  34. data/lib/tasks/upgrade.rake +81 -60
  35. metadata +25 -6
  36. data/lib/tasks/capture_methods_migration.rake +0 -30
  37. data/lib/tasks/order_market_backfill.rake +0 -27
@@ -0,0 +1,221 @@
1
+ module Spree
2
+ # Base class for the data work an upgrade runs: one step of an upgrade
3
+ # manifest (docs/plans/6.0-maintenance-tasks.md).
4
+ #
5
+ # A task walks a stable-ordered collection and processes one record at a
6
+ # time. The runner (Spree::MaintenanceTasks::RunJob) supplies batching,
7
+ # checkpointing, cancelling and the audit row, so a task body says
8
+ # only what it does.
9
+ #
10
+ # class Spree::MaintenanceTasks::Upgrade::BackfillOrderMarkets < Spree::MaintenanceTask
11
+ # supports_dry_run
12
+ #
13
+ # def collection
14
+ # Spree::Order.where(market_id: nil).order(:id)
15
+ # end
16
+ #
17
+ # def process(order)
18
+ # return tally(:would_update) if dry_run?
19
+ #
20
+ # order.update_columns(market_id: order.store.default_market.id)
21
+ # tally(:updated)
22
+ # end
23
+ # end
24
+ #
25
+ # A manifest step names its task class, which registers it in
26
+ # `Spree.maintenance_tasks`; nothing else is runnable.
27
+ class MaintenanceTask
28
+ include ActiveModel::Model
29
+ include ActiveModel::Attributes
30
+ extend Spree::ApiTyped
31
+
32
+ # A rake body that stopped itself with `abort`.
33
+ class RakeTaskAborted < StandardError; end
34
+
35
+ # Two threads of one worker must not both find a task undefined and both
36
+ # load its file — the second load would make the task run twice.
37
+ RAKE_LOAD_LOCK = Mutex.new
38
+
39
+ DEFAULT_BATCH_SIZE = 100
40
+
41
+ # How long one execution may run before the runner checkpoints and
42
+ # re-enqueues itself, so a long step cannot hold a worker indefinitely.
43
+ MAX_RUN_TIME = 5.minutes
44
+
45
+ class_attribute :dry_run_supported, instance_writer: false, default: false
46
+ class_attribute :collection_kind, instance_writer: false, default: :relation
47
+ class_attribute :batch_size, instance_writer: false, default: DEFAULT_BATCH_SIZE
48
+
49
+ attr_accessor :run
50
+
51
+ class << self
52
+ # Declares that `process` honors `dry_run?`. The runner never fakes a
53
+ # dry run by rolling a transaction back, which would silently lie about
54
+ # external calls, enqueued jobs and cache writes.
55
+ def supports_dry_run
56
+ self.dry_run_supported = true
57
+ end
58
+
59
+ # The task performs a single operation instead of iterating; `process`
60
+ # is called once with no argument.
61
+ def no_collection
62
+ self.collection_kind = :none
63
+ end
64
+
65
+ def collection_batch_size(size)
66
+ self.batch_size = size
67
+ end
68
+
69
+ # @return [Array<Class>] registered task classes, sorted by name
70
+ def registered_classes
71
+ Spree.maintenance_tasks.filter_map do |entry|
72
+ entry.is_a?(Class) ? entry : entry.to_s.safe_constantize
73
+ end.uniq.sort_by(&:name)
74
+ end
75
+
76
+ # @param name [String] a task class name
77
+ # @return [Class, nil] nil when the name is not registered — an
78
+ # unregistered task is never runnable, so the runner and the API can
79
+ # treat nil as "no such task" without a second check
80
+ def find_registered(name)
81
+ return nil if name.blank?
82
+
83
+ registered_classes.find { |klass| klass.name == name.to_s }
84
+ end
85
+
86
+ # A task class name as the API shows it. A task that is no longer
87
+ # registered is shortened the same way rather than loaded by name.
88
+ #
89
+ # @param class_name [String]
90
+ # @return [String]
91
+ def api_type_for(class_name)
92
+ find_registered(class_name)&.api_type || class_name.to_s.demodulize.underscore
93
+ end
94
+
95
+ # The registered task an API caller names. Matches the short name only,
96
+ # so a Ruby class name arriving over the API is an unknown task.
97
+ #
98
+ # @param api_type [String] e.g. "capture_methods"
99
+ # @return [Class, nil]
100
+ def find_by_api_type(api_type)
101
+ Spree::ApiTyped.find(Spree.maintenance_tasks, api_type)
102
+ end
103
+ end
104
+
105
+ # The records to walk, ordered by primary key — the cursor is the last id
106
+ # processed, so any other order would resume in the wrong place.
107
+ #
108
+ # @return [ActiveRecord::Relation]
109
+ def collection
110
+ raise NotImplementedError, "#{self.class.name} must implement #collection or declare `no_collection`"
111
+ end
112
+
113
+ # @param item [Object] one record from the collection; omitted for
114
+ # `no_collection` tasks
115
+ def process(item = nil)
116
+ raise NotImplementedError, "#{self.class.name} must implement #process"
117
+ end
118
+
119
+ # Total records, for the progress bar. Return nil when counting is more
120
+ # expensive than the work itself — the run then reports ticks only.
121
+ #
122
+ # @return [Integer, nil]
123
+ def count
124
+ collection.count unless no_collection?
125
+ end
126
+
127
+ def no_collection?
128
+ self.class.collection_kind == :none
129
+ end
130
+
131
+ def dry_run?
132
+ run&.dry_run? || false
133
+ end
134
+
135
+ # Records a counter shown on the run page: what was updated, skipped, or
136
+ # would have changed in a dry run.
137
+ def tally(key, by = 1)
138
+ @tallies ||= Hash.new(0)
139
+ @tallies[key.to_s] += by
140
+ end
141
+
142
+ def tallies
143
+ @tallies ||= Hash.new(0)
144
+ end
145
+
146
+ # Runs an engine rake task as part of this task.
147
+ #
148
+ # Rake bodies stop themselves with `abort`, which raises SystemExit — not
149
+ # an error the runner records. Left alone it would end the whole process
150
+ # from the command line and, in a worker, leave the run marked running
151
+ # with nothing to finish it.
152
+ #
153
+ # @param task_name [String]
154
+ # @raise [RakeTaskAborted] carrying the body's own message
155
+ def invoke_rake_task(task_name)
156
+ load_engine_rake_tasks(task_name)
157
+
158
+ task = ::Rake::Task[task_name]
159
+ task.reenable
160
+ task.invoke
161
+ rescue SystemExit => e
162
+ raise RakeTaskAborted, e.message
163
+ end
164
+
165
+ # Finds the file that defines a task by reading the files rather than
166
+ # loading them: loading a .rake file to discover whether it is the right
167
+ # one would append its blocks to every task it defines, which is the very
168
+ # duplication this avoids.
169
+ #
170
+ # Searches every engine and the host application, since a step may ship in
171
+ # an extension gem. When two files define the same task name, the one
172
+ # that also declares the task's namespaces wins.
173
+ #
174
+ # @return [String, nil]
175
+ def rake_file_defining(task_name)
176
+ *namespaces, leaf = task_name.split(':')
177
+ candidates = rake_files.select do |path|
178
+ File.read(path).match?(/^\s*(?:desc\s.*\n\s*)?task\s+:?["']?#{Regexp.escape(leaf)}["']?\b/)
179
+ end
180
+
181
+ candidates.find do |path|
182
+ source = File.read(path)
183
+ namespaces.all? { |name| source.match?(/namespace\s+:?["']?#{Regexp.escape(name)}["']?\b/) }
184
+ end || candidates.first
185
+ end
186
+
187
+ # Called once the last record has been processed, before the run is
188
+ # marked succeeded — for a summary tally of what the walk left alone.
189
+ def after_complete; end
190
+
191
+ private
192
+
193
+ # Rake tasks are defined by the Rakefile, which a web process never loads —
194
+ # a task fired from the dashboard has to load the engine's tasks itself.
195
+ #
196
+ # Loads the one file that defines the task, and only when it is not already
197
+ # defined. Both halves matter: re-loading a .rake file APPENDS its block to
198
+ # a task that already exists, so a second load makes that task run twice,
199
+ # and loading every file would do that to every task in the engine. The
200
+ # symptom is invisible until something counts what it did.
201
+ #
202
+ # @param task_name [String] the rake task about to be invoked
203
+ def load_engine_rake_tasks(task_name)
204
+ require 'rake'
205
+
206
+ RAKE_LOAD_LOCK.synchronize do
207
+ next if ::Rake::Task.task_defined?(task_name)
208
+
209
+ ::Rake::Task.define_task(:environment) unless ::Rake::Task.task_defined?(:environment)
210
+
211
+ path = rake_file_defining(task_name)
212
+ load(path) if path
213
+ end
214
+ end
215
+
216
+ def rake_files
217
+ roots = Rails.application.railties.grep(Rails::Engine).map(&:root) + [Rails.root]
218
+ roots.uniq.flat_map { |root| Dir.glob(File.join(root, 'lib/tasks/**/*.rake')).sort }
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,78 @@
1
+ module Spree
2
+ # One execution of a Spree::MaintenanceTask — the durable record of what was
3
+ # run, by whom, with which arguments, and how far it got
4
+ # (docs/plans/6.0-maintenance-tasks.md).
5
+ #
6
+ # The row is both the checkpoint and the audit trail. Its `cursor` is the
7
+ # authoritative resume point: Active Job serializes its own continuation
8
+ # into the job data, but that copy is lost whenever the job is dropped by
9
+ # the backend, so the runner writes both in the same checkpoint and always
10
+ # seeds from this column.
11
+ #
12
+ # Runs are installation-wide: an upgrade step converts every store's data,
13
+ # so a run belongs to no store.
14
+ class MaintenanceTaskRun < Spree.base_class
15
+ has_prefix_id :mtr
16
+
17
+ include Spree::HasStatus
18
+
19
+ publishes_lifecycle_events
20
+ publishes_events :started, :succeeded, :cancelled, :interrupted, :errored
21
+
22
+ # Terminal statuses end a run for good; a failed run is started again
23
+ # rather than resumed. `cancelling` is the operator's request, written by
24
+ # the Cancel service and observed by the runner at its next checkpoint —
25
+ # the runner alone moves a running run into `cancelled`, so a stopped
26
+ # worker can never leave a lie in the column.
27
+ has_status :enqueued, :running, :interrupted, :cancelling, :cancelled,
28
+ :succeeded, :errored,
29
+ default: :enqueued
30
+
31
+ ACTIVE_STATUSES = %w[enqueued running interrupted cancelling].freeze
32
+ INITIATED_VIA = %w[dashboard api cli].freeze
33
+
34
+ belongs_to :admin_user, class_name: Spree.admin_user_class.to_s, optional: true
35
+ belongs_to :api_key, class_name: 'Spree::ApiKey', optional: true
36
+
37
+ validates :task_name, presence: true
38
+ validates :initiated_via, presence: true, inclusion: { in: INITIATED_VIA }
39
+
40
+ scope :active, -> { where(status: ACTIVE_STATUSES) }
41
+
42
+ self.whitelisted_ransackable_attributes = %w[task_name status created_at]
43
+
44
+ # Filtered by the task's short name, which is what responses carry; the
45
+ # column holds its class name. A partial value (`task_name_cont=capture`)
46
+ # is camelized so a search still matches part of the stored name.
47
+ ransacker :task_name, formatter: ->(value) {
48
+ Spree::MaintenanceTask.find_by_api_type(value)&.name || value.to_s.camelize
49
+ } do |parent|
50
+ parent.table[:task_name]
51
+ end
52
+
53
+ # @return [Class, nil] the registered task class, or nil when the task has
54
+ # been removed from the registry since the run was created
55
+ def task_class
56
+ Spree::MaintenanceTask.find_registered(task_name)
57
+ end
58
+
59
+ # The task's short name, as the API and the dashboard identify it.
60
+ #
61
+ # @return [String]
62
+ def task_api_type
63
+ Spree::MaintenanceTask.api_type_for(task_name)
64
+ end
65
+
66
+ def active?
67
+ ACTIVE_STATUSES.include?(status)
68
+ end
69
+
70
+ # Seconds of work. Time since the last checkpoint counts only while a
71
+ # worker holds the run — a queued run is not working.
72
+ def duration
73
+ return time_running unless status.in?(%w[running cancelling]) && started_at.present?
74
+
75
+ time_running + (Time.current - (updated_at || started_at))
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,62 @@
1
+ module Spree
2
+ # A release boundary whose data steps this installation has completed
3
+ # (docs/plans/6.0-maintenance-tasks.md).
4
+ #
5
+ # This exists because nothing else in the schema records where an
6
+ # installation upgraded *from*. `Spree.version` is only ever the gem that is
7
+ # installed now, so without a stamp the dashboard cannot tell a store that
8
+ # has been on 5.6 for months from one jumping straight from 5.4 — and would
9
+ # have to show both every step of every manifest, which grows with each
10
+ # release.
11
+ #
12
+ # Written from three places, none of which guesses:
13
+ #
14
+ # - a successful `spree:upgrade` walk stamps the boundary it reached
15
+ # - a fresh install stamps the current version, because a database created
16
+ # by today's schema has no historical data to convert
17
+ # - an operator can stamp one by hand, for a store upgraded before this
18
+ # record existed
19
+ class UpgradeRecord < Spree.base_class
20
+ has_prefix_id :upg
21
+
22
+ SOURCES = %w[walk install manual].freeze
23
+
24
+ validates :version, presence: true, uniqueness: true
25
+ validates :source, presence: true, inclusion: { in: SOURCES }
26
+ validates :completed_at, presence: true
27
+
28
+ # Whether this installation has ever been upgraded, as opposed to having
29
+ # been installed at its current version.
30
+ #
31
+ # A store installed fresh at 6.0 has no historical data to convert and no
32
+ # upgrade to perform — showing it a manifest of steps, current or past,
33
+ # describes work that does not exist for it.
34
+ #
35
+ # @return [Boolean]
36
+ def self.upgraded?
37
+ where.not(source: 'install').exists?
38
+ end
39
+
40
+ # The highest boundary recorded, or nil when this installation has never
41
+ # stamped one.
42
+ #
43
+ # @return [String, nil]
44
+ def self.current_version
45
+ all.map(&:version).max_by { |version| Spree::Upgrade.version_parts(version) }
46
+ end
47
+
48
+ # Records a completed boundary. Idempotent — re-running a walk that has
49
+ # already been stamped updates when it happened rather than failing.
50
+ #
51
+ # @param version [String] two-segment release, e.g. "6.0"
52
+ # @param source [String] one of SOURCES
53
+ # @return [Spree::UpgradeRecord]
54
+ def self.stamp!(version, source: 'walk')
55
+ record = find_or_initialize_by(version: version.to_s)
56
+ record.source = source.to_s
57
+ record.completed_at = Time.current
58
+ record.save!
59
+ record
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ # Turns the CLI's `ARGS='market_id=mkt_1 batch_size=200'` into the hash the
4
+ # task's ActiveModel attributes expect.
5
+ #
6
+ # Values stay strings: attribute types cast them, so the parser never has
7
+ # to guess whether `200` is a number or a label.
8
+ module ArgumentParser
9
+ # Quoted values may contain spaces: ARGS='label="two words" limit=5'.
10
+ TOKEN = /(\w+)=("[^"]*"|'[^']*'|\S+)/
11
+
12
+ # @param raw [String, nil]
13
+ # @return [Hash{String => String}]
14
+ def self.parse(raw)
15
+ return {} if raw.blank?
16
+
17
+ raw.scan(TOKEN).to_h do |key, value|
18
+ [key, value.gsub(/\A["']|["']\z/, '')]
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,51 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ # Stops a run for good. The service records the request and the runner
4
+ # records the outcome; a run that has not started yet is cancelled
5
+ # outright, since no execution will ever observe the request.
6
+ #
7
+ # Cancelling a run that is already `cancelling` also cancels it outright.
8
+ # That is the way out when the worker that should have observed the first
9
+ # request is gone: a run the runner never comes back to would otherwise
10
+ # stay active and block every new run of its task.
11
+ #
12
+ # Cancelling is not a rollback — work already committed stays committed.
13
+ # What a cancelled run keeps is its cursor, so an operator can see exactly
14
+ # how far it got.
15
+ class Cancel
16
+ prepend ::Spree::ServiceModule::Base
17
+
18
+ # @param run [Spree::MaintenanceTaskRun]
19
+ # @return [Spree::ServiceModule::Base::Result] the run
20
+ def call(run:)
21
+ # Locked so the status read here is still the status when it is
22
+ # written: the runner may finish the run in between, and a finished
23
+ # run overwritten with `cancelling` would stay active for good.
24
+ run.with_lock do
25
+ return failure(run, not_cancelable_error(run)) unless run.active?
26
+
27
+ # A running run has a worker mid-batch, so it, not this service,
28
+ # records that the run stopped.
29
+ if run.running?
30
+ run.update!(status: 'cancelling')
31
+ else
32
+ run.update!(status: 'cancelled', ended_at: Time.current)
33
+ run.publish_event('maintenance_task_run.cancelled')
34
+ end
35
+ end
36
+
37
+ success(run.reload)
38
+ end
39
+
40
+ private
41
+
42
+ def not_cancelable_error(run)
43
+ build_error("a run with status #{run.status} cannot be cancelled")
44
+ end
45
+
46
+ def build_error(message)
47
+ ActiveModel::Errors.new(Spree::MaintenanceTaskRun.new).tap { |errors| errors.add(:base, message) }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,127 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ # Creates a run for a registered task and hands it to the runner
4
+ # (docs/plans/6.0-maintenance-tasks.md).
5
+ #
6
+ # Everything that can be known before any work happens is checked here —
7
+ # the task exists, its parameters validate, and no run of the same task
8
+ # is already in flight — so an operator sees a 422
9
+ # rather than a run that dies on its first record.
10
+ class Start
11
+ prepend ::Spree::ServiceModule::Base
12
+
13
+ # @param task_name [String] a registered Spree::MaintenanceTask subclass name
14
+ # @param api_type [String] the task's short name, in place of `task_name`
15
+ # — how an API caller names it, so a class name never has to cross
16
+ # the API
17
+ # @param arguments [Hash] task parameters
18
+ # @param dry_run [Boolean] preview mode; only honored by tasks that declare
19
+ # `supports_dry_run`
20
+ # @param initiated_via [String] dashboard / api / cli
21
+ # @param inline [Boolean] run synchronously (seeds, the CLI, the upgrade walk)
22
+ # @return [Spree::ServiceModule::Base::Result] the run
23
+ def call(task_name: nil, api_type: nil, arguments: {}, dry_run: false, admin_user: nil,
24
+ api_key: nil, initiated_via: 'api', inline: false)
25
+ task_class = if api_type.nil?
26
+ Spree::MaintenanceTask.find_registered(task_name)
27
+ else
28
+ Spree::MaintenanceTask.find_by_api_type(api_type)
29
+ end
30
+ return failure(nil, unknown_task_error(api_type || task_name)) if task_class.nil?
31
+
32
+ task_name = task_class.name
33
+
34
+ arguments = (arguments || {}).stringify_keys
35
+ unknown = arguments.keys - task_class.attribute_names
36
+ return failure(nil, unknown_arguments_error(unknown)) if unknown.any?
37
+
38
+ if initiated_via.to_s.in?(%w[api dashboard]) && superseded_step?(task_class, arguments)
39
+ return failure(nil, superseded_error(task_class))
40
+ end
41
+
42
+ task = task_class.new(arguments)
43
+ return failure(task, task.errors) unless task.valid?
44
+
45
+ active = active_run_for(task_name)
46
+ return failure(active, already_running_error(task_class)) if active
47
+
48
+ run = build_run(task_class, arguments, dry_run, admin_user, api_key, initiated_via)
49
+ return failure(run, run.errors) unless run.save
50
+
51
+ dispatch(run, inline)
52
+
53
+ success(run)
54
+ end
55
+
56
+ private
57
+
58
+ def build_run(task_class, arguments, dry_run, admin_user, api_key, initiated_via)
59
+ Spree::MaintenanceTaskRun.new(
60
+ task_name: task_class.name,
61
+ status: Spree::MaintenanceTaskRun.default_status,
62
+ arguments: arguments,
63
+ # A task that does not declare dry-run support would ignore the flag
64
+ # and write for real; recording it as a preview would be a lie.
65
+ dry_run: dry_run && task_class.dry_run_supported,
66
+ admin_user: admin_user,
67
+ api_key: api_key,
68
+ initiated_via: initiated_via.to_s
69
+ )
70
+ end
71
+
72
+ # The enqueue waits for the transaction so the worker cannot pick the
73
+ # job up before the row it names is visible.
74
+ def dispatch(run, inline)
75
+ if inline
76
+ ActiveRecord.after_all_transactions_commit { Spree::MaintenanceTasks::RunJob.perform_now(run.id, inline: true) }
77
+ else
78
+ Spree::MaintenanceTasks::RunJob.perform_later(run.id)
79
+ end
80
+ end
81
+
82
+ # Upgrade steps of a release this installation already completed. The
83
+ # dashboard never offers them; the command line still runs them, since
84
+ # that is where an operator deliberately repeats a step.
85
+ #
86
+ # Refused even when the completed release was assumed rather than
87
+ # recorded. The assumption is right for nearly every store, and for
88
+ # those a repeated conversion damages data; a store that really skipped
89
+ # a release is told to run its steps from the command line.
90
+ def superseded_step?(task_class, arguments)
91
+ Spree::Upgrade.superseded_steps.any? do |step|
92
+ step['task_class'] == task_class.name ||
93
+ (task_class <= Spree::MaintenanceTasks::UpgradeStep && step['id'] == arguments['step_id'])
94
+ end
95
+ end
96
+
97
+ # One run per task at a time. Checked rather than locked: two requests
98
+ # can both pass this check, and the runner then cancels the newer run
99
+ # before it does any work (RunJob#duplicate_run?).
100
+ def active_run_for(task_name)
101
+ Spree::MaintenanceTaskRun.active.find_by(task_name: task_name)
102
+ end
103
+
104
+ def unknown_task_error(task_name)
105
+ errors_for(:task_name, "#{task_name} is not a registered maintenance task")
106
+ end
107
+
108
+ def unknown_arguments_error(names)
109
+ errors_for(:arguments, "the task takes no parameter named #{names.to_sentence}")
110
+ end
111
+
112
+ def superseded_error(task_class)
113
+ errors_for(:base, "#{task_class.api_type} belongs to a release this store has already completed")
114
+ end
115
+
116
+ def already_running_error(task_class)
117
+ errors_for(:base, "#{task_class.api_type} is already running")
118
+ end
119
+
120
+ def errors_for(attribute, message)
121
+ ActiveModel::Errors.new(Spree::MaintenanceTaskRun.new).tap do |errors|
122
+ errors.add(attribute, message)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -10,6 +10,8 @@ module Spree
10
10
  # countries gem, and zones are migration-only, so none of them
11
11
  # are seeded.
12
12
 
13
+ fresh_database = Spree::Store.none?
14
+
13
15
  Stores.call
14
16
  Spree::Store.find_each { |store| StoreResources.call(store: store) }
15
17
  # The warehouse, delivery zones and pickup are not seeded: their
@@ -18,6 +20,26 @@ module Spree
18
20
  # them from the merchant's answer — at first-run setup, or here
19
21
  # when ADMIN_EMAIL/ADMIN_PASSWORD name an install that skips it.
20
22
  AdminUser.call
23
+
24
+ # A database created by today's schema has no historical data to
25
+ # convert, so every upgrade boundary up to this release is already
26
+ # satisfied. Recording that is what keeps a fresh install from
27
+ # being shown a backlog of steps it must never run. Only a database
28
+ # seeded for the first time qualifies: seeding is also a deploy
29
+ # step, and an upgraded store seeded before its upgrade ran still
30
+ # has every step to do.
31
+ stamp_upgrade_boundaries if fresh_database
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def stamp_upgrade_boundaries
39
+ Spree::Upgrade.manifests.each do |manifest|
40
+ Spree::UpgradeRecord.find_or_create_by!(version: manifest['to']) do |record|
41
+ record.source = 'install'
42
+ record.completed_at = Time.current
21
43
  end
22
44
  end
23
45
  end
@@ -1032,6 +1032,7 @@ en:
1032
1032
  locale_not_supported_by_market: is not offered by the chosen market
1033
1033
  locale_not_supported_by_store: is not supported by this store
1034
1034
  locales_not_listed: 'includes locales Spree does not support: %{codes}'
1035
+ maintenance_tasks_turned_off: Maintenance tasks are turned off for this installation. Run upgrade steps from the command line with `spree upgrade` (or `bin/rake spree:upgrade`).
1035
1036
  manual: Manual
1036
1037
  market: Market
1037
1038
  me_no_current_user: No admin user authenticated this request
@@ -10,7 +10,7 @@ class AddCaptureMethodToPaymentMethods < ActiveRecord::Migration[8.1]
10
10
  # from the provider configuration form.
11
11
  #
12
12
  # Existing auto_capture values are carried over by
13
- # spree:migrate_capture_methods, never here.
13
+ # the capture_methods maintenance task, never here.
14
14
  def change
15
15
  add_column :spree_payment_methods, :capture_method, :string
16
16
  end
@@ -0,0 +1,40 @@
1
+ class CreateSpreeMaintenanceTaskRuns < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_maintenance_task_runs do |t|
4
+ t.string :task_name, null: false
5
+ t.string :status, null: false
6
+ t.boolean :dry_run, null: false, default: false
7
+
8
+ t.string :cursor
9
+ t.integer :tick_count, null: false, default: 0
10
+ t.integer :tick_total
11
+
12
+ t.decimal :time_running, null: false, default: 0.0, precision: 12, scale: 3
13
+ t.datetime :started_at
14
+ t.datetime :ended_at
15
+
16
+ t.string :error_class
17
+ t.text :error_message
18
+ t.text :error_backtrace
19
+
20
+ t.string :job_id
21
+ t.string :initiated_via, null: false
22
+
23
+ t.references :admin_user, null: true
24
+ t.references :api_key, null: true
25
+
26
+ if t.respond_to?(:jsonb)
27
+ t.jsonb :arguments
28
+ t.jsonb :tallies
29
+ else
30
+ t.json :arguments
31
+ t.json :tallies
32
+ end
33
+
34
+ t.timestamps
35
+ end
36
+
37
+ add_index :spree_maintenance_task_runs, [:task_name, :status]
38
+ add_index :spree_maintenance_task_runs, :created_at
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ class CreateSpreeUpgradeRecords < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_upgrade_records do |t|
4
+ # The release boundary this installation has completed the data steps
5
+ # for — "5.6" means every manifest up to and including 5.5 → 5.6 is done.
6
+ t.string :version, null: false
7
+ # How we know: `walk` (a successful upgrade walk), `install` (a fresh
8
+ # install, which needs no historical steps) or `manual`.
9
+ t.string :source, null: false
10
+ t.datetime :completed_at, null: false
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :spree_upgrade_records, :version, unique: true
16
+ end
17
+ end