stepper_motor 0.1.17 → 0.1.19

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae300e62a2208dff5b41284cf55703bd14bf28605a05daa4a86d40d915a42259
4
- data.tar.gz: c7d192edf6a455761303ae83647d8e755111cd847c7fb3a1c3ca8d094ed6ca72
3
+ metadata.gz: b27839f0ee5621c5da6e694e2ee3e790f9f4b96383d05bf5ebbfc0a7ddf99928
4
+ data.tar.gz: 60bf0af4da25cf6f6c7ef2d835bcbd717ec662215da2bf14aea50da6949dc316
5
5
  SHA512:
6
- metadata.gz: 634c4e138fab066753a07bc4627b898dfb2e41ae082e0eb9722dc8fcdad5941a3009f867ec8e2d520aaeebbb8981d583e24bf05ef6f7b7aa667f1950f810401f
7
- data.tar.gz: cea004de19c142606872fae194bf8079a02ccfc5cebe7d59c04f6d2bcba55255b990d5e16778acdc439918af0ae02ddd5162331435a0fb5c504adbbbf019a58e
6
+ metadata.gz: 275d2ba0cc4ff6710f3d87189853f9836a767bfea6e56ac6a635135f7aa8e8043038f3cff1b28fd3b548ea06dd65392b37adb4f6d5aa4a310e7e7d95f53cb444
7
+ data.tar.gz: dd3f67919bf6daca240b6eb32d4c83ca720fc8ae9f2b7e9c571a7913e2e640840c7f1dc4c497f5114f14694b0a413ccdba758330c418844a3adc77b5312289ce
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.19] - 2025-06-25
6
+
7
+ - With `speedrun_journey`, allow configuring the maximum number of steps - for testing journeys
8
+ that iterate or reattempt a lot.
9
+ - With `speedrun_journey`, use time travel by default
10
+ - Allow steps to be placed relative to other steps using `before_step:` and `after_step:` keyword arguments
11
+ - Use just-in-time index lookup instead of `Step#seq`
12
+
13
+ ## [0.1.18] - 2025-06-20
14
+
15
+ - Add `cancel_if` at Journey class level for blanket journey cancellation conditions. This makes it very easy to abort journeys across multiple steps.
16
+
5
17
  ## [0.1.17] - 2025-06-20
6
18
 
7
19
  - Mandate Ruby 3.1+ because we are using 3.x syntax and there are shenanigans with sqlite3 gem version and locking. Compatibility with 2.7 can be assured but is too much hassle at the moment.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StepperMotor
4
+ # A wrapper for conditional logic that can be evaluated against an object.
5
+ # This class encapsulates different types of conditions (booleans, symbols, callables, arrays)
6
+ # and provides a unified interface for checking if a condition is satisfied by a given object.
7
+ # It handles negation and ensures proper context when evaluating conditions.
8
+ class Conditional
9
+ def initialize(condition, negate: false)
10
+ @condition = condition
11
+ @negate = negate
12
+ validate_condition
13
+ end
14
+
15
+ def satisfied_by?(object)
16
+ result = case @condition
17
+ when Array
18
+ @condition.all? { |c| Conditional.new(c).satisfied_by?(object) }
19
+ when Symbol
20
+ !!object.send(@condition)
21
+ when Conditional
22
+ @condition.satisfied_by?(object)
23
+ else
24
+ if @condition.respond_to?(:call)
25
+ !!object.instance_exec(&@condition)
26
+ else
27
+ !!@condition
28
+ end
29
+ end
30
+
31
+ @negate ? !result : result
32
+ end
33
+
34
+ private
35
+
36
+ def validate_condition
37
+ unless [true, false, nil].include?(@condition) || @condition.is_a?(Symbol) || @condition.is_a?(Array) || @condition.is_a?(Conditional) || @condition.respond_to?(:call)
38
+ raise ArgumentError, "condition must be a boolean, nil, Symbol, Array, Conditional or a callable object, but was a #{@condition.inspect}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -58,8 +58,7 @@ module StepperMotor::Journey::FlowControl
58
58
  return
59
59
  end
60
60
 
61
- current_step_seq = current_step_definition.seq
62
- next_step_definition = step_definitions[current_step_seq + 1]
61
+ next_step_definition = self.class.step_definitions_following(current_step_definition).first
63
62
 
64
63
  if next_step_definition
65
64
  # There are more steps after this one - schedule the next step
@@ -47,6 +47,9 @@ module StepperMotor
47
47
  # @return [Array<StepperMotor::Step>] the step definitions defined so far
48
48
  class_attribute :step_definitions, default: []
49
49
 
50
+ # @return [Array<StepperMotor::Conditional>] the cancel_if conditions defined for this journey class
51
+ class_attribute :cancel_if_conditions, default: []
52
+
50
53
  belongs_to :hero, polymorphic: true, optional: true
51
54
 
52
55
  STATES = %w[ready paused performing canceled finished]
@@ -76,7 +79,13 @@ module StepperMotor
76
79
  # as opposed to when the previous step has completed. When the journey gets scheduled, the triggering job is going to
77
80
  # be delayed by this amount of time _minus the `wait` values of the preceding steps, and the `next_step_to_be_performed_at`
78
81
  # attribute will be set to the current time. The `after` value gets converted into the `wait` value and passed to the step definition.
79
- # Mutually exclusive with `wait:`
82
+ # Mutually exclusive with `wait:`.
83
+ # @param before_step[String,Symbol,nil] the name of the step before which this step should be inserted.
84
+ # This allows you to control the order of steps by inserting a step before a specific existing step.
85
+ # The step name can be provided as a string or symbol. Mutually exclusive with `after_step:`.
86
+ # @param after_step[String,Symbol,nil] the name of the step after which this step should be inserted.
87
+ # This allows you to control the order of steps by inserting a step after a specific existing step.
88
+ # The step name can be provided as a string or symbol. Mutually exclusive with `before_step:`.
80
89
  # @param on_exception[Symbol] See {StepperMotor::Step#on_exception}
81
90
  # @param skip_if[TrueClass,FalseClass,Symbol,Proc] condition to check before performing the step. If a symbol is provided,
82
91
  # it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
@@ -86,32 +95,41 @@ module StepperMotor
86
95
  # The step will be performed if the condition returns a truthy value. and skipped otherwise. Inverse of `skip_if`.
87
96
  # @param additional_step_definition_options[Hash] Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
88
97
  # @return [StepperMotor::Step] the step definition that has been created
89
- def self.step(name = nil, wait: nil, after: nil, **additional_step_definition_options, &blk)
98
+ def self.step(name = nil, wait: nil, after: nil, before_step: nil, after_step: nil, **additional_step_definition_options, &blk)
90
99
  # Handle the if: alias for backward compatibility
91
100
  if additional_step_definition_options.key?(:if) && additional_step_definition_options.key?(:skip_if)
92
101
  raise StepConfigurationError, "Either skip_if: or if: can be specified, but not both"
93
102
  end
94
103
  if additional_step_definition_options.key?(:if)
95
- if_condition = additional_step_definition_options.delete(:if)
96
- # Convert if: to skip_if: by negating either the actual value or the return value of the callable
97
- # if: truthy means perform, skip_if: truthy means "skip"
98
- additional_step_definition_options[:skip_if] = case if_condition
99
- when true, false, nil
100
- !if_condition
101
- when Symbol
102
- # For symbols, we need to create a proc that negates the result
103
- -> { !send(if_condition) }
104
- else
105
- # For callables, we need to create a proc that negates the result
106
- -> { !instance_exec(&if_condition) }
104
+ # Convert if: to skip_if:
105
+ additional_step_definition_options[:skip_if] = StepperMotor::Conditional.new(additional_step_definition_options.delete(:if), negate: true)
106
+ end
107
+
108
+ # Validate before_step and after_step parameters
109
+ if before_step && after_step
110
+ raise StepConfigurationError, "Either before_step: or after_step: can be specified, but not both"
111
+ end
112
+
113
+ # Validate that referenced steps exist
114
+ if before_step
115
+ referenced_step = lookup_step_definition(before_step)
116
+ unless referenced_step
117
+ raise StepConfigurationError, "Step named #{before_step.inspect} not found for before_step: parameter"
107
118
  end
108
119
  end
109
120
 
110
- wait = if wait && after
121
+ if after_step
122
+ referenced_step = lookup_step_definition(after_step)
123
+ unless referenced_step
124
+ raise StepConfigurationError, "Step named #{after_step.inspect} not found for after_step: parameter"
125
+ end
126
+ end
127
+
128
+ wait = if wait && after&.respond_to?(:to_f)
111
129
  raise StepConfigurationError, "Either wait: or after: can be specified, but not both"
112
- elsif !wait && !after
130
+ elsif !wait && (!after || !after.respond_to?(:to_f))
113
131
  0
114
- elsif after
132
+ elsif after&.respond_to?(:to_f)
115
133
  accumulated = step_definitions.map(&:wait).sum
116
134
  after - accumulated
117
135
  else
@@ -135,12 +153,27 @@ module StepperMotor
135
153
  raise StepConfigurationError, "Step named #{name.inspect} already defined" if known_step_names.include?(name)
136
154
 
137
155
  # Create the step definition
138
- StepperMotor::Step.new(name: name, wait: wait, seq: step_definitions.length, **additional_step_definition_options, &blk).tap do |step_definition|
139
- # As per Rails docs: you need to be aware when using class_attribute with mutable structures
140
- # as Array or Hash. In such cases, you don't want to do changes in place. Instead use setters.
141
- # See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
156
+ step_definition = StepperMotor::Step.new(name: name, wait: wait, **additional_step_definition_options, &blk)
157
+
158
+ # Determine insertion position based on before_step or after_step parameters
159
+ if before_step
160
+ target_step = lookup_step_definition(before_step)
161
+ target_index = step_definitions.index(target_step)
162
+ new_step_definitions = step_definitions.dup
163
+ new_step_definitions.insert(target_index, step_definition)
164
+ self.step_definitions = new_step_definitions
165
+ elsif after_step
166
+ target_step = lookup_step_definition(after_step)
167
+ target_index = step_definitions.index(target_step)
168
+ new_step_definitions = step_definitions.dup
169
+ new_step_definitions.insert(target_index + 1, step_definition)
170
+ self.step_definitions = new_step_definitions
171
+ else
172
+ # Default behavior: append to the end
142
173
  self.step_definitions = step_definitions + [step_definition]
143
174
  end
175
+
176
+ step_definition
144
177
  end
145
178
 
146
179
  # Returns the `Step` object for a named step. This is used when performing a step, but can also
@@ -152,6 +185,16 @@ module StepperMotor
152
185
  step_definitions.find { |d| d.name.to_s == by_step_name.to_s }
153
186
  end
154
187
 
188
+ # Returns all step definitions that follow the given step in the journey
189
+ #
190
+ # @param step_definition[StepperMotor::Step] the step to find the following steps for
191
+ # @return [Array<StepperMotor::Step>] the following steps, or empty array if this is the last step
192
+ def self.step_definitions_following(step_definition)
193
+ current_index = step_definitions.index(step_definition)
194
+ return [] unless current_index
195
+ step_definitions[(current_index + 1)..]
196
+ end
197
+
155
198
  # Alias for the class attribute, for brevity
156
199
  #
157
200
  # @see Journey.step_definitions
@@ -166,6 +209,41 @@ module StepperMotor
166
209
  self.class.lookup_step_definition(by_step_name)
167
210
  end
168
211
 
212
+ # Defines a condition that will cause the journey to cancel if satisfied.
213
+ # This works like Rails' `etag` - it's class-inheritable and appendable.
214
+ # Multiple `cancel_if` calls can be made to a Journey definition.
215
+ # All conditions are evaluated after setting the state to `performing`.
216
+ # If any condition is satisfied, the journey will cancel.
217
+ #
218
+ # @param condition_arg [TrueClass, FalseClass, Symbol, Proc, Array, Conditional] the condition to check
219
+ # @param condition_blk [Proc] a block that will be evaluated as a condition
220
+ # @return [void]
221
+ def self.cancel_if(condition_arg = :__no_argument_given__, &condition_blk)
222
+ # Check if neither argument nor block is provided
223
+ if condition_arg == :__no_argument_given__ && !condition_blk
224
+ raise ArgumentError, "cancel_if requires either a condition argument or a block"
225
+ end
226
+
227
+ # Check if both argument and block are provided
228
+ if condition_arg != :__no_argument_given__ && condition_blk
229
+ raise ArgumentError, "cancel_if accepts either a condition argument or a block, but not both"
230
+ end
231
+
232
+ # Select the condition: positional argument takes precedence if not sentinel
233
+ condition = if condition_arg != :__no_argument_given__
234
+ condition_arg
235
+ else
236
+ condition_blk
237
+ end
238
+
239
+ conditional = StepperMotor::Conditional.new(condition)
240
+
241
+ # As per Rails docs: you need to be aware when using class_attribute with mutable structures
242
+ # as Array or Hash. In such cases, you don't want to do changes in place. Instead use setters.
243
+ # See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
244
+ self.cancel_if_conditions = cancel_if_conditions + [conditional]
245
+ end
246
+
169
247
  # Performs the next step in the journey. Will check whether any other process has performed the step already
170
248
  # and whether the record is unchanged, and will then lock it and set the state to 'performimg'.
171
249
  #
@@ -188,6 +266,14 @@ module StepperMotor
188
266
  performing!
189
267
  after_locking_for_step(next_step_name)
190
268
  end
269
+
270
+ # Check cancel_if conditions after setting state to performing
271
+ if cancel_if_conditions.any? { |conditional| conditional.satisfied_by?(self) }
272
+ logger.info { "cancel_if condition satisfied, canceling journey" }
273
+ cancel!
274
+ return
275
+ end
276
+
191
277
  current_step_name = next_step_name
192
278
 
193
279
  if current_step_name
@@ -257,8 +343,7 @@ module StepperMotor
257
343
  ready!
258
344
  elsif @skip_current_step
259
345
  # The step asked to be skipped
260
- current_step_seq = @current_step_definition.seq
261
- next_step_definition = step_definitions[current_step_seq + 1]
346
+ next_step_definition = self.class.step_definitions_following(@current_step_definition).first
262
347
 
263
348
  if next_step_definition
264
349
  # There are more steps after this one - schedule the next step
@@ -274,7 +359,7 @@ module StepperMotor
274
359
  elsif finished?
275
360
  logger.info { "was marked finished inside the step" }
276
361
  update!(previous_step_name: current_step_name, next_step_name: nil)
277
- elsif (next_step_definition = step_definitions[@current_step_definition.seq + 1])
362
+ elsif (next_step_definition = self.class.step_definitions_following(@current_step_definition).first)
278
363
  logger.info { "will continue to #{next_step_definition.name}" }
279
364
  set_next_step_and_enqueue(next_step_definition)
280
365
  ready!
@@ -302,8 +387,7 @@ module StepperMotor
302
387
 
303
388
  # @return [ActiveSupport::Duration]
304
389
  def time_remaining_until_final_step
305
- current_step_seq = @current_step_definition&.seq || -1
306
- subsequent_steps = step_definitions.select { |definition| definition.seq > current_step_seq }
390
+ subsequent_steps = @current_step_definition ? self.class.step_definitions_following(@current_step_definition) : step_definitions
307
391
  seconds_remaining = subsequent_steps.map { |definition| definition.wait.to_f }.sum
308
392
  seconds_remaining.seconds # Convert to ActiveSupport::Duration
309
393
  end
@@ -13,9 +13,6 @@ class StepperMotor::Step
13
13
  # @return [Numeric,ActiveSupport::Duration] how long to wait before performing the step
14
14
  attr_reader :wait
15
15
 
16
- # @private
17
- attr_reader :seq
18
-
19
16
  # Creates a new step definition
20
17
  #
21
18
  # @param name[String,Symbol] the name of the Step
@@ -30,18 +27,12 @@ class StepperMotor::Step
30
27
  # it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided,
31
28
  # it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context.
32
29
  # The step will only be performed if the condition returns a truthy value.
33
- def initialize(name:, seq:, on_exception: :pause!, wait: 0, skip_if: false, &step_block)
30
+ def initialize(name:, on_exception: :pause!, wait: 0, skip_if: false, &step_block)
34
31
  @step_block = step_block
35
32
  @name = name.to_s
36
33
  @wait = wait
37
- @seq = seq
38
34
  @on_exception = on_exception # TODO: Validate?
39
- @skip_if_condition = skip_if
40
-
41
- # Validate the skip_if condition
42
- if ![true, false, nil].include?(@skip_if_condition) && !@skip_if_condition.is_a?(Symbol) && !@skip_if_condition.respond_to?(:call)
43
- raise ArgumentError, "skip_if: condition must be a boolean, nil, Symbol or a callable object, but was a #{@skip_if_condition.inspect}"
44
- end
35
+ @skip_if_condition = StepperMotor::Conditional.new(skip_if)
45
36
  end
46
37
 
47
38
  # Checks if the step should be skipped based on the skip_if condition
@@ -49,14 +40,7 @@ class StepperMotor::Step
49
40
  # @param journey[StepperMotor::Journey] the journey to check the condition for
50
41
  # @return [Boolean] true if the step should be skipped, false otherwise
51
42
  def should_skip?(journey)
52
- case @skip_if_condition
53
- when true, false, nil
54
- !!@skip_if_condition
55
- when Symbol
56
- journey.send(@skip_if_condition) # Allow private methods
57
- else
58
- journey.instance_exec(&@skip_if_condition)
59
- end
43
+ @skip_if_condition.satisfied_by?(journey)
60
44
  end
61
45
 
62
46
  # Performs the step on the passed Journey, wrapping the step with the required context.
@@ -8,14 +8,39 @@ module StepperMotor::TestHelper
8
8
  # has neither canceled nor finished, an exception will be raised.
9
9
  #
10
10
  # @param journey[StepperMotor::Journey] the journey to speedrun
11
+ # @param time_travel[Boolean] whether to use ActiveSupport time travel (default: true)
12
+ # Note: When time_travel is true, this method will permanently travel time forward
13
+ # and will not reset it back to the original time when the method exits.
14
+ # @param maximum_steps[Symbol,Integer] how many steps can we take until we assume the
15
+ # journey has hung and fail the test. Default value is :reasonable, which is 10x the
16
+ # number of steps. :unlimited allows "a ton", but can make your test hang if your logic
17
+ # lets a step reattempt indefinitely
11
18
  # @return void
12
- def speedrun_journey(journey)
19
+ def speedrun_journey(journey, time_travel: true, maximum_steps: :reasonable)
13
20
  journey.save!
14
- n_steps = journey.step_definitions.length
21
+ n_steps = case maximum_steps
22
+ when :reasonable
23
+ journey.step_definitions.length * 10
24
+ when :unlimited
25
+ 0xFFFF
26
+ when Integer
27
+ maximum_steps
28
+ else
29
+ raise ArgumentError, "maximum_steps may be :reasonable, :unlimited or an Integer, was #{maximum_steps.inspect}"
30
+ end
31
+
15
32
  n_steps.times do
16
33
  journey.reload
17
34
  break if journey.canceled? || journey.finished?
18
- journey.update(next_step_to_be_performed_at: Time.current)
35
+
36
+ if time_travel
37
+ # Use time travel to move slightly ahead of the time when the next step should be performed
38
+ next_step_time = journey.next_step_to_be_performed_at
39
+ travel_to(next_step_time + 1.second)
40
+ else
41
+ # Update the journey's timestamp to bypass waiting periods
42
+ journey.update(next_step_to_be_performed_at: Time.current)
43
+ end
19
44
  journey.perform_next_step!
20
45
  end
21
46
  journey.reload
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StepperMotor
4
- VERSION = "0.1.17"
4
+ VERSION = "0.1.19"
5
5
  end
data/lib/stepper_motor.rb CHANGED
@@ -13,6 +13,7 @@ module StepperMotor
13
13
 
14
14
  autoload :Journey, File.dirname(__FILE__) + "/stepper_motor/journey.rb"
15
15
  autoload :Step, File.dirname(__FILE__) + "/stepper_motor/step.rb"
16
+ autoload :Conditional, File.dirname(__FILE__) + "/stepper_motor/conditional.rb"
16
17
 
17
18
  autoload :BaseJob, File.dirname(__FILE__) + "/stepper_motor/base_job.rb"
18
19
  autoload :PerformStepJob, File.dirname(__FILE__) + "/stepper_motor/perform_step_job.rb"
data/manual/MANUAL.md CHANGED
@@ -27,7 +27,9 @@ So, stepper_motor aims to give you "just enough of Temporal-like functionality"
27
27
 
28
28
  ## A brief introduction to stepper_motor
29
29
 
30
- stepper_motor is built around the concept of a `Journey`. A `Journey` [is a sequence of steps happening to a `hero`](https://en.wikipedia.org/wiki/Hero%27s_journey) - once launched, the journey will run until it either finishes or cancels. A `Journey` is just an `ActiveRecord` model, with all the persistence methods you already know and use.
30
+ stepper_motor is built around the concept of a `Journey`. A `Journey` [is a sequence of steps happening to a `hero`](https://en.wikipedia.org/wiki/Hero%27s_journey) - once launched, the journey will run until it either finishes or cancels. But also - a `Journey` is an `ActiveRecord` model, with all the persistence methods you already know and use. Every `Journey` has a speficic identity - its primary key, just like any other database row, and carries state information.
31
+
32
+ A `Journey` changes states during asynchronous invocations, and can lay dormant for months until the time comes to take the next step.
31
33
 
32
34
  Steps are defined inside the Journey subclasses as blocks, and they run in the context of that subclass' instance. The following constraints apply:
33
35
 
@@ -40,7 +42,7 @@ The `step` blocks get executed in the context of the `Journey` model instance. T
40
42
 
41
43
  The steps are performed asynchronously, via ActiveJob. When a Journey is created, it gets scheduled for its initial step. The job then gets picked up by the ActiveJob queue worker (whichever you are using) and triggers the step on the `Journey`. If the journey decides to continue to the next step, it schedules another ActiveJob for itself with the step name and other details necessary.
42
44
 
43
- No state is carried inside the job.
45
+ No state is carried inside the job - both the state transition management and the steps are handled by the `Journey` itself.
44
46
 
45
47
  ## Installation
46
48
 
@@ -70,7 +72,7 @@ class SignupJourney < StepperMotor::Journey
70
72
  end
71
73
  end
72
74
 
73
- class SignupController
75
+ class SignupController < ApplicationController
74
76
  def create
75
77
  # ...your other business actions
76
78
  SignupJourney.create!(hero: current_user)
@@ -371,23 +373,54 @@ step :two do
371
373
  end
372
374
  ```
373
375
 
374
- You have a `Journey` which is about to start step `one`. When the step gets performed, stepper_motor will do a lookup to find _the next step in order of definition._ In this case the step will be step `two`, so the name of that step will be saved with the `Journey`. Imagine you then edit the code to add an extra step between those:
376
+ Your existing `Journey` is already primed to perform step `two`. However, a `Journey` which is about to perform step `one` will now set `one_bis` as the next step to perform. This allows limited reordering and editing of `Journey` definitions after they have already begun.
377
+
378
+ ### Step ordering with `before_step:` and `after_step:`
379
+
380
+ You can control the order of steps by inserting them before or after specific existing steps using the `before_step:` and `after_step:` parameters. This is useful when you want to add steps in the middle of a sequence without manually reordering all the steps.
381
+
382
+ The step names can be provided as strings or symbols, and only one of `before_step:` or `after_step:` can be specified:
375
383
 
376
384
  ```ruby
377
- step :one do
378
- # perform some action
379
- end
385
+ class UserOnboardingJourney < StepperMotor::Journey
386
+ step :send_welcome_email do
387
+ WelcomeMailer.welcome(hero).deliver_later
388
+ end
380
389
 
381
- step :one_bis do
382
- # some compliance action
383
- end
390
+ step :send_premium_offer, after: 2.days do
391
+ PremiumOfferMailer.exclusive_offer(hero).deliver_later
392
+ end
384
393
 
385
- step :two do
386
- # perform some other action
394
+ # Insert a compliance check before the premium offer
395
+ step :compliance_check, before_step: "send_premium_offer" do
396
+ ComplianceService.verify_eligibility(hero)
397
+ end
398
+
399
+ # Insert a reminder after the welcome email
400
+ step :send_reminder, after_step: "send_welcome_email", wait: 1.day do
401
+ ReminderMailer.follow_up(hero).deliver_later
402
+ end
403
+
404
+ step :complete_onboarding, after: 7.days do
405
+ hero.update!(onboarding_completed_at: Time.current)
406
+ end
387
407
  end
388
408
  ```
409
+ This approach is particularly useful when extending journey classes through inheritance, as you can insert steps relative to steps defined in the parent class.
410
+ Should you ever get confused and find yourself in need to retrace your steps, call `UserOnboardingJourney.step_definitions.map(&:name)`:
389
411
 
390
- Your existing `Journey` is already primed to perform step `two`. However, a `Journey` which is about to perform step `one` will now set `one_bis` as the next step to perform. This allows limited reordering and editing of `Journey` definitions after they have already begun.
412
+ ```ruby
413
+ UserOnboardingJourney.step_definitions.map(&:name)
414
+ # => ["send_welcome_email", "send_reminder", "compliance_check", "send_premium_offer", "complete_onboarding"]
415
+ ```
416
+
417
+ This will show you the exact order in which steps will be executed. In this case:
418
+
419
+ 1. `send_welcome_email`
420
+ 2. `send_reminder` (inserted after `send_welcome_email`)
421
+ 3. `compliance_check` (inserted before `send_premium_offer`)
422
+ 4. `send_premium_offer`
423
+ 5. `complete_onboarding`
391
424
 
392
425
  ### Using instance methods as steps
393
426
 
@@ -515,6 +548,123 @@ It is possible to store instance variables on the `Journey` instance, but they d
515
548
  > This means that the volatile state such as instance variables is not going to be available between steps. Always assume that
516
549
  > the `Journey` you are inside of does not have any instance variables set by previous steps and has just been freshly loaded from the database.
517
550
 
551
+ ### Blanket step conditions with `cancel_if`
552
+
553
+ In addition to conditional steps that can be skipped, you can define blanket conditions that apply to all steps in a journey using `cancel_if`. These conditions are evaluated after the journey's state is set to `performing` but before any step execution begins. If any `cancel_if` condition is satisfied, the entire journey is canceled immediately.
554
+
555
+ `cancel_if` works like Rails' `etag` - it's class-inheritable and appendable. You can call `cancel_if` multiple times in a journey definition, and all conditions will be evaluated.
556
+
557
+ #### Using blocks with `cancel_if`
558
+
559
+ The most common way to use `cancel_if` is with a block that gets evaluated in the context of the journey:
560
+
561
+ ```ruby
562
+ class UserOnboardingJourney < StepperMotor::Journey
563
+ cancel_if { hero.deactivated? }
564
+ cancel_if { hero.account_closed? }
565
+
566
+ step :send_welcome_email do
567
+ WelcomeMailer.welcome(hero).deliver_later
568
+ end
569
+
570
+ step :send_premium_offer, wait: 2.days do
571
+ PremiumOfferMailer.exclusive_offer(hero).deliver_later
572
+ end
573
+
574
+ step :complete_onboarding, wait: 7.days do
575
+ hero.update!(onboarding_completed_at: Time.current)
576
+ end
577
+ end
578
+ ```
579
+
580
+ In this example, if the user becomes deactivated or closes their account at any point during the onboarding journey, the entire journey will be canceled and no further steps will be executed.
581
+
582
+ #### Using positional arguments with `cancel_if`
583
+
584
+ You can also pass conditions directly as arguments to `cancel_if`:
585
+
586
+ ```ruby
587
+ class PaymentProcessingJourney < StepperMotor::Journey
588
+ cancel_if :payment_canceled?
589
+ cancel_if { hero.amount > 10000 } # Cancel if amount exceeds limit
590
+
591
+ step :validate_payment do
592
+ PaymentValidator.validate(hero)
593
+ end
594
+
595
+ step :process_payment do
596
+ PaymentProcessor.charge(hero)
597
+ end
598
+
599
+ step :send_confirmation do
600
+ PaymentConfirmationMailer.confirm(hero).deliver_later
601
+ end
602
+
603
+ private
604
+
605
+ def payment_canceled?
606
+ hero.canceled_at.present?
607
+ end
608
+ end
609
+ ```
610
+
611
+ #### Supported condition types
612
+
613
+ `cancel_if` accepts the same types of conditions as `skip_if`:
614
+
615
+ * **Symbols**: Method names that return a boolean
616
+ * **Blocks**: Callable blocks that return a boolean
617
+ * **Booleans**: Literal `true` or `false` values
618
+ * **Arrays**: Arrays of conditions (all must be true)
619
+ * **Procs/Lambdas**: Callable objects
620
+ * **Conditional objects**: `StepperMotor::Conditional` instances
621
+ * **Nil**: Treated as `false` (doesn't cancel)
622
+
623
+ #### Multiple `cancel_if` conditions
624
+
625
+ You can define multiple `cancel_if` conditions, and any one of them being satisfied will cancel the journey:
626
+
627
+ ```ruby
628
+ class EmailCampaignJourney < StepperMotor::Journey
629
+ cancel_if { hero.unsubscribed? }
630
+ cancel_if { hero.bounced? }
631
+ cancel_if { hero.complained? }
632
+ cancel_if { hero.deactivated? }
633
+
634
+ step :send_initial_email do
635
+ CampaignMailer.initial(hero).deliver_later
636
+ end
637
+
638
+ step :send_follow_up, wait: 3.days do
639
+ CampaignMailer.follow_up(hero).deliver_later
640
+ end
641
+
642
+ step :send_final_reminder, wait: 7.days do
643
+ CampaignMailer.final_reminder(hero).deliver_later
644
+ end
645
+ end
646
+ ```
647
+
648
+ #### Inheritance and appendability
649
+
650
+ `cancel_if` conditions are class-inheritable and appendable, just like Rails' `etag`:
651
+
652
+ ```ruby
653
+ class BaseJourney < StepperMotor::Journey
654
+ cancel_if { hero.deactivated? }
655
+ end
656
+
657
+ class PremiumUserJourney < BaseJourney
658
+ cancel_if { hero.subscription_expired? } # Adds to parent's conditions
659
+
660
+ step :send_premium_content do
661
+ PremiumContentMailer.send_content(hero).deliver_later
662
+ end
663
+ end
664
+ ```
665
+
666
+ In this example, `PremiumUserJourney` will cancel if either the user is deactivated (from the parent class) or if their subscription has expired (from the child class).
667
+
518
668
 
519
669
  ### Waiting for the start of the step
520
670
 
@@ -531,6 +681,25 @@ end
531
681
 
532
682
  The `wait:` parameter defines the amount of time computed **from the moment the Journey gets created or the previous step is completed.**
533
683
 
684
+ ## Execution model: assume that everything is async
685
+
686
+ stepper_motor is built on the assumption that steps should be able to execute asynchronously. This is a **very important** design trait which has a number of implications:
687
+
688
+ * You will almost never have the same `self` for a Journey between multiple steps
689
+ * Any instance variables you set inside a step will get discarded and will not be available in a subsequent step
690
+ * Any step may want to reattempt itself at an arbitrary point in the future. Any stack variables or local state will be discarded.
691
+
692
+ This is deliberate and intentional. At the moment, stepper_motor does not have any built-in primitives for executing multiple steps inline. The `speedrun_journey`
693
+ helper used in tests is going to `reload` your Journey between step entries, to allow for any state changes to be blown away - but it is not going to reset any instance variables.
694
+
695
+ A good way to think about it is: every step execution happens:
696
+
697
+ * On a different machine
698
+ * In a different thread
699
+ * In a different database transaction
700
+ * In the context of a different Journey instance
701
+ * Potentially - after a long time has passed since the previous attempt or previous step
702
+
534
703
  ## Journey states
535
704
 
536
705
  The Journeys are managed using a state machine, which stepper_motor completely coordinates for you. The states are as follows: