axn 0.1.0.pre.alpha.2.5.1.2 → 0.1.0.pre.alpha.2.5.3

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: 42cc1a4a04b3e66593f20e8c705d21b18d0805b335ecc0f466aadbe66a4b34b1
4
- data.tar.gz: 6e402f557bac045b01271a782a549264b52435a31ce4422b9023df3305cc378d
3
+ metadata.gz: d62aa4b98c8f159761234817e6b2ded26e0fcafa46d07685049d827281e92235
4
+ data.tar.gz: d3ffb46353e17db427393049767a6a03fbf3d5b30e94e4011a47397644364c32
5
5
  SHA512:
6
- metadata.gz: 2dc07f5c6be221dc0d46961c8ea2810ed42a8b17d5bc195f4b695b782695f489259f6e9f6c4369b2e13e2b87f1a779db7fb71dbf8fd59b5712169512bac2c217
7
- data.tar.gz: d772ed5b642945eaa6ed3c09d0161b26e7763ec692e481d70df893b88a9d1779018978806fb10459d84f8479eb2a5616a56e4c109723dc610227ec0e41998502
6
+ metadata.gz: 54a4ea06f021850cc1d4e1e9bf18a6ec20f495871f23efab20380cd2a8135c52df46099f03281944c4dbe4419c836a72309cfd719e769ae3fd7e48a2ad7683db
7
+ data.tar.gz: 474eea3af1b53ad4ae85096a2e39d4b141b01c8c4f239f710cd13de933046d92b7efc67333902c78ed29c882bda5ada0a44e9e2efeab05721681f5ded1633b41
data/.rubocop.yml CHANGED
@@ -27,6 +27,12 @@ Style/TrailingCommaInArrayLiteral:
27
27
  Style/TrailingCommaInHashLiteral:
28
28
  EnforcedStyleForMultiline: comma
29
29
 
30
+ Style/ClassAndModuleChildren:
31
+ Enabled: false
32
+
33
+ Style/HashSyntax:
34
+ EnforcedShorthandSyntax: always
35
+
30
36
  Style/DoubleNegation:
31
37
  Enabled: false
32
38
 
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+ * N/A
5
+
6
+ ## 0.1.0-alpha.2.5.3
7
+ * More aggressive logging of swallowed exceptions when not in production mode
8
+ * Make automatic pre/post logging more digestible
9
+
10
+ ## 0.1.0-alpha.2.5.2
11
+ * [BREAKING] Removing `EnqueueAllInBackground` + `EnqueueAllWorker` - better + simply solved at application level
12
+ * [TEST] Expose spec helpers to consumers (add `require "axn/testing/spec_helpers"` to your `spec_helper.rb`)
13
+ # [FEAT] Added ability to use custom Strategies (via e.g. `use :transaction`)
14
+
3
15
  ## 0.1.0-alpha.2.5.1.2
4
16
  * [BUGFIX] Subfield expectations: now support hashes with string keys (using with_indifferent_access)
5
17
  * [BUGFIX] Subfield expectations: Model reader fields now cache initial value (otherwise get fresh instance each call, cannot make in-memory changes)
@@ -47,6 +47,13 @@ export default defineConfig({
47
47
  { text: 'Testing Actions', link: '/recipes/testing' },
48
48
  ]
49
49
  },
50
+ {
51
+ text: 'Strategies',
52
+ items: [
53
+ { text: 'Overview', link: '/strategies/index' },
54
+ { text: 'Transaction', link: '/strategies/transaction' },
55
+ ]
56
+ },
50
57
  {
51
58
  text: 'Additional Notes',
52
59
  items: [
@@ -36,6 +36,22 @@ For example, if you're using Honeybadger this could look something like:
36
36
  end
37
37
  ```
38
38
 
39
+ **Note:** The `action:` and `context:` keyword arguments are *optional*—your proc can accept any combination of `e`, `action:`, and `context:`. Only the keyword arguments you explicitly declare will be passed to your handler. All of the following are valid:
40
+
41
+ ```ruby
42
+ # Only exception object
43
+ c.on_exception = proc { |e| ... }
44
+
45
+ # Exception and action
46
+ c.on_exception = proc { |e, action:| ... }
47
+
48
+ # Exception and context
49
+ c.on_exception = proc { |e, context:| ... }
50
+
51
+ # Exception, action, and context
52
+ c.on_exception = proc { |e, action:, context:| ... }
53
+ ```
54
+
39
55
  A couple notes:
40
56
 
41
57
  * `context` will contain the arguments passed to the `action`, _but_ any marked as sensitive (e.g. `expects :foo, sensitive: true`) will be filtered out in the logs.
@@ -0,0 +1,272 @@
1
+ # Strategies
2
+
3
+ Strategies in Axn are reusable modules that provide common functionality and configuration patterns for your actions. They allow you to DRY up your code by encapsulating frequently used behaviors into named, configurable modules.
4
+
5
+ ## What are Strategies?
6
+
7
+ Strategies are Ruby modules that can be included into your actions to add specific functionality. They're designed to be:
8
+
9
+ - **Reusable**: Once defined, they can be used across multiple actions
10
+ - **Configurable**: Many strategies support configuration options
11
+ - **Composable**: You can use multiple strategies in a single action
12
+ - **Discoverable**: Built-in strategies are automatically loaded, and custom ones can be registered
13
+
14
+ ## How to Use Strategies
15
+
16
+ ### Basic Usage
17
+
18
+ To use a strategy in your action, call the `use` method with the strategy name:
19
+
20
+ ```ruby
21
+ class CreateUser
22
+ include Action
23
+
24
+ use :transaction
25
+
26
+ expects :email, :name
27
+
28
+ def call
29
+ # This action will now run within a database transaction (including before/after hooks)
30
+ user = User.create!(email: email, name: name)
31
+ expose :user, user
32
+ end
33
+ end
34
+ ```
35
+
36
+ ### Using Strategies with Configuration
37
+
38
+ Some strategies support configuration options. These strategies have a `setup` method that accepts configuration and returns a configured module. As an _imaginary_ example:
39
+
40
+ ```ruby
41
+ class ProcessPayment
42
+ include Action
43
+
44
+ use :retry, max_attempts: 3, backoff: :exponential
45
+
46
+ expects :amount, :card_token
47
+
48
+ def call
49
+ # This action will retry up to 3 times with exponential backoff
50
+ result = PaymentProcessor.charge(amount, card_token)
51
+ expose :transaction_id, result.id
52
+ end
53
+ end
54
+ ```
55
+
56
+ ## Built-in Strategies
57
+
58
+ The list of built in strategies is available via `Action::Strategies.built_in`.
59
+
60
+ ## Registering Custom Strategies
61
+
62
+ ### Simple Strategies
63
+
64
+ To create a custom strategy, define a module that extends `ActiveSupport::Concern`:
65
+
66
+ ```ruby
67
+ module MyCustomStrategy
68
+ extend ActiveSupport::Concern
69
+
70
+ included do
71
+ # Add your strategy behavior here
72
+ # For example, add hooks, validations, or other functionality
73
+ before { log("Custom strategy before hook") }
74
+ after { log("Custom strategy after hook") }
75
+ end
76
+ end
77
+ ```
78
+
79
+ Then register it with the strategies system:
80
+
81
+ ```ruby
82
+ Action::Strategies.register(:my_custom, MyCustomStrategy)
83
+ ```
84
+
85
+ Now you can use it in your actions:
86
+
87
+ ```ruby
88
+ class MyAction
89
+ include Action
90
+
91
+ use :my_custom
92
+
93
+ def call
94
+ # Your action implementation
95
+ end
96
+ end
97
+ ```
98
+
99
+ ### Configurable Strategies
100
+
101
+ For strategies that need configuration, implement a `setup` method that returns a configured module:
102
+
103
+ ```ruby
104
+ module RetryStrategy
105
+ extend ActiveSupport::Concern
106
+
107
+ def self.setup(max_attempts: 3, backoff: :linear, &block)
108
+ Module.new do
109
+ extend ActiveSupport::Concern
110
+
111
+ included do
112
+ around do |hooked|
113
+ attempts = 0
114
+ begin
115
+ attempts += 1
116
+ hooked.call
117
+ rescue StandardError => e
118
+ if attempts < max_attempts
119
+ sleep(backoff_delay(attempts, backoff))
120
+ retry
121
+ else
122
+ raise e
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ def backoff_delay(attempt, type)
131
+ case type
132
+ when :linear
133
+ attempt * 0.1
134
+ when :exponential
135
+ 0.1 * (2 ** (attempt - 1))
136
+ else
137
+ 0.1
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ # Register the strategy
145
+ Action::Strategies.register(:retry, RetryStrategy)
146
+ ```
147
+
148
+ ### Strategy Registration Best Practices
149
+
150
+ 1. **Register early**: Register custom strategies during application initialization
151
+ 2. **Use descriptive names**: Choose strategy names that clearly indicate their purpose
152
+ 3. **Handle configuration validation**: Validate configuration options in your `setup` method
153
+ 4. **Return proper modules**: Always return a module from the `setup` method
154
+ 5. **Document your strategies**: Include clear documentation for how to use your custom strategies
155
+
156
+ ### Example: Complete Custom Strategy
157
+
158
+ Here's a complete example of a custom strategy that adds performance monitoring:
159
+
160
+ ```ruby
161
+ module PerformanceMonitoringStrategy
162
+ extend ActiveSupport::Concern
163
+
164
+ def self.setup(threshold_ms: 1000, notify_slow: false, &block)
165
+ Module.new do
166
+ extend ActiveSupport::Concern
167
+
168
+ included do
169
+ around do |hooked|
170
+ start_time = Time.current
171
+ result = hooked.call
172
+ duration = ((Time.current - start_time) * 1000).round(2)
173
+
174
+ if duration > threshold_ms
175
+ log("Action took #{duration}ms (threshold: #{threshold_ms}ms)", level: :warn)
176
+ notify_slow_action(duration) if notify_slow
177
+ else
178
+ log("Action completed in #{duration}ms", level: :info)
179
+ end
180
+
181
+ result
182
+ end
183
+ end
184
+
185
+ private
186
+
187
+ def notify_slow_action(duration)
188
+ # In a real implementation, this might send to a monitoring service
189
+ # like New Relic, DataDog, or a custom alerting system
190
+ Rails.logger.warn("SLOW ACTION ALERT: #{self.class.name} took #{duration}ms")
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ # Register the strategy
197
+ Action::Strategies.register(:performance_monitoring, PerformanceMonitoringStrategy)
198
+
199
+ # Use it in an action
200
+ class ExpensiveCalculation
201
+ include Action
202
+
203
+ use :performance_monitoring, threshold_ms: 500, notify_slow: true
204
+
205
+ expects :data
206
+
207
+ def call
208
+ # This action will be monitored for performance
209
+ result = perform_expensive_calculation(data)
210
+ expose :result, result
211
+ end
212
+
213
+ private
214
+
215
+ def perform_expensive_calculation(data)
216
+ # Simulate expensive operation
217
+ sleep(0.1)
218
+ data.map { |item| item * 2 }
219
+ end
220
+ end
221
+ ```
222
+
223
+ ## Strategy Management
224
+
225
+ ### Viewing Available Strategies
226
+
227
+ You can inspect all registered strategies:
228
+
229
+ ```ruby
230
+ Action::Strategies.all
231
+ # Returns a hash of strategy names to their modules
232
+ ```
233
+
234
+ ### Finding Specific Strategies
235
+
236
+ To find a specific strategy by name:
237
+
238
+ ```ruby
239
+ Action::Strategies.find(:transaction)
240
+ # Returns the strategy module for the transaction strategy
241
+
242
+ Action::Strategies.find(:nonexistent)
243
+ # Raises Action::StrategyNotFound: Strategy 'nonexistent' not found
244
+ ```
245
+
246
+ The `find` method is useful when you need to programmatically access a strategy module or verify that a strategy exists before using it.
247
+
248
+ ### Clearing Strategies
249
+
250
+ To reset strategies to only built-in ones (useful in tests):
251
+
252
+ ```ruby
253
+ Action::Strategies.clear!
254
+ ```
255
+
256
+ ### Strategy Errors
257
+
258
+ The following errors may be raised when using strategies:
259
+
260
+ - `Action::StrategyNotFound`: When trying to use a strategy that hasn't been registered
261
+ - `Action::DuplicateStrategyError`: When trying to register a strategy with a name that's already taken
262
+ - `ArgumentError`: When providing configuration to a strategy that doesn't support it
263
+
264
+ ## Best Practices
265
+
266
+ 1. **Keep strategies focused**: Each strategy should have a single, well-defined responsibility
267
+ 2. **Use meaningful names**: Strategy names should clearly indicate their purpose
268
+ 3. **Document configuration**: If your strategy accepts configuration, document all available options
269
+ 4. **Test your strategies**: Write tests for your custom strategies to ensure they work correctly
270
+ 5. **Consider composition**: Design strategies to work well together when used in combination
271
+
272
+
@@ -0,0 +1,29 @@
1
+ # Transaction Strategy
2
+
3
+ The `transaction` strategy wraps your action execution in a database transaction:
4
+
5
+ ```ruby
6
+ class TransferFunds
7
+ include Action
8
+
9
+ use :transaction
10
+
11
+ expects :from_account, :to_account, :amount
12
+
13
+ def call
14
+ from_account.withdraw!(amount)
15
+ to_account.deposit!(amount)
16
+ expose :transfer_id, SecureRandom.uuid
17
+ end
18
+ end
19
+ ```
20
+
21
+ **Important**: The transaction wraps the entire action execution, including:
22
+ - `before` hooks
23
+ - The main `call` method
24
+ - `after` hooks
25
+ - Success/failure callbacks (`on_success`, `on_failure`, etc.)
26
+
27
+ This means that if any part of the action (including hooks or callbacks) raises an exception or calls `fail!`, the entire transaction will be rolled back.
28
+
29
+ **Requirements**: Requires ActiveRecord to be available in your application.
data/docs/usage/using.md CHANGED
@@ -55,33 +55,3 @@ Sidekiq integration is NOT YET TESTED/NOT YET USED IN OUR APP, and naming will V
55
55
  * enqueue will not retry even if fails
56
56
  * enqueue! will go through normal sidekiq retries on any failure (including user-facing `fail!`)
57
57
  * Note implicit GlobalID support (if not serializable, will get ArgumentError at callsite)
58
-
59
-
60
- ### `.enqueue_all_in_background`
61
-
62
- In practice it's fairly common to need to enqueue a bunch of sidekiq jobs from a clock process.
63
-
64
- One approach is to define a class-level `.enqueue_all` method on your Action... but that ends up executing the enqueue_all logic directly from the clock process, which is undesirable.
65
-
66
-
67
- ::: danger ALPHA
68
- We are actively testing this pattern -- not yet certain we'll keep it past beta.
69
- :::
70
-
71
- Therefore we've added an `.enqueue_all_in_background` method that will automatically call your `.enqueue_all` _from a background job_ rather than directly on the active process.
72
-
73
- ```ruby
74
- class Foo
75
- include Action
76
-
77
- def self.enqueue_all
78
- SomeModel.some_scope.find_each do |record|
79
- enqueue(record:)
80
- end
81
- end
82
-
83
- ...
84
- end
85
-
86
- Foo.enqueue_all # works, but `SomeModel.some_scope.find_each` is executed in the current context
87
- Foo.enqueue_all_in_background # same, but runs in the background (via Action::Enqueueable::EnqueueAllWorker)
@@ -154,5 +154,5 @@ after hook
154
154
 
155
155
  A number of custom callback are available for you as well, if you want to take specific actions when a given Axn succeeds or fails. See the [Class Interface docs](/reference/class#callbacks) for details.
156
156
 
157
- ## Debugging
158
- Remember you can [enable debug logging](/reference/configuration.html#global-debug-logging) to print log lines before and after each action is executed.
157
+ ## Strategies
158
+ A number of [Strategies](/strategies/index), which are <abbr title="Don't Repeat Yourself">DRY</abbr>ed bits of commonly-used configuration, are available for your use as well.
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Action
4
4
  class Configuration
5
- include Action::Logging
6
5
  attr_accessor :top_level_around_hook
7
6
  attr_writer :logger, :env, :on_exception, :additional_includes, :default_log_level, :default_autolog_level
8
7
 
@@ -12,11 +11,21 @@ module Action
12
11
  def additional_includes = @additional_includes ||= []
13
12
 
14
13
  def on_exception(e, action:, context: {})
15
- if @on_exception
16
- # TODO: only pass action: or context: if requested (and update documentation)
17
- @on_exception.call(e, action:, context:)
14
+ msg = "Handled exception (#{e.class.name}): #{e.message}"
15
+ msg = ("#" * 10) + " #{msg} " + ("#" * 10) unless Action.config.env.production?
16
+ action.log(msg)
17
+
18
+ return unless @on_exception
19
+
20
+ # Only pass the kwargs that the given block expects
21
+ kwargs = @on_exception.parameters.select { |type, _name| %i[key keyreq].include?(type) }.map(&:last)
22
+ kwarg_hash = {}
23
+ kwarg_hash[:action] = action if kwargs.include?(:action)
24
+ kwarg_hash[:context] = context if kwargs.include?(:context)
25
+ if kwarg_hash.any?
26
+ @on_exception.call(e, **kwarg_hash)
18
27
  else
19
- log("[#{action.class.name.presence || "Anonymous Action"}] Exception raised: #{e.class.name} - #{e.message}")
28
+ @on_exception.call(e)
20
29
  end
21
30
  end
22
31
 
@@ -75,8 +75,7 @@ module Action
75
75
  action.instance_exec(&msg)
76
76
  end
77
77
  rescue StandardError => e
78
- action.warn("Ignoring #{e.class.name} while determining message callable: #{e.message}")
79
- nil
78
+ Axn::Util.piping_error("determining message callable", action:, exception: e)
80
79
  end
81
80
  end
82
81
 
@@ -27,8 +27,7 @@ module Action
27
27
  action.instance_exec(exception, &@handler)
28
28
  true
29
29
  rescue StandardError => e
30
- action.warn("Ignoring #{e.class.name} when evaluating handler: #{e.message}")
31
- nil
30
+ Axn::Util.piping_error("executing handler", action:, exception: e)
32
31
  end
33
32
  end
34
33
 
@@ -54,8 +53,7 @@ module Action
54
53
  false
55
54
  end
56
55
  rescue StandardError => e
57
- action.warn("Ignoring #{e.class.name} while determining matcher: #{e.message}")
58
- false
56
+ Axn::Util.piping_error("determining if handler applies to exception", action:, exception: e)
59
57
  end
60
58
 
61
59
  private attr_reader :matcher
@@ -3,7 +3,7 @@
3
3
  require_relative "event_handlers"
4
4
 
5
5
  module Action
6
- module SwallowExceptions
6
+ module HandleExceptions
7
7
  def self.included(base)
8
8
  base.class_eval do
9
9
  class_attribute :_success_msg, :_error_msg
@@ -56,7 +56,7 @@ module Action
56
56
  rescue StandardError => e
57
57
  # No action needed -- downstream #on_exception implementation should ideally log any internal failures, but
58
58
  # we don't want exception *handling* failures to cascade and overwrite the original exception.
59
- warn("Ignoring #{e.class.name} in on_exception hook: #{e.message}")
59
+ Axn::Util.piping_error("executing on_exception hooks", action: self, exception: e)
60
60
  end
61
61
 
62
62
  class << base
@@ -16,20 +16,20 @@ module Action
16
16
  module ClassMethods
17
17
  def default_log_level = Action.config.default_log_level
18
18
 
19
- def log(message, level: default_log_level)
19
+ def log(message, level: default_log_level, before: nil, after: nil)
20
20
  msg = [_log_prefix, message].compact_blank.join(" ")
21
+ msg = [before, msg, after].compact_blank.join if before || after
21
22
 
22
23
  Action.config.logger.send(level, msg)
23
24
  end
24
25
 
25
26
  LEVELS.each do |level|
26
- define_method(level) do |message|
27
- log(message, level:)
27
+ define_method(level) do |message, before: nil, after: nil|
28
+ log(message, level:, before:, after:)
28
29
  end
29
30
  end
30
31
 
31
- # TODO: this is ugly, we should be able to override in the config class...
32
- def _log_prefix = name == "Action::Configuration" ? nil : "[#{name || "Anonymous Class"}]"
32
+ def _log_prefix = "[#{name.presence || "Anonymous Class"}]"
33
33
  end
34
34
  end
35
35
  end
@@ -24,8 +24,9 @@ module Action
24
24
  self.class.default_autolog_level,
25
25
  [
26
26
  "About to execute",
27
- context_for_logging(:inbound).presence&.inspect,
27
+ _log_context(:inbound),
28
28
  ].compact.join(" with: "),
29
+ before: Action.config.env.production? ? nil : "\n------\n",
29
30
  )
30
31
  end
31
32
 
@@ -36,21 +37,38 @@ module Action
36
37
  self.class.default_autolog_level,
37
38
  [
38
39
  "Execution completed (with outcome: #{outcome}) in #{elapsed_mils} milliseconds",
39
- _log_after_data_peak,
40
+ _log_context(:outbound),
40
41
  ].compact.join(". Set: "),
42
+ after: Action.config.env.production? ? nil : "\n------\n",
41
43
  )
42
44
  end
43
45
 
44
- def _log_after_data_peak
45
- return unless (data = context_for_logging(:outbound)).present?
46
+ def _log_context(direction)
47
+ data = context_for_logging(direction)
48
+ return unless data.present?
46
49
 
47
- max_length = 100
48
- suffix = "...<truncated>...}"
50
+ max_length = 150
51
+ suffix = "…<truncated>…"
49
52
 
50
- data.inspect.tap do |str|
53
+ _log_object(data).tap do |str|
51
54
  return str[0, max_length - suffix.length] + suffix if str.length > max_length
52
55
  end
53
56
  end
57
+
58
+ def _log_object(data)
59
+ case data
60
+ when Hash
61
+ # NOTE: slightly more manual in order to avoid quotes around ActiveRecord objects' <Class#id> formatting
62
+ "{#{data.map { |k, v| "#{k}: #{_log_object(v)}" }.join(", ")}}"
63
+ when Array
64
+ data.map { |v| _log_object(v) }
65
+ else
66
+ return data.to_unsafe_h if defined?(ActionController::Parameters) && data.is_a?(ActionController::Parameters)
67
+ return "<#{data.class.name}##{data.to_param.presence || "unpersisted"}>" if defined?(ActiveRecord::Base) && data.is_a?(ActiveRecord::Base)
68
+
69
+ data.inspect
70
+ end
71
+ end
54
72
  end
55
73
 
56
74
  module InstanceMethods
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module UseStrategy
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def use(strategy_name, **config, &block)
9
+ strategy = Action::Strategies.all[strategy_name.to_sym]
10
+ raise StrategyNotFound, "Strategy #{strategy_name} not found" if strategy.blank?
11
+ raise ArgumentError, "Strategy #{strategy_name} does not support config" if config.any? && !strategy.respond_to?(:setup)
12
+
13
+ # Allow dynamic setup of strategy (i.e. dynamically define module before returning)
14
+ if strategy.respond_to?(:setup)
15
+ configured = strategy.setup(**config, &block)
16
+ raise ArgumentError, "Strategy #{strategy_name} setup method must return a module" unless configured.is_a?(Module)
17
+
18
+ strategy = configured
19
+ else
20
+ raise ArgumentError, "Strategy #{strategy_name} does not support config (define #setup method)" if config.any?
21
+ raise ArgumentError, "Strategy #{strategy_name} does not support blocks (define #setup method)" if block_given?
22
+ end
23
+
24
+ include strategy
25
+ end
26
+ end
27
+ end
28
+ end
@@ -26,8 +26,7 @@ module Action
26
26
  msg = id.blank? ? "not found (given a blank ID)" : "not found for class #{klass.name} and ID #{id}"
27
27
  record.errors.add(attribute, msg)
28
28
  rescue StandardError => e
29
- warn("Model validation on field '#{attribute}' raised #{e.class.name}: #{e.message}")
30
-
29
+ Axn::Util.piping_error("applying model validation on field '#{attribute}'", exception: e)
31
30
  record.errors.add(attribute, "error raised while trying to find a valid #{klass.name}")
32
31
  end
33
32
  end
@@ -9,7 +9,7 @@ module Action
9
9
  msg = begin
10
10
  options[:with].call(value)
11
11
  rescue StandardError => e
12
- Action.config.logger.warn("Custom validation on field '#{attribute}' raised #{e.class.name}: #{e.message}")
12
+ Axn::Util.piping_error("applying custom validation on field '#{attribute}'", exception: e)
13
13
 
14
14
  "failed validation: #{e.message}"
15
15
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "enqueueable/via_sidekiq"
4
- require_relative "enqueueable/enqueue_all_in_background"
5
4
 
6
5
  module Action
7
6
  module Enqueueable
@@ -9,7 +8,6 @@ module Action
9
8
 
10
9
  included do
11
10
  include ViaSidekiq
12
- include EnqueueAllInBackground
13
11
  end
14
12
  end
15
13
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ class Strategies
5
+ module Transaction
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ raise NotImplementedError, "Transaction strategy requires ActiveRecord" unless defined?(ActiveRecord)
10
+
11
+ around do |hooked|
12
+ ActiveRecord::Base.transaction do
13
+ hooked.call
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ class StrategyNotFound < StandardError; end
5
+ class DuplicateStrategyError < StandardError; end
6
+
7
+ class Strategies
8
+ # rubocop:disable Style/ClassVars
9
+ class << self
10
+ def built_in
11
+ return @@built_in if defined?(@@built_in)
12
+
13
+ strategy_files = Dir[File.join(__dir__, "strategies", "*.rb")]
14
+ strategy_files.each { |file| require file }
15
+
16
+ constants = Action::Strategies.constants.map { |const| Action::Strategies.const_get(const) }
17
+ mods = constants.select { |const| const.is_a?(Module) }
18
+
19
+ @@built_in = mods.to_h { |mod| [mod.name.split("::").last.downcase.to_sym, mod] }
20
+ end
21
+
22
+ def register(name, strategy)
23
+ all # ensure built_in is initialized
24
+ key = name.to_sym
25
+ raise DuplicateStrategyError, "Strategy #{name} already registered" if @@strategies.key?(key)
26
+
27
+ @@strategies[key] = strategy
28
+ @@strategies
29
+ end
30
+
31
+ def all
32
+ @@strategies ||= built_in.dup
33
+ end
34
+
35
+ def clear!
36
+ @@strategies = built_in.dup
37
+ end
38
+
39
+ def find(name)
40
+ raise StrategyNotFound, "Strategy name cannot be nil" if name.nil?
41
+ raise StrategyNotFound, "Strategy name cannot be empty" if name.to_s.strip.empty?
42
+
43
+ all[name.to_sym] or raise StrategyNotFound, "Strategy '#{name}' not found"
44
+ end
45
+ end
46
+ # rubocop:enable Style/ClassVars
47
+ end
48
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec"
4
+
5
+ module Axn
6
+ module Testing
7
+ module SpecHelpers
8
+ def build_action(&block)
9
+ action = Class.new.send(:include, Action)
10
+ action.class_eval(&block) if block
11
+ action
12
+ end
13
+
14
+ def build_axn(**, &)
15
+ Axn::Factory.build(**, &)
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ RSpec.configure do |config|
22
+ config.include Axn::Testing::SpecHelpers
23
+ end
data/lib/axn/util.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Util
5
+ def self.piping_error(desc, exception:, action: nil)
6
+ # Extract just filename/line number from backtrace
7
+ src = exception.backtrace.first.split.first.split("/").last.split(":")[0, 2].join(":")
8
+
9
+ message = if Action.config.env.production?
10
+ "Ignoring exception raised while #{desc}: #{exception.class.name} - #{exception.message} (from #{src})"
11
+ else
12
+ msg = "!! IGNORING EXCEPTION RAISED WHILE #{desc.upcase} !!\n\n" \
13
+ "\t* Exception: #{exception.class.name}\n" \
14
+ "\t* Message: #{exception.message}\n" \
15
+ "\t* From: #{src}"
16
+ "#{"⌵" * 30}\n\n#{msg}\n\n#{"^" * 30}"
17
+ end
18
+
19
+ (action || Action.config.logger).send(:warn, message)
20
+
21
+ nil
22
+ end
23
+ end
24
+ end
data/lib/axn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Axn
4
- VERSION = "0.1.0-alpha.2.5.1.2"
4
+ VERSION = "0.1.0-alpha.2.5.3"
5
5
  end
data/lib/axn.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Axn; end
4
4
  require_relative "axn/version"
5
+ require_relative "axn/util"
5
6
 
6
7
  require "interactor"
7
8
  require "active_support"
@@ -16,13 +17,15 @@ require_relative "action/core/configuration"
16
17
  require_relative "action/core/top_level_around_hook"
17
18
  require_relative "action/core/contract"
18
19
  require_relative "action/core/contract_for_subfields"
19
- require_relative "action/core/swallow_exceptions"
20
+ require_relative "action/core/handle_exceptions"
20
21
  require_relative "action/core/hoist_errors"
22
+ require_relative "action/core/use_strategy"
21
23
 
22
24
  require_relative "axn/factory"
23
25
 
24
26
  require_relative "action/attachable"
25
27
  require_relative "action/enqueueable"
28
+ require_relative "action/strategies"
26
29
 
27
30
  def Axn(callable, **) # rubocop:disable Naming/MethodName
28
31
  return callable if callable.is_a?(Class) && callable < Action
@@ -42,12 +45,14 @@ module Action
42
45
  # can include those hook executions in any traces set from this hook.
43
46
  include TopLevelAroundHook
44
47
 
45
- include SwallowExceptions
48
+ include HandleExceptions
46
49
  include Contract
47
50
  include ContractForSubfields
48
51
 
49
52
  include HoistErrors
50
53
 
54
+ include UseStrategy
55
+
51
56
  # --- Extensions ---
52
57
  include Attachable
53
58
  include Enqueueable
@@ -67,5 +72,3 @@ module Action
67
72
  end
68
73
  end
69
74
  end
70
-
71
- require "action/enqueueable/enqueue_all_worker"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: axn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.alpha.2.5.1.2
4
+ version: 0.1.0.pre.alpha.2.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kali Donovan
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-06-26 00:00:00.000000000 Z
11
+ date: 2025-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -81,6 +81,8 @@ files:
81
81
  - docs/reference/class.md
82
82
  - docs/reference/configuration.md
83
83
  - docs/reference/instance.md
84
+ - docs/strategies/index.md
85
+ - docs/strategies/transaction.md
84
86
  - docs/usage/setup.md
85
87
  - docs/usage/using.md
86
88
  - docs/usage/writing.md
@@ -94,21 +96,24 @@ files:
94
96
  - lib/action/core/contract_for_subfields.rb
95
97
  - lib/action/core/event_handlers.rb
96
98
  - lib/action/core/exceptions.rb
99
+ - lib/action/core/handle_exceptions.rb
97
100
  - lib/action/core/hoist_errors.rb
98
101
  - lib/action/core/logging.rb
99
- - lib/action/core/swallow_exceptions.rb
100
102
  - lib/action/core/top_level_around_hook.rb
103
+ - lib/action/core/use_strategy.rb
101
104
  - lib/action/core/validation/fields.rb
102
105
  - lib/action/core/validation/subfields.rb
103
106
  - lib/action/core/validation/validators/model_validator.rb
104
107
  - lib/action/core/validation/validators/type_validator.rb
105
108
  - lib/action/core/validation/validators/validate_validator.rb
106
109
  - lib/action/enqueueable.rb
107
- - lib/action/enqueueable/enqueue_all_in_background.rb
108
- - lib/action/enqueueable/enqueue_all_worker.rb
109
110
  - lib/action/enqueueable/via_sidekiq.rb
111
+ - lib/action/strategies.rb
112
+ - lib/action/strategies/transaction.rb
110
113
  - lib/axn.rb
111
114
  - lib/axn/factory.rb
115
+ - lib/axn/testing/spec_helpers.rb
116
+ - lib/axn/util.rb
112
117
  - lib/axn/version.rb
113
118
  - package.json
114
119
  - yarn.lock
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Action
4
- module Enqueueable
5
- module EnqueueAllInBackground
6
- extend ActiveSupport::Concern
7
-
8
- module ClassMethods
9
- def enqueue_all_in_background
10
- raise NotImplementedError, "#{name} must implement a .enqueue_all method in order to use .enqueue_all_in_background" unless respond_to?(:enqueue_all)
11
-
12
- ::Action::Enqueueable::EnqueueAllWorker.enqueue(klass_name: name)
13
- end
14
- end
15
- end
16
- end
17
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # NOTE: this is a standalone worker for enqueueing all instances of a class.
4
- # Unlike the other files in the folder, it is NOT included in the Action stack.
5
-
6
- # Note it uses Axn-native enqueueing, so will automatically support additional
7
- # backends as they are added (initially, just Sidekiq)
8
-
9
- module Action
10
- module Enqueueable
11
- class EnqueueAllWorker
12
- include Action
13
-
14
- expects :klass_name, type: String
15
-
16
- def call
17
- klass_name.constantize.enqueue_all
18
- end
19
- end
20
- end
21
- end