stepper_motor 0.1.12 → 0.1.16

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.
data/manual/MANUAL.md CHANGED
@@ -230,26 +230,352 @@ end
230
230
 
231
231
  Here, we first initiate a payment using an idempotency key, and then poll for its completion or failure repeatedly. When a payment fails or succeeds, we notify the sender and finish the `Journey`. Note that this `Journey` is of a _payment,_ not of the user. A user may have multiple Payments in flight, each with their own `Journey` being tracket transactionally and correctly.
232
232
 
233
+ ### Polling with anonymous steps
234
+
235
+ Here's an example of a Journey that polls an external API for a status update, using anonymous steps with increasing intervals:
236
+
237
+ ```ruby
238
+ class OrderStatusPollingJourney < StepperMotor::Journey
239
+ alias_method :order, :hero
240
+
241
+ # Initial check
242
+ step do
243
+ check_status!
244
+ end
245
+
246
+ # Check after 30 seconds
247
+ step(wait: 30.seconds) do
248
+ check_status!
249
+ end
250
+
251
+ # Check every minute for 5 minutes
252
+ 5.times do
253
+ step(wait: 1.minute) do
254
+ check_status!
255
+ end
256
+ end
257
+
258
+ # Check every 5 minutes for 30 minutes
259
+ 6.times do
260
+ step(wait: 5.minutes) do
261
+ check_status!
262
+ end
263
+ end
264
+
265
+ # Final check after 1 hour
266
+ step(wait: 1.hour) do
267
+ check_status!
268
+ end
269
+
270
+ private
271
+
272
+ def check_status!
273
+ status = ExternalOrderAPI.fetch_status(order.external_id)
274
+
275
+ case status
276
+ when :completed
277
+ order.complete!
278
+ finished!
279
+ when :failed
280
+ order.fail!
281
+ cancel!
282
+ when :processing
283
+ # Do nothing, will continue to next step
284
+ else
285
+ # Unexpected status, pause for manual investigation
286
+ pause!
287
+ end
288
+ end
289
+ end
290
+ ```
291
+
292
+ ## Defining steps
293
+
294
+ Steps are key building blocks in stepper_motor and can be defined in a number of ways. Rules of thumb:
295
+
296
+ * When steps are recalled to be performed, they get recalled _by name._
297
+ * When preparing for the next step or skipping to the next step, _the next step from the current in order of definition_ is going to be used.
298
+ * Step names within a Journey subclass must be unique.
299
+
300
+ stepper_motor will help you follow these rules, so don't worry too much about them just yet.
301
+
302
+ The step definition is some form of callable block, which is then going to run in the context of this Journey instance (or the Journey subclass' instance)
303
+
304
+ > [!TIP]
305
+ > Regardless whether the step definition is a method or a block - the step will be `instance_exec`-ed in the context of the Journey instance.
306
+ > Inside the step code you have access to all the methods of the Journey, including instance variables.
307
+
308
+ ### Anonymous steps
309
+
310
+ The simplest is using anonymous steps and blocks (stepper_motor will generate you step names):
311
+
312
+ ```ruby
313
+ class EraseAccountJourney < StepperMotor::Journey
314
+ step { hero.datapoints.in_batches.each(&:delete_all) }
315
+ step { hero.update!(email: "#{Digest::SHA1.hexdigest(hero.email)}@example.com") }
316
+ end
317
+ ```
318
+
319
+ Anonymous steps have a very good use for polling, when you want to define the repeats of the step programmatically. For example:
320
+
321
+ ```ruby
322
+ class PollStatusJourney < StepperMotor::Journey
323
+ alias_method :payment, :hero
324
+
325
+ step { verify_status! }
326
+
327
+ # Then after 5 minutes
328
+ step(wait: 5.minutes) { verify_status! }
329
+
330
+ # Check every 2 hours after
331
+ 12.times do
332
+ step(wait: 2.hours) { verify_status! }
333
+ end
334
+
335
+ # Check once a day after that
336
+ 7.times do
337
+ step(wait: 1.day) { verify_status! }
338
+ end
339
+
340
+ step :terminate do
341
+ payment.failed!
342
+ end
343
+
344
+ def verify_status!
345
+ status = payment.current_status
346
+ finished! if status.complete?
347
+ end
348
+ end
349
+ ```
350
+
351
+ ### Named steps
352
+
353
+ You can give the steps names. Step names must be unique and stepper_motor will raise an exception if you reuse a step name:
354
+
355
+ ```ruby
356
+ class EraseAccountJourney < StepperMotor::Journey
357
+ step :delete_datapoints { hero.datapoints.in_batches.each(&:delete_all) }
358
+ step :pseudonymize_email { hero.update!(email: "#{Digest::SHA1.hexdigest(hero.email)}@example.com") }
359
+ end
360
+ ```
361
+
362
+ The name of the step is what stepper_motor is going to persist to resume the Journey later. Naming your steps is useful because you then can then update your code and insert steps between the existing ones, and have your `Journey` correctly identify the right step. Steps are performed in the order they are defined. Imagine you start with this step sequence:
363
+
364
+ ```ruby
365
+ step :one do
366
+ # perform some action
367
+ end
368
+
369
+ step :two do
370
+ # perform some other action
371
+ end
372
+ ```
373
+
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:
375
+
376
+ ```ruby
377
+ step :one do
378
+ # perform some action
379
+ end
380
+
381
+ step :one_bis do
382
+ # some compliance action
383
+ end
384
+
385
+ step :two do
386
+ # perform some other action
387
+ end
388
+ ```
389
+
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.
391
+
392
+ ### Using instance methods as steps
393
+
394
+ You can use instance methods of your Journey as steps by passing their name as a symbol to the `step` method:
395
+
396
+ ```ruby
397
+ class Erasure < StepperMotor::Journey
398
+ step :erase_attachments
399
+ step :erase_emails
400
+
401
+ def erase_attachments
402
+ hero.uploaded_attachments.find_each(&:destroy)
403
+ end
404
+
405
+ def erase_emails
406
+ while hero.emails.count > 0
407
+ hero.emails.limit(5000).delete_all
408
+ end
409
+ end
410
+ end
411
+ ```
412
+
413
+ Since a method definition in Ruby returns a Symbol, you can use the return value of the `def` expression
414
+ to define a `step` immediately:
415
+
416
+ ```ruby
417
+ class Erasure < StepperMotor::Journey
418
+ step def erase_attachments
419
+ hero.uploaded_attachments.find_each(&:destroy)
420
+ end
421
+
422
+ step def erase_emails
423
+ while hero.emails.count > 0
424
+ hero.emails.limit(5000).delete_all
425
+ end
426
+ end
427
+ end
428
+ ```
429
+
430
+ ### Conditional steps with `skip_if:`
431
+
432
+ You can make steps conditional by using the `skip_if:` parameter. This allows you to skip steps based on runtime conditions. The `skip_if:` parameter accepts:
433
+
434
+ * A symbol (method name) that returns a boolean.
435
+ * A callable (lambda or proc) that returns a boolean. It will be `instance_exec`d in the context of the Journey.
436
+ * A literal boolean value (`true` or `false`) or a `nil` (treated as `false`)
437
+
438
+ When a step's condition evaluates to `false`, the step is skipped and the journey continues to the next step. If there are no more steps, the journey finishes.
439
+
440
+ > **Note:** The `if:` parameter is also supported as an alias for `skip_if:` for backward compatibility, but `skip_if:` is the preferred parameter name.
441
+
442
+ #### Using method names as conditions
443
+
444
+ ```ruby
445
+ class UserOnboardingJourney < StepperMotor::Journey
446
+ step :send_welcome_email, skip_if: :should_send_welcome? do
447
+ WelcomeMailer.welcome(hero).deliver_later
448
+ end
449
+
450
+ step :send_premium_offer, skip_if: :is_premium_user? do
451
+ PremiumOfferMailer.exclusive_offer(hero).deliver_later
452
+ end
453
+
454
+ step :complete_onboarding do
455
+ hero.update!(onboarding_completed_at: Time.current)
456
+ end
457
+
458
+ private
459
+
460
+ def should_send_welcome?
461
+ hero.email.present? && !hero.welcome_email_sent?
462
+ end
463
+
464
+ def is_premium_user?
465
+ hero.subscription&.premium?
466
+ end
467
+ end
468
+ ```
469
+
470
+ #### Using callable conditions
471
+
472
+ You can use lambdas or procs for more dynamic conditions. They will be `instance_exec`d in the context of the Journey.:
473
+
474
+ ```ruby
475
+ class OrderProcessingJourney < StepperMotor::Journey
476
+ step :send_confirmation, skip_if: -> { hero.email.present? } do
477
+ OrderConfirmationMailer.confirm(hero).deliver_later
478
+ end
479
+
480
+ step :process_payment
481
+ PaymentProcessor.charge(hero)
482
+ end
483
+ end
484
+ ```
485
+
486
+ #### Skipping steps with literal conditions
487
+
488
+ You can use literal boolean values to conditionally include or exclude steps:
489
+
490
+ ```ruby
491
+ class FeatureFlagJourney < StepperMotor::Journey
492
+ step :new_feature_step, skip_if: Rails.application.config.new_feature_enabled do
493
+ NewFeatureService.process(hero)
494
+ end
495
+
496
+ step :legacy_step, skip_if: ENV["PERFORM_LEGACY_STEP"] do # This step will never execute
497
+ LegacyService.process(hero)
498
+ end
499
+
500
+ step :always_execute do
501
+ # This step always runs
502
+ end
503
+ end
504
+ ```
505
+
506
+ When a step is skipped due to a false condition, the journey seamlessly continues to the next step without any interruption - or finishes if that step was the last one.
507
+
508
+ #### Accessing Journey state in conditions
509
+
510
+ It is possible to store instance variables on the `Journey` instance, but they do not persist between steps. This is very important to remember:
511
+
512
+ > [!WARNING]
513
+ > Because conditions are `instance_exec`d they can access instance variables of the `Journey`. It will also break majestically, because
514
+ > the `Journey` is getting persisted and then loaded from the database on a different matchine, and it is always consumed fresh.
515
+ > This means that the volatile state such as instance variables is not going to be available between steps. Always assume that
516
+ > 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
+
518
+
519
+ ### Waiting for the start of the step
520
+
521
+ You configure how long a step should wait before starting using the `wait:` parameter. The `wait:` can be arbirarily long - but must be finite:
522
+
523
+ ```ruby
524
+ class DraconianPasswordResetJourney < StepperMotor::Journey
525
+ step :ask_for_password_reset, wait: 6.months do
526
+ PasswordExpiredMailer.expired(hero).deliver_later
527
+ reattempt!
528
+ emd
529
+ end
530
+ ```
531
+
532
+ The `wait:` parameter defines the amount of time computed **from the moment the Journey gets created or the previous step is completed.**
533
+
534
+ ## Journey states
535
+
536
+ The Journeys are managed using a state machine, which stepper_motor completely coordinates for you. The states are as follows:
537
+
538
+ ```mermaid
539
+ stateDiagram-v2
540
+ [*] --> ready: create
541
+ ready --> performing: perform_next_step!
542
+ performing --> ready: reattempt!
543
+ performing --> finished: step completes
544
+ performing --> canceled: cancel!
545
+ performing --> paused: pause! or exception
546
+ paused --> ready: resume!
547
+ finished --> [*]
548
+ canceled --> [*]
549
+ ```
550
+
233
551
  ## Flow control within steps
234
552
 
235
- Inside a step, you currently can use the following flow control methods:
553
+ You currently can use the following flow control methods, both when a step is performing and on a Journey fetched from the database:
236
554
 
237
555
  * `cancel!` - cancel the Journey immediately. It will be persisted and moved into the `canceled` state.
238
556
  * `reattempt!` - reattempt the Journey immediately, triggering it asynchronously. It will be persisted
239
557
  and returned into the `ready` state. You can specify the `wait:` interval, which may deviate from
240
- the wait time defined for the current step
558
+ the wait time defined for the current step. `reattepmt!` cannot be used outside of steps!
241
559
  * `pause!` - pause the Journey either within a step or outside of one. This moves the Journey into the `paused` state.
242
560
  In that state, the journey is still considered unique-per-hero (you won't be able to create an identical Journey)
243
561
  but it will not be picked up by the scheduled step jobs. Should it get picked up, the step will not be performed.
244
562
  You have to explicitly `resume!` the Journey to make it `ready` - once you do, a new job will be scheduled to
245
563
  perform the step.
564
+ * `skip!` - skip either the step currently being performed or the step scheduled to be taken next, and proceed to the next
565
+ step in the journey. This is useful when you want to conditionally skip a step based on some business logic without
566
+ canceling the entire journey. For example, you might want to skip a reminder email step if the user has already taken the required action.
567
+
568
+ If there are more steps after the current step, `skip!` will schedule the next step to be performed.
569
+ If the current step is the last step in the journey, `skip!` will finish the journey.
246
570
 
247
571
  > [!IMPORTANT]
248
- > Flow control methods use `throw` when they are called from inside a step. Unlike Rails `render` or `redirect` that require an explicit
249
- > `return`, the code following a `reattempt!` or `cancel!` within the same scope will not be executed, so those methods may only be called once within a particular scope.
572
+ > Flow control methods use `throw` when they are called during step execution. Unlike Rails `render` or `redirect` that require an explicit
573
+ > `return`, the code following a `reattempt!` or `cancel!` within the same scope will not be executed inside steps, so those methods may only be called once within a particular scope.
250
574
 
251
- You can't call those methods outside of the context of a performing step, and an exception is going to be raised if you do.
575
+ Most of those methods do the right thing both inside steps and outside step execution. The only exception is `reattempt!`.
252
576
 
577
+ > [!IMPORTANT]
578
+ > `reattempt!` only works inside of steps.
253
579
 
254
580
  ## Transactional semantics within steps
255
581
 
@@ -273,16 +599,12 @@ We recommend using a "co-committing" ActiveJob adapter with stepper_motor (an ad
273
599
  * [good_job](https://github.com/bensheldon/good_job)
274
600
  * [solid_queue](https://github.com/rails/solid_queue) - with the same database used for the queue as the one used for Journeys
275
601
 
276
- While Rails core admittedly [insists on the stylistic choice of denying the users the option of co-committing their jobs](https://github.com/rails/rails/pull/53375#issuecomment-2555694252) we find this a highly inconsiderate choice, which has highly negative consequences for a system such as stepper_motor - where durability is paramount. Having good defaults is appropriate, but not removing a crucial affordance that a DB-based job queue provides is downright user-hostile.
602
+ While Rails core admittedly [insists on the stylistic choice of denying the users the option of co-committing their jobs](https://github.com/rails/rails/pull/53375#issuecomment-2555694252) we find this a highly inconsiderate choice. It has negative consequences for a system such as stepper_motor - where durability is paramount. Having good defaults is great, but removing a crucial affordance that a DB-based job queue provides is user-hostile.
277
603
 
278
604
  In the future, stepper_motor _may_ move to a transactional outbox pattern whereby we emit events into a separate table and whichever queue adapter you have installed will be picking those messages up.
605
+ Note that at the moment - if your ActiveJob adapter enqueues after commit - it is possible for your app to crash _between_ the Journey committing and your job enqueueing.
279
606
 
280
- For its own "trigger" job (the `PerformStepJob` and its versions) stepper_motor is configured to commit it with the Journey state changes, within the same transaction.
281
-
282
- This is done for the following reasons:
283
-
284
- * Not having the Journey with up-to-date state in teh DB when the job performs will lead to the job silently skipping, which is undesirable
285
- * But having the app crash between the Journey state committing and the trigger job committing is even less desirable. This would lead to jobs hanging in the `ready` state indefinitly, seemingly at random.
607
+ We may address this in the future using a transactional outbox.
286
608
 
287
609
  ## Saving side-effects of steps
288
610
 
@@ -386,102 +708,40 @@ end
386
708
 
387
709
  If you use in-time scheduling you will need to add the `StepperMotor::ScheduleLoopJob` to your cron jobs, and perform it frequently enough. Note that having just the granularity of your cron jobs (minutes) may not be enough as reattempts of the steps may get scheduled with a smaller delay - of a few seconds, for instance.
388
710
 
389
- ## Naming steps
390
-
391
- stepper_motor will name steps for you. However, using named steps is useful because you then can insert steps between existing ones, and have your `Journey` correctly identify the right step. Steps are performed in the order they are defined. Imagine you start with this step sequence:
392
-
393
- ```ruby
394
- step :one do
395
- # perform some action
396
- end
397
-
398
- step :two do
399
- # perform some other action
400
- end
401
- ```
402
-
403
- 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:
404
-
405
- ```ruby
406
- step :one do
407
- # perform some action
408
- end
409
-
410
- step :one_bis do
411
- # some compliance action
412
- end
413
-
414
- step :two do
415
- # perform some other action
416
- end
417
- ```
711
+ ## Exception handling inside steps
418
712
 
419
- 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.
713
+ > [!IMPORTANT]
714
+ > Exception handling in steps is in flux, expect API changes.
420
715
 
421
- So, rules of thumb:
716
+ By default, an exception raised inside a step of a Journey will _pause_ that Journey at that step. This is done for a number of reasons:
422
717
 
423
- * When steps are recalled to be performed, they get recalled _by name._
424
- * When preparing for the next step, _the next step from the current in order of definition_ is going to be used.
718
+ * An endlessly reattempting step can cause load on your infrastructure and will never stop retrying
719
+ * Since at the moment there is no configuration for backoff, such a step is likely to hit rate limits on the external resource it hits
720
+ * It is likely a condition that was not anticipated when the Journey was written, thus a blind reattempt is unwise.
425
721
 
426
- ## Using instance methods as steps
722
+ While we may change this in future versions of `stepper_motor`, the current default is thus to `pause!` the Journey if an unhandled exception occurs.
427
723
 
428
- You can use instance methods as steps by passing their name as a symbol to the `step` method:
724
+ Should that happen, you can query for the paused Journeys in your Rails console:
429
725
 
430
726
  ```ruby
431
- class Erasure < StepperMotor::Journey
432
- step :erase_attachments
433
- step :erase_emails
434
-
435
- def erase_attachments
436
- hero.uploaded_attachments.find_each(&:destroy)
437
- end
438
-
439
- def erase_emails
440
- while hero.emails.count > 0
441
- hero.emails.limit(5000).delete_all
442
- end
443
- end
444
- end
727
+ my-app(dev)> StepperMotor::Journey.paused
445
728
  ```
446
729
 
447
- Since a method definition in Ruby returns a Symbol, you can use the return value of the `def` expression
448
- to define a `step` immediately:
730
+ If a journey has been paused, you can resume it manually from the Rails console once you have investigated and rectified the error. You do so by calling `resume!` on it:
449
731
 
450
732
  ```ruby
451
- class Erasure < StepperMotor::Journey
452
- step def erase_attachments
453
- hero.uploaded_attachments.find_each(&:destroy)
454
- end
455
-
456
- step def erase_emails
457
- while hero.emails.count > 0
458
- hero.emails.limit(5000).delete_all
459
- end
460
- end
461
- end
733
+ my-app(dev)> journey = StepperMotor::Journey.paused.first!
734
+ my-app(dev)> paused_journey.resume!
462
735
  ```
463
- ## Exception handling inside steps
464
-
465
- > [!IMPORTANT]
466
- > Exception handling in steps is in flux, expect API changes.
467
-
468
- When performing the step, any exceptions raised from within the step will be stored in a local
469
- variable to allow the Journey to be released as either `ready`, `finished` or `canceled`. The exception
470
- will be raised from within an `ensure` block after the persistence of the Journey has been taken care of.
471
-
472
- By default, an exception raised inside a step of a Journey will _pause_ that Journey. This is done for a number of reasons:
473
736
 
474
- * An endlessly reattempting step can cause load on your infrastructure and will never stop retrying
475
- * Since at the moment there is no configuration for backoff, such a step is likely to hit rate limits on the external resource it hits
476
- * It is likely a condition that was not anticipated when the Journey was written, thus a blind reattempt is unwise.
737
+ This action is deliberately manual. You can also set up a job which automatically resumes paused Journeys, but we do not recommend that as things usually fail for a reason.
477
738
 
478
- While we may change this in future versions of `stepper_motor`, the current default is thus to `pause!` the Journey if an unhandled
479
- exception occurs. You can, however, switch it to `reattempt!` or `cancel!` a Journey should a particular step raise. This is configured per step:
739
+ If you can make a decision to cancel or reattempt the journey - that's possible as well. This is configured per step:
480
740
 
481
741
  ```ruby
482
742
  class Erasure < StepperMotor::Journey
483
743
  step :initiate_deletion, on_exception: :reattempt! do
484
- # ..Do the requisite work
744
+ # ..Do the requisite work with aggressive retries
485
745
  end
486
746
  end
487
747
  ```
@@ -524,4 +784,6 @@ class Payment < StepperMotor::Journey
524
784
  end
525
785
  ```
526
786
 
787
+ It can be helpful to understand how exception handling is done by stepper_motor internally: when performing the step, any exceptions raised from within the step will be stored in a local variable to allow the Journey to be released as `ready`, `finished`, `canceled` or `paused`.
527
788
 
789
+ That exception will is then going to be raised from within an `ensure` block, but only after the persistence of the Journey has been taken care of. This way the Journey has way more chance to reach a stable persisted state where it can be recovered (provided the database accepts the write, of course).
@@ -2,7 +2,7 @@
2
2
  # StepperMotor is a module for building multi-step flows where steps are sequential and only
3
3
  # ever progress forward. The building block of StepperMotor is StepperMotor::Journey
4
4
  module StepperMotor
5
- VERSION = T.let("0.1.12", T.untyped)
5
+ VERSION = T.let("0.1.16", T.untyped)
6
6
  PerformStepJobV2 = T.let(StepperMotor::PerformStepJob, T.untyped)
7
7
  RecoverStuckJourneysJobV1 = T.let(StepperMotor::RecoverStuckJourneysJob, T.untyped)
8
8
 
@@ -32,17 +32,28 @@ module StepperMotor
32
32
  #
33
33
  # _@param_ `wait` — the amount of time to wait before entering the step
34
34
  #
35
- # _@param_ `on_exception` — the action to take if an exception occurs when performing the step. The possible values are: * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising.
35
+ # _@param_ `on_exception` — the action to take if an exception occurs when performing the step. The possible values are: * `:cancel!` - cancels the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:reattempt!` - reattempts the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:pause!` - pauses the Journey and re-raises the exception. The Journey will be persisted before re-raising. * `:skip!` - skips the current step and proceeds to the next step, or finishes the journey if it's the last step.
36
+ #
37
+ # _@param_ `skip_if` — condition to check before performing the step. If a boolean is provided, it will be used directly. If nil is provided, it will be treated as false. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will only be performed if the condition returns a truthy value.
36
38
  sig do
37
39
  params(
38
40
  name: T.any(String, Symbol),
39
41
  seq: T.untyped,
40
42
  on_exception: Symbol,
41
43
  wait: T.any(Numeric, ActiveSupport::Duration),
44
+ skip_if: T.any(TrueClass, FalseClass, NilClass, Symbol, Proc),
42
45
  step_block: T.untyped
43
46
  ).void
44
47
  end
45
- def initialize(name:, seq:, on_exception:, wait: 0, &step_block); end
48
+ def initialize(name:, seq:, on_exception: :pause!, wait: 0, skip_if: false, &step_block); end
49
+
50
+ # Checks if the step should be skipped based on the skip_if condition
51
+ #
52
+ # _@param_ `journey` — the journey to check the condition for
53
+ #
54
+ # _@return_ — true if the step should be skipped, false otherwise
55
+ sig { params(journey: StepperMotor::Journey).returns(T::Boolean) }
56
+ def should_skip?(journey); end
46
57
 
47
58
  # Performs the step on the passed Journey, wrapping the step with the required context.
48
59
  #
@@ -128,6 +139,10 @@ module StepperMotor
128
139
  #
129
140
  # _@param_ `on_exception` — See {StepperMotor::Step#on_exception}
130
141
  #
142
+ # _@param_ `skip_if` — condition to check before performing the step. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will be skipped if the condition returns a truthy value.
143
+ #
144
+ # _@param_ `if` — condition to check before performing the step. If a symbol is provided, it will call the method on the Journey. If a block is provided, it will be executed with the Journey as context. The step will be performed if the condition returns a truthy value. and skipped otherwise. Inverse of `skip_if`.
145
+ #
131
146
  # _@param_ `additional_step_definition_options` — Any remaining options get passed to `StepperMotor::Step.new` as keyword arguments.
132
147
  #
133
148
  # _@return_ — the step definition that has been created
@@ -136,12 +151,11 @@ module StepperMotor
136
151
  name: T.nilable(String),
137
152
  wait: T.nilable(T.any(Float, T.untyped, ActiveSupport::Duration)),
138
153
  after: T.nilable(T.any(Float, T.untyped, ActiveSupport::Duration)),
139
- on_exception: Symbol,
140
- additional_step_definition_options: T.untyped,
154
+ additional_step_definition_options: T::Hash[T.untyped, T.untyped],
141
155
  blk: T.untyped
142
156
  ).returns(StepperMotor::Step)
143
157
  end
144
- def self.step(name = nil, wait: nil, after: nil, on_exception: :pause!, **additional_step_definition_options, &blk); end
158
+ def self.step(name = nil, wait: nil, after: nil, **additional_step_definition_options, &blk); end
145
159
 
146
160
  # sord warn - "StepperMotor::Step?" does not appear to be a type
147
161
  # Returns the `Step` object for a named step. This is used when performing a step, but can also
@@ -234,6 +248,20 @@ module StepperMotor
234
248
  sig { params(wait: T.untyped).returns(T.untyped) }
235
249
  def reattempt!(wait: nil); end
236
250
 
251
+ # Is used to skip the current step and proceed to the next step in the journey. This is useful when you want to
252
+ # conditionally skip a step based on some business logic without canceling the entire journey. For example,
253
+ # you might want to skip a reminder email step if the user has already taken the required action.
254
+ #
255
+ # If there are more steps after the current step, `skip!` will schedule the next step to be performed.
256
+ # If the current step is the last step in the journey, `skip!` will finish the journey.
257
+ #
258
+ # `skip!` may be called within a step or outside of a step for journeys in the `ready` state.
259
+ # When called outside of a step, it will skip the next scheduled step and proceed to the following step.
260
+ #
261
+ # _@return_ — void
262
+ sig { returns(T.untyped) }
263
+ def skip!; end
264
+
237
265
  # Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
238
266
  # journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
239
267
  #
@@ -285,6 +313,20 @@ module StepperMotor
285
313
  sig { params(wait: T.untyped).returns(T.untyped) }
286
314
  def reattempt!(wait: nil); end
287
315
 
316
+ # Is used to skip the current step and proceed to the next step in the journey. This is useful when you want to
317
+ # conditionally skip a step based on some business logic without canceling the entire journey. For example,
318
+ # you might want to skip a reminder email step if the user has already taken the required action.
319
+ #
320
+ # If there are more steps after the current step, `skip!` will schedule the next step to be performed.
321
+ # If the current step is the last step in the journey, `skip!` will finish the journey.
322
+ #
323
+ # `skip!` may be called within a step or outside of a step for journeys in the `ready` state.
324
+ # When called outside of a step, it will skip the next scheduled step and proceed to the following step.
325
+ #
326
+ # _@return_ — void
327
+ sig { returns(T.untyped) }
328
+ def skip!; end
329
+
288
330
  # Is used to pause a Journey at any point. The "paused" state is similar to the "ready" state, except that "perform_next_step!" on the
289
331
  # journey will do nothing - even if it is scheduled to be performed. Pausing a Journey can be useful in the following situations:
290
332
  #