axn 0.1.0.pre.alpha.2.8.1 → 0.1.0.pre.alpha.4

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 (148) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/commands/pr.md +36 -0
  3. data/.cursor/rules/axn-framework-patterns.mdc +43 -0
  4. data/.cursor/rules/general-coding-standards.mdc +27 -0
  5. data/.cursor/rules/spec/testing-patterns.mdc +40 -0
  6. data/CHANGELOG.md +57 -0
  7. data/Rakefile +114 -4
  8. data/docs/.vitepress/config.mjs +19 -10
  9. data/docs/advanced/conventions.md +3 -3
  10. data/docs/advanced/mountable.md +476 -0
  11. data/docs/advanced/profiling.md +351 -0
  12. data/docs/advanced/rough.md +27 -8
  13. data/docs/index.md +5 -3
  14. data/docs/intro/about.md +1 -1
  15. data/docs/intro/overview.md +6 -6
  16. data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
  17. data/docs/recipes/memoization.md +103 -18
  18. data/docs/recipes/rubocop-integration.md +38 -284
  19. data/docs/recipes/testing.md +14 -14
  20. data/docs/recipes/validating-user-input.md +1 -1
  21. data/docs/reference/async.md +429 -0
  22. data/docs/reference/axn-result.md +107 -0
  23. data/docs/reference/class.md +225 -64
  24. data/docs/reference/configuration.md +366 -34
  25. data/docs/reference/form-object.md +252 -0
  26. data/docs/reference/instance.md +14 -29
  27. data/docs/strategies/client.md +212 -0
  28. data/docs/strategies/form.md +235 -0
  29. data/docs/strategies/index.md +21 -21
  30. data/docs/strategies/transaction.md +1 -1
  31. data/docs/usage/setup.md +16 -2
  32. data/docs/usage/steps.md +7 -7
  33. data/docs/usage/using.md +23 -12
  34. data/docs/usage/writing.md +191 -12
  35. data/lib/axn/async/adapters/active_job.rb +74 -0
  36. data/lib/axn/async/adapters/disabled.rb +41 -0
  37. data/lib/axn/async/adapters/sidekiq.rb +67 -0
  38. data/lib/axn/async/adapters.rb +26 -0
  39. data/lib/axn/async/batch_enqueue/config.rb +38 -0
  40. data/lib/axn/async/batch_enqueue.rb +99 -0
  41. data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
  42. data/lib/axn/async.rb +178 -0
  43. data/lib/axn/configuration.rb +113 -0
  44. data/lib/{action → axn}/context.rb +22 -4
  45. data/lib/axn/core/automatic_logging.rb +89 -0
  46. data/lib/axn/core/context/facade.rb +69 -0
  47. data/lib/{action → axn}/core/context/facade_inspector.rb +32 -5
  48. data/lib/{action → axn}/core/context/internal.rb +5 -5
  49. data/lib/{action → axn}/core/contract.rb +111 -73
  50. data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
  51. data/lib/{action → axn}/core/contract_validation.rb +27 -12
  52. data/lib/axn/core/contract_validation_for_subfields.rb +165 -0
  53. data/lib/axn/core/default_call.rb +63 -0
  54. data/lib/axn/core/field_resolvers/extract.rb +32 -0
  55. data/lib/axn/core/field_resolvers/model.rb +63 -0
  56. data/lib/axn/core/field_resolvers.rb +24 -0
  57. data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
  58. data/lib/{action → axn}/core/flow/exception_execution.rb +9 -13
  59. data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
  60. data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
  61. data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +23 -11
  62. data/lib/axn/core/flow/handlers/invoker.rb +47 -0
  63. data/lib/{action → axn}/core/flow/handlers/matcher.rb +9 -19
  64. data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
  65. data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
  66. data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
  67. data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
  68. data/lib/axn/core/flow/handlers.rb +20 -0
  69. data/lib/{action → axn}/core/flow/messages.rb +8 -8
  70. data/lib/{action → axn}/core/flow.rb +4 -4
  71. data/lib/{action → axn}/core/hooks.rb +17 -5
  72. data/lib/axn/core/logging.rb +48 -0
  73. data/lib/axn/core/memoization.rb +53 -0
  74. data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
  75. data/lib/{action → axn}/core/timing.rb +1 -1
  76. data/lib/axn/core/tracing.rb +90 -0
  77. data/lib/axn/core/use_strategy.rb +29 -0
  78. data/lib/{action → axn}/core/validation/fields.rb +26 -2
  79. data/lib/{action → axn}/core/validation/subfields.rb +14 -12
  80. data/lib/axn/core/validation/validators/model_validator.rb +36 -0
  81. data/lib/axn/core/validation/validators/type_validator.rb +80 -0
  82. data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
  83. data/lib/{action → axn}/core.rb +55 -55
  84. data/lib/{action → axn}/exceptions.rb +12 -2
  85. data/lib/axn/extras/strategies/client.rb +150 -0
  86. data/lib/axn/extras/strategies/vernier.rb +121 -0
  87. data/lib/axn/extras.rb +4 -0
  88. data/lib/axn/factory.rb +122 -34
  89. data/lib/axn/form_object.rb +90 -0
  90. data/lib/axn/internal/logging.rb +30 -0
  91. data/lib/axn/internal/registry.rb +87 -0
  92. data/lib/axn/mountable/descriptor.rb +76 -0
  93. data/lib/axn/mountable/helpers/class_builder.rb +193 -0
  94. data/lib/axn/mountable/helpers/mounter.rb +33 -0
  95. data/lib/axn/mountable/helpers/namespace_manager.rb +38 -0
  96. data/lib/axn/mountable/helpers/validator.rb +112 -0
  97. data/lib/axn/mountable/inherit_profiles.rb +72 -0
  98. data/lib/axn/mountable/mounting_strategies/_base.rb +87 -0
  99. data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
  100. data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
  101. data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
  102. data/lib/axn/mountable/mounting_strategies.rb +32 -0
  103. data/lib/axn/mountable.rb +119 -0
  104. data/lib/axn/rails/engine.rb +51 -0
  105. data/lib/axn/rails/generators/axn_generator.rb +86 -0
  106. data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
  107. data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
  108. data/lib/{action → axn}/result.rb +32 -13
  109. data/lib/axn/strategies/form.rb +98 -0
  110. data/lib/axn/strategies/transaction.rb +26 -0
  111. data/lib/axn/strategies.rb +20 -0
  112. data/lib/axn/testing/spec_helpers.rb +6 -8
  113. data/lib/axn/util/callable.rb +120 -0
  114. data/lib/axn/util/contract_error_handling.rb +32 -0
  115. data/lib/axn/util/execution_context.rb +34 -0
  116. data/lib/axn/util/global_id_serialization.rb +52 -0
  117. data/lib/axn/util/logging.rb +87 -0
  118. data/lib/axn/util/memoization.rb +20 -0
  119. data/lib/axn/version.rb +1 -1
  120. data/lib/axn.rb +26 -16
  121. data/lib/rubocop/cop/axn/README.md +23 -23
  122. data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
  123. metadata +106 -64
  124. data/.rspec +0 -3
  125. data/.rubocop.yml +0 -76
  126. data/.tool-versions +0 -1
  127. data/docs/reference/action-result.md +0 -37
  128. data/lib/action/attachable/base.rb +0 -43
  129. data/lib/action/attachable/steps.rb +0 -63
  130. data/lib/action/attachable/subactions.rb +0 -70
  131. data/lib/action/attachable.rb +0 -17
  132. data/lib/action/configuration.rb +0 -55
  133. data/lib/action/core/automatic_logging.rb +0 -93
  134. data/lib/action/core/context/facade.rb +0 -48
  135. data/lib/action/core/flow/handlers/invoker.rb +0 -73
  136. data/lib/action/core/flow/handlers.rb +0 -20
  137. data/lib/action/core/logging.rb +0 -37
  138. data/lib/action/core/tracing.rb +0 -17
  139. data/lib/action/core/use_strategy.rb +0 -30
  140. data/lib/action/core/validation/validators/model_validator.rb +0 -34
  141. data/lib/action/core/validation/validators/type_validator.rb +0 -30
  142. data/lib/action/enqueueable/via_sidekiq.rb +0 -76
  143. data/lib/action/enqueueable.rb +0 -13
  144. data/lib/action/strategies/transaction.rb +0 -19
  145. data/lib/action/strategies.rb +0 -48
  146. data/lib/axn/util.rb +0 -24
  147. data/package.json +0 -10
  148. data/yarn.lock +0 -1166
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/handlers/base_descriptor"
3
+ require "axn/core/flow/handlers/base_descriptor"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Flow
8
8
  module Handlers
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/handlers/base_descriptor"
3
+ require "axn/core/flow/handlers/base_descriptor"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Flow
8
8
  module Handlers
@@ -12,8 +12,8 @@ module Action
12
12
  attr_reader :prefix
13
13
 
14
14
  def initialize(matcher:, handler:, prefix: nil)
15
- super(matcher:, handler:)
16
15
  @prefix = prefix
16
+ super(matcher:, handler:)
17
17
  end
18
18
 
19
19
  def self.build(handler: nil, if: nil, unless: nil, prefix: nil, from: nil, **)
@@ -31,19 +31,31 @@ module Action
31
31
  _build_rule_for_from_condition(from),
32
32
  ].compact
33
33
 
34
- Action::Core::Flow::Handlers::Matcher.new(rules, invert: !!binding.local_variable_get(:unless))
34
+ Axn::Core::Flow::Handlers::Matcher.new(rules, invert: !!binding.local_variable_get(:unless))
35
35
  end
36
36
 
37
37
  def self._build_rule_for_from_condition(from_class)
38
38
  return nil unless from_class
39
39
 
40
- if from_class.is_a?(String)
41
- lambda { |exception:, **|
42
- exception.is_a?(Action::Failure) && exception.source&.class&.name == from_class
43
- }
44
- else
45
- ->(exception:, **) { exception.is_a?(Action::Failure) && exception.source.is_a?(from_class) }
46
- end
40
+ # Special case: `from: true` means "from any child action"
41
+ return ->(exception:, **) { exception.is_a?(Axn::Failure) && exception.source } if from_class == true
42
+
43
+ from_classes = Array(from_class)
44
+ lambda { |exception:, **|
45
+ return false unless exception.is_a?(Axn::Failure) && exception.source
46
+
47
+ source = exception.source
48
+ from_classes.any? do |cls|
49
+ if cls.is_a?(String)
50
+ # rubocop:disable Style/ClassEqualityComparison
51
+ # We're comparing class name strings, not classes themselves
52
+ source.class.name == cls
53
+ # rubocop:enable Style/ClassEqualityComparison
54
+ else
55
+ source.is_a?(cls)
56
+ end
57
+ end
58
+ }
47
59
  end
48
60
  end
49
61
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ module Flow
6
+ module Handlers
7
+ # Shared block evaluation with consistent arity handling and error piping
8
+ module Invoker
9
+ extend self
10
+
11
+ def call(action:, handler:, exception: nil, operation: "executing handler")
12
+ return call_symbol_handler(action:, symbol: handler, exception:) if symbol?(handler)
13
+ return call_callable_handler(action:, callable: handler, exception:) if callable?(handler)
14
+
15
+ literal_value(handler)
16
+ rescue StandardError => e
17
+ Axn::Internal::Logging.piping_error(operation, action:, exception: e)
18
+ end
19
+
20
+ private
21
+
22
+ def symbol?(value) = value.is_a?(Symbol)
23
+
24
+ def callable?(value) = value.respond_to?(:arity)
25
+
26
+ def call_symbol_handler(action:, symbol:, exception: nil)
27
+ unless action.respond_to?(symbol, true)
28
+ action.warn("Ignoring apparently-invalid symbol #{symbol.inspect} -- action does not respond to method")
29
+ return nil
30
+ end
31
+
32
+ method = action.method(symbol)
33
+ filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(method, exception)
34
+ action.send(symbol, *filtered_args, **filtered_kwargs)
35
+ end
36
+
37
+ def call_callable_handler(action:, callable:, exception: nil)
38
+ filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(callable, exception)
39
+ action.instance_exec(*filtered_args, **filtered_kwargs, &callable)
40
+ end
41
+
42
+ def literal_value(value) = value
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/handlers/invoker"
3
+ require "axn/core/flow/handlers/invoker"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Flow
8
8
  module Handlers
@@ -16,7 +16,7 @@ module Action
16
16
  result = matches?(exception:, action:)
17
17
  @invert ? !result : result
18
18
  rescue StandardError => e
19
- Axn::Util.piping_error("determining if handler applies to exception", action:, exception: e)
19
+ Axn::Internal::Logging.piping_error("determining if handler applies to exception", action:, exception: e)
20
20
  end
21
21
 
22
22
  private
@@ -36,25 +36,15 @@ module Action
36
36
  def exception_class? = @rule.is_a?(Class) && @rule <= Exception
37
37
 
38
38
  def apply_callable(action:, exception:)
39
- if exception && Invoker.accepts_exception_keyword?(@rule)
40
- !!action.instance_exec(exception:, &@rule)
41
- elsif exception && Invoker.accepts_positional_exception?(@rule)
42
- !!action.instance_exec(exception, &@rule)
43
- else
44
- !!action.instance_exec(&@rule)
45
- end
39
+ filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(@rule, exception)
40
+ !!action.instance_exec(*filtered_args, **filtered_kwargs, &@rule)
46
41
  end
47
42
 
48
43
  def apply_symbol(action:, exception:)
49
44
  if action.respond_to?(@rule)
50
45
  method = action.method(@rule)
51
- if exception && Invoker.accepts_exception_keyword?(method)
52
- !!action.public_send(@rule, exception:)
53
- elsif exception && Invoker.accepts_positional_exception?(method)
54
- !!action.public_send(@rule, exception)
55
- else
56
- !!action.public_send(@rule)
57
- end
46
+ filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(method, exception)
47
+ !!action.public_send(@rule, *filtered_args, **filtered_kwargs)
58
48
  else
59
49
  begin
60
50
  klass = Object.const_get(@rule.to_s)
@@ -92,7 +82,7 @@ module Action
92
82
  def call(exception:, action:)
93
83
  matches?(exception:, action:)
94
84
  rescue StandardError => e
95
- Axn::Util.piping_error("determining if handler applies to exception", action:, exception: e)
85
+ Axn::Internal::Logging.piping_error("determining if handler applies to exception", action:, exception: e)
96
86
  end
97
87
 
98
88
  def static? = @rules.empty?
@@ -103,7 +93,7 @@ module Action
103
93
  if_condition = binding.local_variable_get(:if)
104
94
  unless_condition = binding.local_variable_get(:unless)
105
95
 
106
- raise Action::UnsupportedArgument, "providing both :if and :unless" if if_condition && unless_condition
96
+ raise Axn::UnsupportedArgument, "providing both :if and :unless" if if_condition && unless_condition
107
97
 
108
98
  new(Array(if_condition || unless_condition).compact, invert: !!unless_condition)
109
99
  end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module Flow
6
6
  module Handlers
7
7
  # Small, immutable, copy-on-write registry keyed by event_type.
8
8
  # Stores arrays of entries (handlers/interceptors) in insertion order.
9
+ #
10
+ # NOTE: serves different need than user-mutable e.g. Axn::Async::Adapters
9
11
  class Registry
10
12
  def self.empty = new({})
11
13
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module Flow
6
6
  module Handlers
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/handlers/invoker"
3
+ require "axn/core/flow/handlers/invoker"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Flow
8
8
  module Handlers
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/handlers/invoker"
3
+ require "axn/core/flow/handlers/invoker"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Flow
8
8
  module Handlers
@@ -33,7 +33,7 @@ module Action
33
33
  message = resolved_message_body(descriptor)
34
34
  return nil unless message.present?
35
35
 
36
- descriptor.prefix ? "#{descriptor.prefix}#{message}" : message
36
+ "#{resolved_prefix(descriptor)}#{message}"
37
37
  end
38
38
 
39
39
  def resolved_message_body(descriptor)
@@ -49,6 +49,15 @@ module Action
49
49
  end
50
50
  end
51
51
 
52
+ def resolved_prefix(descriptor)
53
+ return nil unless descriptor.prefix
54
+ return descriptor.prefix if descriptor.prefix.is_a?(String)
55
+
56
+ Invoker.call(action:, handler: descriptor.prefix, exception:, operation: "determining prefix callable")
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
52
61
  def invoke_handler(handler) = handler ? Invoker.call(operation: "determining message callable", action:, handler:, exception:).presence : nil
53
62
  def fallback_message = event_type == :success ? DEFAULT_SUCCESS : DEFAULT_ERROR
54
63
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ module Flow
6
+ module Handlers
7
+ end
8
+ end
9
+ end
10
+ end
11
+
12
+ require "axn/core/flow/handlers/base_descriptor"
13
+ require "axn/core/flow/handlers/matcher"
14
+ require "axn/core/flow/handlers/resolvers/base_resolver"
15
+ require "axn/core/flow/handlers/descriptors/message_descriptor"
16
+ require "axn/core/flow/handlers/descriptors/callback_descriptor"
17
+ require "axn/core/flow/handlers/invoker"
18
+ require "axn/core/flow/handlers/resolvers/callback_resolver"
19
+ require "axn/core/flow/handlers/registry"
20
+ require "axn/core/flow/handlers/resolvers/message_resolver"
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/handlers"
3
+ require "axn/core/flow/handlers"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Flow
8
8
  module Messages
9
9
  def self.included(base)
10
10
  base.class_eval do
11
- class_attribute :_messages_registry, default: Action::Core::Flow::Handlers::Registry.empty
11
+ class_attribute :_messages_registry, default: Axn::Core::Flow::Handlers::Registry.empty
12
12
 
13
13
  extend ClassMethods
14
14
  end
@@ -21,19 +21,19 @@ module Action
21
21
  private
22
22
 
23
23
  def _add_message(kind, message:, **kwargs, &block)
24
- raise Action::UnsupportedArgument, "calling #{kind} with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
25
- raise Action::UnsupportedArgument, "Combining from: with if: or unless:" if kwargs.key?(:from) && (kwargs.key?(:if) || kwargs.key?(:unless))
24
+ raise Axn::UnsupportedArgument, "calling #{kind} with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
25
+ raise Axn::UnsupportedArgument, "Combining from: with if: or unless:" if kwargs.key?(:from) && (kwargs.key?(:if) || kwargs.key?(:unless))
26
26
  raise ArgumentError, "Provide either a message or a block, not both" if message && block_given?
27
- raise ArgumentError, "Provide a message, block, or prefix" unless message || block_given? || kwargs[:prefix]
27
+ raise ArgumentError, "Provide a message, block, or prefix" unless message || block_given? || kwargs[:prefix] || kwargs[:from]
28
28
  raise ArgumentError, "from: only applies to error messages" if kwargs.key?(:from) && kind != :error
29
29
 
30
30
  # If message is already a descriptor, use it directly
31
- entry = if message.is_a?(Action::Core::Flow::Handlers::Descriptors::MessageDescriptor)
31
+ entry = if message.is_a?(Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)
32
32
  raise ArgumentError, "Cannot pass additional configuration with prebuilt descriptor" if kwargs.any? || block_given?
33
33
 
34
34
  message
35
35
  else
36
- Action::Core::Flow::Handlers::Descriptors::MessageDescriptor.build(
36
+ Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor.build(
37
37
  handler: block_given? ? block : message,
38
38
  **kwargs,
39
39
  )
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/messages"
4
- require "action/core/flow/callbacks"
5
- require "action/core/flow/exception_execution"
3
+ require "axn/core/flow/messages"
4
+ require "axn/core/flow/callbacks"
5
+ require "axn/core/flow/exception_execution"
6
6
 
7
- module Action
7
+ module Axn
8
8
  module Core
9
9
  module Flow
10
10
  def self.included(base)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module Hooks
6
6
  def self.included(base)
@@ -68,10 +68,15 @@ module Action
68
68
  private
69
69
 
70
70
  def _with_hooks
71
- _run_around_hooks do
72
- _run_before_hooks
73
- yield
74
- _run_after_hooks
71
+ # Outer is needed in the unlikely case done! is called in around hooks
72
+ __respecting_early_completion do
73
+ _run_around_hooks do
74
+ __respecting_early_completion do
75
+ _run_before_hooks
76
+ yield
77
+ _run_after_hooks
78
+ end
79
+ end
75
80
  end
76
81
  end
77
82
 
@@ -118,6 +123,13 @@ module Action
118
123
  def _run_hook(hook, *)
119
124
  hook.is_a?(Symbol) ? send(hook, *) : instance_exec(*, &hook)
120
125
  end
126
+
127
+ def __respecting_early_completion
128
+ yield
129
+ rescue Axn::Internal::EarlyCompletion => e
130
+ @__context.__record_early_completion(e.message)
131
+ raise e
132
+ end
121
133
  end
122
134
  end
123
135
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+
5
+ module Axn
6
+ module Core
7
+ module Logging
8
+ LEVELS = %i[debug info warn error fatal].freeze
9
+
10
+ def self.included(base)
11
+ base.class_eval do
12
+ extend ClassMethods
13
+ delegate :log, *LEVELS, to: :class
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+ def log_level = Axn.config.log_level
19
+
20
+ # @param message [String] The message to log
21
+ # @param level [Symbol] The log level (default: log_level)
22
+ # @param before [String, nil] Text to prepend to the message
23
+ # @param after [String, nil] Text to append to the message
24
+ # @param prefix [String, nil] Override the default prefix (useful for class-level logging)
25
+ def log(message, level: log_level, before: nil, after: nil, prefix: nil)
26
+ resolved_prefix = prefix.nil? ? _log_prefix : prefix
27
+ msg = [resolved_prefix, message].compact_blank.join(" ")
28
+ msg = [before, msg, after].compact_blank.join if before || after
29
+
30
+ Axn.config.logger.send(level, msg)
31
+ end
32
+
33
+ LEVELS.each do |level|
34
+ define_method(level) do |message, before: nil, after: nil, prefix: nil|
35
+ log(message, level:, before:, after:, prefix:)
36
+ end
37
+ end
38
+
39
+ def _log_prefix
40
+ names = NestingTracking._current_axn_stack.map do |axn|
41
+ axn.class.name.presence || "Anonymous Class"
42
+ end
43
+ "[#{names.join(' > ')}]"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ module Memoization
6
+ def self.included(base)
7
+ base.class_eval do
8
+ extend ClassMethods
9
+ end
10
+ end
11
+
12
+ module ClassMethods
13
+ def memo(method_name)
14
+ if _memo_wise_available?
15
+ _ensure_memo_wise_prepended
16
+ memo_wise(method_name)
17
+ else
18
+ _memo_minimal(method_name)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def _memo_wise_available?
25
+ defined?(MemoWise)
26
+ end
27
+
28
+ def _ensure_memo_wise_prepended
29
+ return if ancestors.include?(MemoWise)
30
+
31
+ prepend MemoWise
32
+ end
33
+
34
+ def _memo_minimal(method_name)
35
+ method = instance_method(method_name)
36
+ params = method.parameters
37
+ has_args = params.any? { |type, _name| %i[req opt rest keyreq key keyrest].include?(type) }
38
+
39
+ if has_args
40
+ raise ArgumentError,
41
+ "Memoization of methods with arguments requires the 'memo_wise' gem. " \
42
+ "Please add 'memo_wise' to your Gemfile or use a method without arguments."
43
+ end
44
+
45
+ # Wrap the method with memoization
46
+ Axn::Util::Memoization.define_memoized_reader_method(self, method_name) do
47
+ method.bind(self).call
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module NestingTracking
6
6
  def self.included(base)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module Timing
6
6
  def self.included(base)
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Axn
6
+ module Core
7
+ module Tracing
8
+ class << self
9
+ # Cache the tracer instance to avoid repeated lookups
10
+ # The tracer provider may cache internally, but we avoid the method call overhead
11
+ # We check defined?(OpenTelemetry) each time to handle cases where it's loaded lazily
12
+ def tracer
13
+ return nil unless defined?(OpenTelemetry)
14
+
15
+ # Re-fetch if the tracer provider has changed (e.g., in tests with mocks)
16
+ current_provider = OpenTelemetry.tracer_provider
17
+ return @tracer if defined?(@tracer) && defined?(@tracer_provider) && @tracer_provider == current_provider
18
+
19
+ @tracer_provider = current_provider
20
+ @tracer = current_provider.tracer("axn", Axn::VERSION)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def _with_tracing(&)
27
+ resource = self.class.name || "AnonymousClass"
28
+ payload = { resource:, action: self }
29
+
30
+ update_payload = proc do
31
+ result = self.result
32
+ outcome = result.outcome.to_s
33
+ payload[:outcome] = outcome
34
+ payload[:result] = result
35
+ payload[:elapsed_time] = result.elapsed_time
36
+ payload[:exception] = result.exception if result.exception
37
+ rescue StandardError => e
38
+ # Don't raise in ensure block to avoid interfering with existing exceptions
39
+ Axn::Internal::Logging.piping_error("updating notification payload while tracing axn.call", action: self, exception: e)
40
+ end
41
+
42
+ instrument_block = proc do
43
+ ActiveSupport::Notifications.instrument("axn.call", payload, &)
44
+ ensure
45
+ # Update payload BEFORE instrument completes so subscribers see the changes
46
+ update_payload.call
47
+ end
48
+
49
+ # NOTE: despite using block form, ActiveSupport explicitly only emits to subscribers when it's finished,
50
+ # which means it's not suitable for wrapping execution with a span and tracking child spans.
51
+ # We use OpenTelemetry for that, if available.
52
+ if defined?(OpenTelemetry)
53
+ Tracing.tracer.in_span("axn.call", attributes: { "axn.resource" => resource }) do |span|
54
+ instrument_block.call
55
+ ensure
56
+ # Update span with outcome and error status after execution
57
+ # This ensure runs before the span finishes, so we can still update it
58
+ begin
59
+ result = self.result
60
+ outcome = result.outcome.to_s
61
+ span.set_attribute("axn.outcome", outcome)
62
+
63
+ if %w[failure exception].include?(outcome) && result.exception
64
+ span.record_exception(result.exception)
65
+ error_message = result.exception.message || result.exception.class.name
66
+ span.status = OpenTelemetry::Trace::Status.error(error_message)
67
+ end
68
+ rescue StandardError => e
69
+ # Don't raise in ensure block to avoid interfering with existing exceptions
70
+ Axn::Internal::Logging.piping_error("updating OTel span while tracing axn.call", action: self, exception: e)
71
+ end
72
+ end
73
+ else
74
+ instrument_block.call
75
+ end
76
+ ensure
77
+ begin
78
+ emit_metrics_proc = Axn.config.emit_metrics
79
+ if emit_metrics_proc
80
+ result = self.result
81
+ Axn::Util::Callable.call_with_desired_shape(emit_metrics_proc, kwargs: { resource:, result: })
82
+ end
83
+ rescue StandardError => e
84
+ # Don't raise in ensure block to avoid interfering with existing exceptions
85
+ Axn::Internal::Logging.piping_error("calling emit_metrics while tracing axn.call", action: self, exception: e)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ module UseStrategy
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def use(strategy_name, **config, &block)
10
+ strategy = Axn::Strategies.find(strategy_name)
11
+ raise ArgumentError, "Strategy #{strategy_name} does not support config" if config.any? && !strategy.respond_to?(:configure)
12
+
13
+ # Allow dynamic configuration of strategy (i.e. dynamically define module before returning)
14
+ if strategy.respond_to?(:configure)
15
+ configured = strategy.configure(**config, &block)
16
+ raise ArgumentError, "Strategy #{strategy_name} configure 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 #configure method)" if config.any?
21
+ raise ArgumentError, "Strategy #{strategy_name} does not support blocks (define #configure method)" if block_given?
22
+ end
23
+
24
+ include strategy
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Validation
5
5
  class Fields
6
6
  include ActiveModel::Validations
@@ -20,9 +20,25 @@ module Action
20
20
  @context.public_send(attr)
21
21
  end
22
22
 
23
+ def method_missing(method_name, ...)
24
+ # Delegate method calls to the action instance to support symbol-based validations
25
+ # like inclusion: { in: :valid_channels_for_number }
26
+ action = _action_for_validation
27
+ return super unless action && action.respond_to?(method_name, true) # rubocop:disable Style/SafeNavigation
28
+
29
+ action.send(method_name, ...)
30
+ end
31
+
32
+ def respond_to_missing?(method_name, include_private = false)
33
+ action = _action_for_validation
34
+ return super unless action
35
+
36
+ action.respond_to?(method_name, include_private) || super
37
+ end
38
+
23
39
  def self.validate!(validations:, context:, exception_klass:)
24
40
  validator = Class.new(self) do
25
- def self.name = "Action::Validation::Fields::OneOff"
41
+ def self.name = "Axn::Validation::Fields::OneOff"
26
42
 
27
43
  validations.each do |field, field_validations|
28
44
  field_validations.each do |key, value|
@@ -35,6 +51,14 @@ module Action
35
51
 
36
52
  raise exception_klass, validator.errors
37
53
  end
54
+
55
+ private
56
+
57
+ def _action_for_validation
58
+ return unless @context.respond_to?(:action, true)
59
+
60
+ @context.send(:action)
61
+ end
38
62
  end
39
63
  end
40
64
  end