actionable-rb 1.9.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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/.claude-plugin/marketplace.json +15 -0
  3. data/.claude-plugin/plugin.json +9 -0
  4. data/.rspec +3 -0
  5. data/AGENTS.md +150 -0
  6. data/CHANGELOG.md +766 -0
  7. data/CODE_OF_CONDUCT.md +132 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +555 -0
  10. data/UPGRADING.md +957 -0
  11. data/USAGE.md +1291 -0
  12. data/docs/getting_started.md +611 -0
  13. data/lib/actionable/action.rb +1220 -0
  14. data/lib/actionable/batch_result.rb +92 -0
  15. data/lib/actionable/errors.rb +36 -0
  16. data/lib/actionable/history.rb +91 -0
  17. data/lib/actionable/input_dispatch.rb +94 -0
  18. data/lib/actionable/job.rb +157 -0
  19. data/lib/actionable/measurement.rb +64 -0
  20. data/lib/actionable/rails/after_commit.rb +142 -0
  21. data/lib/actionable/rails/proxy_validator.rb +59 -0
  22. data/lib/actionable/rails/transactions.rb +95 -0
  23. data/lib/actionable/rails.rb +24 -0
  24. data/lib/actionable/railtie.rb +10 -0
  25. data/lib/actionable/rbs.rb +502 -0
  26. data/lib/actionable/registry.rb +64 -0
  27. data/lib/actionable/result.rb +166 -0
  28. data/lib/actionable/results/failure.rb +43 -0
  29. data/lib/actionable/results/skipped.rb +30 -0
  30. data/lib/actionable/results/success.rb +14 -0
  31. data/lib/actionable/rspec/coverage/report.rb +264 -0
  32. data/lib/actionable/rspec/coverage/tracker.rb +330 -0
  33. data/lib/actionable/rspec/coverage.rb +45 -0
  34. data/lib/actionable/rspec/matchers.rb +266 -0
  35. data/lib/actionable/rspec/stubs.rb +250 -0
  36. data/lib/actionable/rspec.rb +40 -0
  37. data/lib/actionable/rubocop/config.yml +8 -0
  38. data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
  39. data/lib/actionable/rubocop/plugin.rb +31 -0
  40. data/lib/actionable/rubocop.rb +25 -0
  41. data/lib/actionable/runner.rb +300 -0
  42. data/lib/actionable/steps/action.rb +76 -0
  43. data/lib/actionable/steps/base.rb +107 -0
  44. data/lib/actionable/steps/case.rb +124 -0
  45. data/lib/actionable/steps/method.rb +39 -0
  46. data/lib/actionable/steps.rb +27 -0
  47. data/lib/actionable/value_match.rb +22 -0
  48. data/lib/actionable/verb_position.rb +574 -0
  49. data/lib/actionable/version.rb +6 -0
  50. data/lib/actionable-rb.rb +6 -0
  51. data/lib/actionable.rb +59 -0
  52. data/sig/actionable/dsl.rbs +42 -0
  53. data/sig/actionable/rails.rbs +16 -0
  54. data/sig/actionable.rbs +2593 -0
  55. data/skills/actionable/SKILL.md +214 -0
  56. metadata +132 -0
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # The value object returned by {Action.run_each}: an ordered, +Enumerable+
5
+ # collection of the per-item {Result}s, plus aggregate predicates over them
6
+ # (decision D19). Each item ran independently — one {Result} per element, in
7
+ # input order — so a failure in one never affects another.
8
+ #
9
+ # batch = ImportRow.run_each(rows) { |row| {row: row} }
10
+ # batch.all_ok? # => false
11
+ # batch.failures.size # => 2
12
+ # batch.to_api_h # => [{index: 0, status: :success, ...}, ...]
13
+ class BatchResult
14
+ include Enumerable
15
+
16
+ # @return [Array<Result>] the per-item results, in input order
17
+ attr_reader :results
18
+
19
+ # @param results [Array<Result>]
20
+ def initialize(results)
21
+ @results = results.to_a
22
+ end
23
+
24
+ # @yieldparam result [Result]
25
+ # @return [Enumerator, self]
26
+ def each(&block)
27
+ return to_enum(:each) unless block
28
+
29
+ @results.each(&block)
30
+ self
31
+ end
32
+
33
+ # @param index [Integer]
34
+ # @return [Result, nil] the result at +index+
35
+ def [](index)
36
+ @results[index]
37
+ end
38
+
39
+ # @return [Integer] the number of results
40
+ def size
41
+ @results.size
42
+ end
43
+ alias length size
44
+
45
+ # @return [Boolean] whether the batch ran no items
46
+ def empty?
47
+ @results.empty?
48
+ end
49
+
50
+ # @return [Boolean] whether every result is +ok?+ (success or skip);
51
+ # vacuously true for an empty batch
52
+ def all_ok?
53
+ @results.all?(&:ok?)
54
+ end
55
+
56
+ # @return [Boolean] whether any result is a failure
57
+ def any_failure?
58
+ @results.any?(&:failure?)
59
+ end
60
+
61
+ # @return [Boolean] whether the batch is mixed — at least one failure and at
62
+ # least one +ok?+ result
63
+ def partial?
64
+ any_failure? && @results.any?(&:ok?)
65
+ end
66
+
67
+ # @return [Array<Result>] the successful results
68
+ def successes
69
+ @results.select(&:success?)
70
+ end
71
+
72
+ # @return [Array<Result>] the failed results
73
+ def failures
74
+ @results.select(&:failure?)
75
+ end
76
+
77
+ # @return [Array<Result>] the skipped results
78
+ def skips
79
+ @results.select(&:skipped?)
80
+ end
81
+
82
+ # The batch as an array of API elements, each tagged with its position
83
+ # (see {Result#to_api_h}). Ready to render as a JSON array of per-item
84
+ # statuses.
85
+ #
86
+ # @param id_field [Symbol] the output field to surface as +:id+
87
+ # @return [Array<Hash{Symbol=>Object}>]
88
+ def to_api_h(id_field: :id)
89
+ @results.each_with_index.map { |result, index| result.to_api_h(index: index, id_field: id_field) }
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # Base class for every error this gem raises. Rescue +Actionable::Error+ to
5
+ # catch the whole family. Genuine errors are never caught by the runner for
6
+ # control flow — they propagate to the caller (decision D4).
7
+ class Error < StandardError; end
8
+
9
+ # Raised when an action is misconfigured — a reserved output field name, or a
10
+ # declared step whose method isn't implemented. Surfaced early and clearly so
11
+ # humans and agents get an actionable message instead of a deep +NoMethodError+
12
+ # (decision D18).
13
+ class DefinitionError < Error; end
14
+
15
+ # The throw/catch tag used to unwind an action's step pipeline early.
16
+ # Halting (+fail!+ / +succeed!+ / +halt!+) throws this symbol; the runner
17
+ # catches it via {Actionable.catch_halt}. This is control flow, NOT an
18
+ # exception (decision D4).
19
+ # @return [Symbol]
20
+ HALT = :actionable_halt
21
+
22
+ module_function
23
+
24
+ # Run +block+ inside the halt catch point.
25
+ #
26
+ # Returns the block's value when nothing is thrown, or the value passed with
27
+ # +throw Actionable::HALT, value+ when the pipeline halts (+nil+ for a bare
28
+ # +throw Actionable::HALT+). A raised StandardError is deliberately NOT
29
+ # caught — it propagates to the caller (decision D4).
30
+ #
31
+ # @yieldreturn [Object]
32
+ # @return [Object] the block's value, or the thrown halt value
33
+ def catch_halt(&block)
34
+ catch(HALT, &block)
35
+ end
36
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+ require 'time'
5
+
6
+ module Actionable
7
+ # An ordered record of the steps a run executed, when measurement is enabled
8
+ # (decision D10). Each entry is a {History::Step}; nested action steps carry
9
+ # their child's +History+.
10
+ class History
11
+ # One recorded step: which section it ran in, its name, when it started, how
12
+ # long it took, the result code it produced (if any), and — for action
13
+ # steps — the nested child {History}.
14
+ class Step
15
+ # @return [Symbol] +:main+, +:success+, +:failure+, or +:always+
16
+ attr_reader :section
17
+ # @return [Symbol, String, Class] the step's name
18
+ attr_reader :name
19
+ # @return [Time] wall-clock start time
20
+ attr_reader :started_at
21
+ # @return [Float] elapsed seconds (monotonic)
22
+ attr_accessor :duration
23
+ # @return [Symbol, nil] the result code this step recorded, if it set one
24
+ attr_accessor :code
25
+ # @return [History, nil] the nested action's history, for action steps
26
+ attr_accessor :nested
27
+
28
+ # @param section [Symbol]
29
+ # @param name [Symbol, String, Class]
30
+ # @param started_at [Time]
31
+ def initialize(section:, name:, started_at:)
32
+ @section = section
33
+ @name = name
34
+ @started_at = started_at
35
+ @duration = 0.0
36
+ @code = nil
37
+ @nested = nil
38
+ end
39
+
40
+ # @return [Hash{Symbol=>Object}] a JSON-ready hash (nested history recurses)
41
+ def as_json(*)
42
+ {
43
+ section: section,
44
+ name: name.is_a?(Class) ? name.name : name,
45
+ started_at: started_at.iso8601(6),
46
+ duration: duration,
47
+ code: code,
48
+ history: nested&.as_json
49
+ }
50
+ end
51
+ end
52
+
53
+ # @return [Array<Step>] the recorded steps, in execution order
54
+ attr_reader :steps
55
+
56
+ def initialize
57
+ @steps = []
58
+ end
59
+
60
+ # Append a recorded step.
61
+ #
62
+ # @param step [Step]
63
+ # @return [self]
64
+ def <<(step)
65
+ @steps << step
66
+ self
67
+ end
68
+
69
+ # @return [Boolean] whether any step has been recorded
70
+ def empty?
71
+ @steps.empty?
72
+ end
73
+
74
+ # @return [Float] the summed duration of every recorded step
75
+ def took
76
+ @steps.sum(&:duration)
77
+ end
78
+
79
+ # @return [Array<Hash>] each step as a JSON-ready hash
80
+ def as_json(*)
81
+ @steps.map(&:as_json)
82
+ end
83
+
84
+ # @return [String] the history serialized to JSON via Oj (decision D15).
85
+ # +:compat+ mode emits plain JSON (string keys, symbols as strings) rather
86
+ # than Oj's default object-marker encoding.
87
+ def to_json(*)
88
+ Oj.dump(as_json, mode: :compat)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # Runtime selection of an input schema from a discriminator value (decision
5
+ # D19, declared with +input_for+). Each branch maps a match value to a
6
+ # FieldStruct schema; an optional +default+ catches the rest. Lets a
7
+ # polymorphic payload (e.g. +event_type+ → shape) use typed, validated input.
8
+ #
9
+ # input_for :event_type do
10
+ # on 'sale', SaleShape
11
+ # on 'refund', RefundShape
12
+ # default UnknownShape
13
+ # end
14
+ class InputDispatch
15
+ # Collects +on+/+default+ branch declarations inside an +input_for+ block.
16
+ class Builder
17
+ # @return [Array<Array(Object, Class<FieldStruct::Base>)>] ordered [match, schema] pairs
18
+ attr_reader :branches
19
+ # @return [Class<FieldStruct::Base>, nil] the fallback schema, if declared
20
+ attr_reader :default_schema
21
+
22
+ def initialize
23
+ @branches = []
24
+ @default_schema = nil
25
+ end
26
+
27
+ # Register a branch. +value+ matches the discriminator by +==+, by
28
+ # Array membership, or by +Regexp#match?+ (see {ValueMatch}).
29
+ #
30
+ # @param value [Object, Array, Regexp]
31
+ # @param schema [Class<FieldStruct::Base>]
32
+ # @return [void]
33
+ def on(value, schema)
34
+ @branches << [value, schema]
35
+ end
36
+
37
+ # Register the fallback schema, used when no +on+ branch matches.
38
+ #
39
+ # @param schema [Class<FieldStruct::Base>]
40
+ # @return [void]
41
+ def default(schema)
42
+ @default_schema = schema
43
+ end
44
+ end
45
+
46
+ # @return [Symbol] the discriminator field read from +.run+'s kwargs
47
+ attr_reader :discriminator
48
+ # @return [Array<Array(Object, Class<FieldStruct::Base>)>] ordered [match, schema] pairs
49
+ attr_reader :branches
50
+ # @return [Class<FieldStruct::Base>, nil] the fallback schema
51
+ attr_reader :default_schema
52
+
53
+ # Build a dispatch from an +input_for+ declaration.
54
+ #
55
+ # @param discriminator [Symbol, String] the kwarg whose value selects the schema
56
+ # @yield the branch declarations (+on+ / +default+)
57
+ # @return [InputDispatch]
58
+ def self.define(discriminator, &block)
59
+ builder = Builder.new
60
+ builder.instance_eval(&block)
61
+ new(discriminator.to_sym, builder.branches, builder.default_schema)
62
+ end
63
+
64
+ # @param discriminator [Symbol]
65
+ # @param branches [Array<Array(Object, Class<FieldStruct::Base>)>]
66
+ # @param default_schema [Class<FieldStruct::Base>, nil]
67
+ def initialize(discriminator, branches, default_schema)
68
+ @discriminator = discriminator
69
+ @branches = branches
70
+ @default_schema = default_schema
71
+ end
72
+
73
+ # The schema for a discriminator value: the first matching branch, else the
74
+ # default, else +nil+ (no match and no default).
75
+ #
76
+ # @param value [Object] the discriminator value
77
+ # @return [Class<FieldStruct::Base>, nil]
78
+ def schema_for(value)
79
+ @branches.each { |branch_value, schema| return schema if ValueMatch.matches?(branch_value, value) }
80
+ @default_schema
81
+ end
82
+
83
+ # @return [Array<Class<FieldStruct::Base>>] every distinct schema reachable
84
+ # through this dispatch (branches plus default)
85
+ def schemas
86
+ (@branches.map(&:last) + [@default_schema]).compact.uniq
87
+ end
88
+
89
+ # @return [Array<Object>] the branch match values, in declaration order
90
+ def variants
91
+ @branches.map(&:first)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional background-job integration (decision D19). Load it explicitly with
4
+ # `require 'actionable/job'`. Framework-agnostic and pure Ruby — it maps an
5
+ # action {Actionable::Result} to a queue *disposition* and provides a {Mixin}
6
+ # you include into a Sidekiq worker or ActiveJob job. Never loaded by the core
7
+ # entry point.
8
+ require_relative '../actionable' # ensure the core is loaded (self-contained entry)
9
+
10
+ module Actionable
11
+ # Runs an action inside a background job and maps its result to a queue
12
+ # disposition: a success or skip is done (+:ack+); a failure either retries
13
+ # (transient) or is discarded (permanent). Genuine exceptions are never
14
+ # swallowed — they propagate so the queue's own retry/backoff handles them
15
+ # (invariant 2).
16
+ module Job
17
+ # Failure codes that should NOT be retried — a re-run can't fix bad input or
18
+ # a missing record, so the job is discarded rather than retried. Override
19
+ # per job via {Mixin#actionable_permanent_codes}.
20
+ # @return [Array<Symbol>]
21
+ DEFAULT_PERMANENT_CODES = %i[invalid_input invalid_output not_found].freeze
22
+
23
+ # Raised by {Mixin#run_actionable} for a retryable failure, so the host
24
+ # queue (Sidekiq / ActiveJob) sees a raised error and applies its retry
25
+ # policy. Carries the offending result.
26
+ class RetryableFailure < StandardError
27
+ # @return [Result] the failed result that triggered the retry
28
+ attr_reader :result
29
+
30
+ # @param result [Result] a {Failure}, or a {Skipped} retried because its
31
+ # metadata says +retry: true+ (see {RetryMetadata#honor_retry_metadata})
32
+ def initialize(result)
33
+ @result = result
34
+ verb = result.skipped? ? 'skipped' : 'failed'
35
+ super("actionable run #{verb} with retryable code #{result.code.inspect}")
36
+ end
37
+ end
38
+
39
+ # The +honor_retry_metadata+ class macro (owner ruling P10). Extended onto
40
+ # {Actionable::Action} by +actionable/job+, so an action can opt in to having
41
+ # {Mixin#run_actionable} act on the +retry:+ metadata of its declared codes.
42
+ # Metadata stays neutral by default (decisions D20/D21).
43
+ module RetryMetadata
44
+ # Opt this action in (or, with +enabled: false+, back out) to {Job}
45
+ # honoring the +retry:+ metadata of its declared failures and skips:
46
+ # +retry: true+ retries the job, +retry: false+ does not. A code with no
47
+ # +retry:+ key keeps the default mapping. Inherited; a subclass can turn
48
+ # it off.
49
+ #
50
+ # class Reindex < Actionable::Action
51
+ # honor_retry_metadata
52
+ # skips :not_ready, retry: true # the job retries until the index is ready
53
+ # end
54
+ #
55
+ # @param enabled [Boolean]
56
+ # @return [void]
57
+ def honor_retry_metadata(enabled: true)
58
+ @honor_retry_metadata = enabled
59
+ end
60
+
61
+ # Whether {Job} honors this action's +retry:+ metadata, inherited from
62
+ # ancestors when not set locally. +false+ unless an ancestor opted in.
63
+ #
64
+ # @return [Boolean]
65
+ def honor_retry_metadata?
66
+ return @honor_retry_metadata if defined?(@honor_retry_metadata)
67
+ return superclass.honor_retry_metadata? if superclass.respond_to?(:honor_retry_metadata?)
68
+
69
+ false
70
+ end
71
+ end
72
+
73
+ module_function
74
+
75
+ # Map a result to a queue disposition.
76
+ #
77
+ # With +honor_retry: true+, a failure or skip whose metadata has a +retry:+
78
+ # key is decided by it instead: truthy is +:retry+, falsy is +:discard+ for
79
+ # a failure and +:ack+ for a skip. Results without the key, and successes,
80
+ # map as usual.
81
+ #
82
+ # @param result [Result] the action's result
83
+ # @param permanent [Array<Symbol>] failure codes treated as non-retryable
84
+ # @param honor_retry [Boolean] act on the result's +retry:+ metadata
85
+ # @return [:ack, :retry, :discard] +:ack+ for a success or skip; +:discard+
86
+ # for a failure whose code is +permanent+; +:retry+ for any other failure
87
+ def disposition(result, permanent: DEFAULT_PERMANENT_CODES, honor_retry: false)
88
+ return metadata_disposition(result) if honor_retry && retry_declared?(result)
89
+ return :ack if result.ok?
90
+
91
+ permanent.include?(result.code) ? :discard : :retry
92
+ end
93
+
94
+ # @param result [Result]
95
+ # @return [Boolean] whether the result carries +retry:+ metadata
96
+ def retry_declared?(result)
97
+ !result.success? && result.metadata.key?(:retry)
98
+ end
99
+ private_class_method :retry_declared?
100
+
101
+ # @param result [Failure, Skipped] a result whose metadata has +retry:+
102
+ # @return [:ack, :retry, :discard]
103
+ def metadata_disposition(result)
104
+ return :retry if result.metadata[:retry]
105
+
106
+ result.failure? ? :discard : :ack
107
+ end
108
+ private_class_method :metadata_disposition
109
+
110
+ # Mix into a Sidekiq worker or ActiveJob job. Call {#run_actionable} from
111
+ # +perform+; it runs the action, records the result on {#actionable_result},
112
+ # and raises {RetryableFailure} for a retryable failure (so the queue
113
+ # retries) — returning normally for +:ack+ and +:discard+.
114
+ #
115
+ # class ProjectEventJob
116
+ # include Sidekiq::Job
117
+ # include Actionable::Job::Mixin
118
+ # def perform(event_id) = run_actionable(ProjectApiEvent, event_id: event_id)
119
+ # end
120
+ module Mixin
121
+ # @return [Result, nil] the result of the most recent {#run_actionable}
122
+ attr_reader :actionable_result
123
+
124
+ # Run +action_class+ with the given arguments, map the outcome, and either
125
+ # return the result (+:ack+ / +:discard+) or raise {RetryableFailure}
126
+ # (+:retry+). A genuine exception inside the action propagates unchanged.
127
+ # When the action opted in with +honor_retry_metadata+, its declared
128
+ # +retry:+ metadata decides (see {Job.disposition}).
129
+ #
130
+ # @param action_class [Class<Actionable::Action>]
131
+ # @param args [Object] positional arguments, forwarded to +.run+
132
+ # @param kwargs [Object] keyword arguments, forwarded to +.run+
133
+ # @return [Result] the run's result (for an ack/discard outcome)
134
+ # @raise [RetryableFailure] when the failure should be retried
135
+ def run_actionable(action_class, *args, **kwargs)
136
+ @actionable_result = action_class.run(*args, **kwargs)
137
+ disposition = Actionable::Job.disposition(
138
+ @actionable_result,
139
+ permanent: actionable_permanent_codes,
140
+ honor_retry: action_class.respond_to?(:honor_retry_metadata?) && action_class.honor_retry_metadata?
141
+ )
142
+ raise RetryableFailure, @actionable_result if disposition == :retry
143
+
144
+ @actionable_result
145
+ end
146
+
147
+ # @return [Array<Symbol>] failure codes this job treats as non-retryable;
148
+ # override to customize. Defaults to {DEFAULT_PERMANENT_CODES}.
149
+ def actionable_permanent_codes
150
+ DEFAULT_PERMANENT_CODES
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ # Wire the adapter into the core: the honor_retry_metadata macro onto Action.
157
+ Actionable::Action.extend(Actionable::Job::RetryMetadata)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ # Thread-local measurement context (decision D10). It lets a measuring run
5
+ # cascade into the nested actions it invokes, and lets an action step attach
6
+ # its child's history to the step record currently open in the parent runner.
7
+ #
8
+ # All state is per-thread and saved/restored around each scope, so concurrent
9
+ # runs on different threads never interfere.
10
+ module Measurement
11
+ class << self
12
+ # @return [Boolean] whether the current thread is inside a measuring run
13
+ def active?
14
+ store[:active]
15
+ end
16
+
17
+ # @return [History::Step, nil] the step record nested history attaches to
18
+ def open_step
19
+ store[:open_step]
20
+ end
21
+
22
+ # Mark measurement active for the block, so nested runs measure too.
23
+ #
24
+ # @yieldreturn [Object]
25
+ # @return [Object] the block's value
26
+ def measuring(&)
27
+ with({active: true}, &)
28
+ end
29
+
30
+ # Make +record+ the open step for the block, so a nested action step can
31
+ # attach its child's history to it.
32
+ #
33
+ # @param record [History::Step]
34
+ # @yieldreturn [Object]
35
+ # @return [Object] the block's value
36
+ def recording(record, &)
37
+ with({open_step: record}, &)
38
+ end
39
+
40
+ private
41
+
42
+ # This thread's measurement state: +:active+ and +:open_step+.
43
+ #
44
+ # @return [Hash{Symbol=>Object}]
45
+ def store
46
+ Thread.current[:actionable_measurement] ||= {active: false, open_step: nil}
47
+ end
48
+
49
+ # Merge +changes+ into the state for the block, restoring it afterwards.
50
+ #
51
+ # @param changes [Hash{Symbol=>Object}] the keys to override
52
+ # @yield the scope the changes apply to
53
+ # @yieldreturn [Object]
54
+ # @return [Object] the block's value
55
+ def with(changes)
56
+ previous = store.dup
57
+ store.merge!(changes)
58
+ yield
59
+ ensure
60
+ Thread.current[:actionable_measurement] = previous
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ module Rails
5
+ # The +after_commit+ hook kind. Prepended onto {Actionable::Action}'s
6
+ # singleton class by +actionable/rails+, so the core never knows about it
7
+ # (decision D8).
8
+ #
9
+ # An +after_commit+ hook runs once, after the database work of a
10
+ # successful run has really committed: it is registered with
11
+ # +ActiveRecord.after_all_transactions_commit+ when the run ends, so it
12
+ # waits for the outermost open transaction (not the action's own
13
+ # savepoint), runs immediately when none is open, and never runs if any
14
+ # enclosing transaction rolls back.
15
+ module AfterCommit
16
+ # The first ActiveRecord version with +after_all_transactions_commit+.
17
+ # @return [String]
18
+ MINIMUM_ACTIVE_RECORD = '7.2'
19
+
20
+ # Declare a hook that runs after the outermost database transaction
21
+ # commits, iff the run's final result is a {Success}.
22
+ #
23
+ # after_commit :send_receipt
24
+ # after_commit NotifyWarehouse, input: %i[order]
25
+ #
26
+ # The run is over by then: the result has been returned (or is about to
27
+ # be, when no transaction is open), so the hook cannot change it, a
28
+ # halting verb only stops the remaining +after_commit+ hooks, and the
29
+ # hook is not recorded in the history. An exception it raises propagates
30
+ # from wherever the commit happens: the outer +transaction+ call, or
31
+ # +.run+ itself when no transaction was open.
32
+ #
33
+ # @param target [Symbol, String, Class<Actionable::Action>] the instance
34
+ # method or nested action to run
35
+ # @param options [Object] step options (+:if+, +:unless+, and for a
36
+ # nested action +:input+)
37
+ # @raise [DefinitionError] when ActiveRecord is not loaded or predates
38
+ # +after_all_transactions_commit+
39
+ # @return [Set<Steps::Base>]
40
+ def after_commit(target, **options)
41
+ ensure_after_commit_supported!
42
+ declare(after_commit_hooks, Steps.build(target, **options))
43
+ end
44
+
45
+ # Hooks that run after the outermost transaction commits a successful run.
46
+ # @return [Set<Steps::Base>]
47
+ def after_commit_hooks
48
+ @after_commit_hooks ||= Set.new
49
+ end
50
+
51
+ # {Actionable::Action.describe}, with the +after_commit+ hooks listed as
52
+ # one more +:hooks+ section, so {Actionable::Action.describe_text} shows
53
+ # them too (only when there are any, like the core sections).
54
+ #
55
+ # @return [Hash{Symbol=>Object}]
56
+ def describe
57
+ summary = super
58
+ summary[:hooks] = summary[:hooks].merge(after_commit: after_commit_hooks.map(&:name))
59
+ summary
60
+ end
61
+
62
+ # Give each subclass its own copy of the inherited +after_commit+ hooks.
63
+ #
64
+ # @param subclass [Class]
65
+ # @return [void]
66
+ def inherited(subclass)
67
+ super
68
+ subclass.instance_variable_set(:@after_commit_hooks, after_commit_hooks.dup)
69
+ end
70
+
71
+ private
72
+
73
+ # Extend the run-start guard (decision D18) to the +after_commit+ hooks.
74
+ #
75
+ # @param instance [Action]
76
+ # @return [void]
77
+ def ensure_steps_implemented!(instance)
78
+ super
79
+ missing = after_commit_hooks.flat_map(&:required_methods).uniq.reject { |method| implements?(instance, method) }
80
+ return if missing.empty?
81
+
82
+ raise DefinitionError,
83
+ "#{name || "action"} declares after_commit hook(s) calling #{missing.map(&:inspect).join(", ")} " \
84
+ 'but does not implement them'
85
+ end
86
+
87
+ # Extend the recording-verb position check (owner ruling R5) to the
88
+ # +after_commit+ hooks.
89
+ #
90
+ # @return [Array<Steps::Base>]
91
+ def verb_checked_steps
92
+ super + after_commit_hooks.to_a
93
+ end
94
+
95
+ # @raise [DefinitionError] when +after_all_transactions_commit+ is unavailable
96
+ # @return [void]
97
+ def ensure_after_commit_supported!
98
+ return if defined?(::ActiveRecord) && ::ActiveRecord.respond_to?(:after_all_transactions_commit)
99
+
100
+ raise DefinitionError,
101
+ "after_commit needs ActiveRecord >= #{MINIMUM_ACTIVE_RECORD} " \
102
+ '(ActiveRecord.after_all_transactions_commit); load a supported ActiveRecord before declaring it'
103
+ end
104
+ end
105
+
106
+ # Prepended onto {Actionable::Runner} (outside {RunnerTransaction}) to
107
+ # register a successful run's +after_commit+ hooks once the run, and its
108
+ # own transaction, are over.
109
+ module RunnerAfterCommit
110
+ # @return [Result]
111
+ def run
112
+ result = super
113
+ schedule_after_commit if result.success?
114
+ result
115
+ end
116
+
117
+ private
118
+
119
+ # @return [void]
120
+ def schedule_after_commit
121
+ klass = @instance.class
122
+ return unless klass.respond_to?(:after_commit_hooks)
123
+
124
+ hooks = klass.after_commit_hooks.to_a
125
+ return if hooks.empty?
126
+
127
+ ::ActiveRecord.after_all_transactions_commit { run_after_commit_hooks(hooks) }
128
+ end
129
+
130
+ # Run the hooks in order. A halt stops the remaining ones; nothing they
131
+ # record reaches the already-returned result.
132
+ #
133
+ # @param hooks [Array<Steps::Base>]
134
+ # @return [void]
135
+ def run_after_commit_hooks(hooks)
136
+ Actionable.catch_halt do
137
+ hooks.each { |hook| hook.call(@instance) unless hook.skip?(@instance) }
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end