axn 0.1.0.pre.alpha.3 → 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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/commands/pr.md +36 -0
  3. data/CHANGELOG.md +15 -1
  4. data/Rakefile +102 -2
  5. data/docs/.vitepress/config.mjs +12 -8
  6. data/docs/advanced/conventions.md +1 -1
  7. data/docs/advanced/mountable.md +4 -90
  8. data/docs/advanced/profiling.md +26 -30
  9. data/docs/advanced/rough.md +27 -8
  10. data/docs/intro/overview.md +1 -1
  11. data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
  12. data/docs/recipes/memoization.md +102 -17
  13. data/docs/reference/async.md +269 -0
  14. data/docs/reference/class.md +113 -50
  15. data/docs/reference/configuration.md +226 -75
  16. data/docs/reference/form-object.md +252 -0
  17. data/docs/strategies/client.md +212 -0
  18. data/docs/strategies/form.md +235 -0
  19. data/docs/usage/setup.md +2 -2
  20. data/docs/usage/writing.md +99 -1
  21. data/lib/axn/async/adapters/active_job.rb +19 -10
  22. data/lib/axn/async/adapters/disabled.rb +15 -0
  23. data/lib/axn/async/adapters/sidekiq.rb +25 -32
  24. data/lib/axn/async/batch_enqueue/config.rb +38 -0
  25. data/lib/axn/async/batch_enqueue.rb +99 -0
  26. data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
  27. data/lib/axn/async.rb +121 -4
  28. data/lib/axn/configuration.rb +53 -13
  29. data/lib/axn/context.rb +1 -0
  30. data/lib/axn/core/automatic_logging.rb +47 -51
  31. data/lib/axn/core/context/facade_inspector.rb +1 -1
  32. data/lib/axn/core/contract.rb +73 -30
  33. data/lib/axn/core/contract_for_subfields.rb +1 -1
  34. data/lib/axn/core/contract_validation.rb +14 -9
  35. data/lib/axn/core/contract_validation_for_subfields.rb +14 -7
  36. data/lib/axn/core/default_call.rb +63 -0
  37. data/lib/axn/core/flow/exception_execution.rb +5 -0
  38. data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +19 -7
  39. data/lib/axn/core/flow/handlers/invoker.rb +4 -30
  40. data/lib/axn/core/flow/handlers/matcher.rb +4 -14
  41. data/lib/axn/core/flow/messages.rb +1 -1
  42. data/lib/axn/core/hooks.rb +1 -0
  43. data/lib/axn/core/logging.rb +16 -5
  44. data/lib/axn/core/memoization.rb +53 -0
  45. data/lib/axn/core/tracing.rb +77 -4
  46. data/lib/axn/core/validation/validators/type_validator.rb +1 -1
  47. data/lib/axn/core.rb +31 -46
  48. data/lib/axn/extras/strategies/client.rb +150 -0
  49. data/lib/axn/extras/strategies/vernier.rb +121 -0
  50. data/lib/axn/extras.rb +4 -0
  51. data/lib/axn/factory.rb +22 -2
  52. data/lib/axn/form_object.rb +90 -0
  53. data/lib/axn/internal/logging.rb +5 -1
  54. data/lib/axn/mountable/helpers/class_builder.rb +41 -10
  55. data/lib/axn/mountable/helpers/namespace_manager.rb +6 -34
  56. data/lib/axn/mountable/inherit_profiles.rb +2 -2
  57. data/lib/axn/mountable/mounting_strategies/_base.rb +10 -6
  58. data/lib/axn/mountable/mounting_strategies/method.rb +2 -2
  59. data/lib/axn/mountable.rb +41 -7
  60. data/lib/axn/rails/generators/axn_generator.rb +19 -1
  61. data/lib/axn/rails/generators/templates/action.rb.erb +1 -1
  62. data/lib/axn/result.rb +2 -2
  63. data/lib/axn/strategies/form.rb +98 -0
  64. data/lib/axn/strategies/transaction.rb +7 -0
  65. data/lib/axn/util/callable.rb +120 -0
  66. data/lib/axn/util/contract_error_handling.rb +32 -0
  67. data/lib/axn/util/execution_context.rb +34 -0
  68. data/lib/axn/util/global_id_serialization.rb +52 -0
  69. data/lib/axn/util/logging.rb +87 -0
  70. data/lib/axn/version.rb +1 -1
  71. data/lib/axn.rb +9 -0
  72. metadata +22 -4
  73. data/lib/axn/core/profiling.rb +0 -124
  74. data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +0 -55
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This is a base class for all form objects that are used with Axn actions.
4
+ #
5
+ # It provides a number of conveniences for working with form objects, including:
6
+ # - Automatically attr_accessor any attribute for which we add a validation
7
+ # - Add support for nested forms
8
+ # - Add support for money objects (analogous to 'include MoneyRails::ActiveRecord::Monetizable' for ActiveRecord models)
9
+ module Axn
10
+ class FormObject
11
+ include ActiveModel::Model
12
+
13
+ class << self
14
+ attr_accessor :field_names
15
+
16
+ def inherited(subclass)
17
+ # Inherit field_names from parent class, or initialize as empty array if parent doesn't have any
18
+ subclass.field_names = (field_names || []).dup
19
+
20
+ super
21
+ end
22
+
23
+ # Override attr_accessor to track field names for automatic #to_h support
24
+ def attr_accessor(*attributes)
25
+ # Initialize field_names if not already set
26
+ self.field_names ||= []
27
+
28
+ # Add new attributes to the field_names array
29
+ self.field_names += attributes.map(&:to_sym)
30
+
31
+ super
32
+ end
33
+
34
+ # Automatically attr_accessor any attribute for which we add a validation
35
+ def validates(*attributes)
36
+ our_attributes = attributes.dup
37
+
38
+ # Pulled from upstream: https://github.com/rails/rails/blob/6f0d1ad14b92b9f5906e44740fce8b4f1c7075dc/activemodel/lib/active_model/validations/validates.rb#L106
39
+ our_attributes.extract_options!
40
+ our_attributes.each { |attr| attr_accessor(attr) }
41
+
42
+ super
43
+ end
44
+
45
+ # Add support for nested forms
46
+ def nested_forms(**kwargs)
47
+ kwargs.each do |name, klass|
48
+ validates name, presence: true
49
+
50
+ define_method("#{name}=") do |params|
51
+ return instance_variable_set("@#{name}", nil) if params.nil?
52
+
53
+ child_params = params.dup
54
+
55
+ # Automatically inject the parent into the child form if it has a parent= method
56
+ child_params[:parent_form] = self if klass.instance_methods.include?(:parent_form=)
57
+
58
+ instance_variable_set("@#{name}", klass.new(child_params))
59
+ end
60
+
61
+ validation_method_name = :"validate_#{name}_form"
62
+ validate validation_method_name
63
+ define_method(validation_method_name) do
64
+ return if public_send(name).nil? || public_send(name).valid?
65
+
66
+ public_send(name).errors.each do |error|
67
+ errors.add("#{name}.#{error.attribute}", error.message)
68
+ end
69
+ end
70
+ private validation_method_name
71
+ end
72
+ end
73
+ alias nested_form nested_forms
74
+ end
75
+
76
+ def to_h
77
+ return {} if self.class.field_names.nil?
78
+
79
+ self.class.field_names.each_with_object({}) do |field_name, hash|
80
+ next unless respond_to?(field_name)
81
+
82
+ # Skip parent_form to avoid infinite recursion with circular references
83
+ next if field_name == :parent_form
84
+
85
+ value = public_send(field_name)
86
+ hash[field_name] = value.is_a?(Axn::FormObject) ? value.to_h : value
87
+ end
88
+ end
89
+ end
90
+ end
@@ -4,6 +4,10 @@ module Axn
4
4
  module Internal
5
5
  module Logging
6
6
  def self.piping_error(desc, exception:, action: nil)
7
+ # If raise_piping_errors_in_dev is enabled and we're in development, raise instead of log.
8
+ # Test and production environments always swallow the error to match production behavior.
9
+ raise exception if Axn.config.raise_piping_errors_in_dev && Axn.config.env.development?
10
+
7
11
  # Extract just filename/line number from backtrace
8
12
  src = exception.backtrace.first.split.first.split("/").last.split(":")[0, 2].join(":")
9
13
 
@@ -14,7 +18,7 @@ module Axn
14
18
  "\t* Exception: #{exception.class.name}\n" \
15
19
  "\t* Message: #{exception.message}\n" \
16
20
  "\t* From: #{src}"
17
- "#{"" * 30}\n\n#{msg}\n\n#{"^" * 30}"
21
+ "#{'' * 30}\n\n#{msg}\n\n#{'^' * 30}"
18
22
  end
19
23
 
20
24
  (action || Axn.config.logger).send(:warn, message)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "axn/mountable/inherit_profiles"
4
+ require "axn/exceptions"
4
5
 
5
6
  module Axn
6
7
  module Mountable
@@ -23,15 +24,25 @@ module Axn
23
24
  end
24
25
 
25
26
  def build_and_configure_action_class(target, name, namespace)
26
- mounted_axn = build_action_class(target)
27
- configure_class_name_and_constant(mounted_axn, name, namespace)
28
- configure_axn_mounted_to(mounted_axn, target)
29
- mounted_axn
27
+ # Mark target as having an action class being created to prevent recursion
28
+ # This is necessary because create_superclass_for_inherit_mode may create
29
+ # classes that inherit from target, triggering the inherited callback
30
+ target.instance_variable_set(:@_axn_creating_action_class_for, target)
31
+ begin
32
+ # Pass target through to Factory.build so it can mark the superclass
33
+ # The superclass flag is checked by the inherited callback in mountable.rb
34
+ mounted_axn = build_action_class(target, _creating_action_class_for: target)
35
+ configure_class_name_and_constant(mounted_axn, name, namespace, target)
36
+ configure_axn_mounted_to(mounted_axn, target)
37
+ mounted_axn
38
+ ensure
39
+ target.instance_variable_set(:@_axn_creating_action_class_for, nil)
40
+ end
30
41
  end
31
42
 
32
43
  private
33
44
 
34
- def build_action_class(target)
45
+ def build_action_class(target, _creating_action_class_for: nil) # rubocop:disable Lint/UnderscorePrefixedVariableName
35
46
  existing_axn_klass = @descriptor.instance_variable_get(:@existing_axn_klass)
36
47
  return existing_axn_klass if existing_axn_klass
37
48
 
@@ -49,12 +60,15 @@ module Axn
49
60
  factory_kwargs[:superclass] = create_superclass_for_inherit_mode(target, inherit_config)
50
61
  end
51
62
 
63
+ # Pass the target class through to Factory.build so it can mark the superclass
64
+ factory_kwargs[:_creating_action_class_for] = _creating_action_class_for
65
+
52
66
  Axn::Factory.build(**factory_kwargs, &block)
53
67
  end
54
68
 
55
- def configure_class_name_and_constant(axn_klass, name, axn_namespace)
69
+ def configure_class_name_and_constant(axn_klass, name, axn_namespace, target)
56
70
  configure_class_name(axn_klass, name, axn_namespace) if name.present?
57
- register_constant(axn_klass, name, axn_namespace) if should_register_constant?(axn_namespace)
71
+ register_constant(axn_klass, name, axn_namespace, target) if should_register_constant?(axn_namespace)
58
72
  end
59
73
 
60
74
  def configure_axn_mounted_to(axn_klass, target)
@@ -148,12 +162,29 @@ module Axn
148
162
  end
149
163
  end
150
164
 
151
- def register_constant(axn_klass, name, axn_namespace)
165
+ def register_constant(axn_klass, name, axn_namespace, target)
152
166
  constant_name = generate_constant_name(name)
153
167
 
154
- # Only set the constant if it doesn't exist
155
- return if axn_namespace.const_defined?(constant_name, false)
168
+ # Check if constant already exists - if so, only allow overwriting in inheritance scenarios
169
+ if axn_namespace.const_defined?(constant_name, false)
170
+ # Only allow overwriting if this is an inheritance scenario (child overriding parent)
171
+ # Check if the target's parent has the same method mounted (inheritance override)
172
+ parent = target.superclass
173
+ is_inheritance_override = parent.respond_to?(:_mounted_axn_descriptors) &&
174
+ parent._mounted_axn_descriptors.any? { |d| d.name.to_s == name.to_s }
175
+
176
+ # If it's not an inheritance override, this is a same-class collision
177
+ # Raise an error here for clarity, rather than silently skipping and failing later
178
+ # Use the same error message format as mount_method for consistency
179
+ unless is_inheritance_override
180
+ method_name = "#{name}!"
181
+ strategy_name = @descriptor.mount_strategy.name.split("::").last
182
+ raise Axn::Mountable::MountingError,
183
+ "#{strategy_name} unable to attach -- method '#{method_name}' is already taken"
184
+ end
185
+ end
156
186
 
187
+ # Set the constant (either it doesn't exist, or it's an inheritance override)
157
188
  axn_namespace.const_set(constant_name, axn_klass)
158
189
  end
159
190
  end
@@ -14,50 +14,22 @@ module Axn
14
14
  return axn_class if axn_class.is_a?(Class)
15
15
  end
16
16
 
17
- # Create a namespace class that inherits from parent's Axns if available
18
- client_class = target
19
- parent_axns = find_parent_axns_namespace(client_class)
20
- namespace_class = create_namespace_class(client_class, parent_axns)
21
-
22
- # Only set the constant if it doesn't exist
23
- return if target.const_defined?(:Axns, false)
24
-
25
- target.const_set(:Axns, namespace_class)
17
+ # Create a fresh namespace class for this target
18
+ create_namespace_class(target).tap do |namespace_class|
19
+ target.const_set(:Axns, namespace_class) unless target.const_defined?(:Axns, false)
20
+ end
26
21
  end
27
22
 
28
23
  private
29
24
 
30
- def find_parent_axns_namespace(client_class)
31
- return nil unless client_class.is_a?(Class)
32
- return nil unless client_class.superclass.respond_to?(:_mounted_axn_descriptors)
33
- return nil unless client_class.superclass.const_defined?(:Axns, false)
34
-
35
- client_class.superclass.const_get(:Axns)
36
- end
37
-
38
- def create_namespace_class(client_class, parent_axns)
39
- base_class = parent_axns || Class.new
40
-
41
- Class.new(base_class) do
25
+ def create_namespace_class(client_class)
26
+ Class.new do
42
27
  define_singleton_method(:__axn_mounted_to__) { client_class }
43
28
 
44
29
  define_singleton_method(:name) do
45
30
  client_name = client_class.name.presence || "AnonymousClient_#{client_class.object_id}"
46
31
  "#{client_name}::Axns"
47
32
  end
48
- end.tap do |ns|
49
- update_inherited_action_classes(ns, client_class) if parent_axns
50
- end
51
- end
52
-
53
- def update_inherited_action_classes(namespace, client_class)
54
- namespace.constants.each do |const_name|
55
- const_value = namespace.const_get(const_name)
56
- next unless const_value.is_a?(Class) && const_value.respond_to?(:__axn_mounted_to__)
57
-
58
- # Update __axn_mounted_to__ method on the existing class
59
- const_value.define_singleton_method(:__axn_mounted_to__) { client_class }
60
- const_value.define_method(:__axn_mounted_to__) { client_class }
61
33
  end
62
34
  end
63
35
  end
@@ -45,12 +45,12 @@ module Axn
45
45
  case inherit
46
46
  when Symbol
47
47
  PROFILES.fetch(inherit) do
48
- raise ArgumentError, "Unknown inherit profile: #{inherit.inspect}. Valid profiles: #{PROFILES.keys.join(", ")}"
48
+ raise ArgumentError, "Unknown inherit profile: #{inherit.inspect}. Valid profiles: #{PROFILES.keys.join(', ')}"
49
49
  end
50
50
  when Hash
51
51
  # Validate hash keys
52
52
  invalid_keys = inherit.keys - PROFILES[:none].keys
53
- raise ArgumentError, "Invalid inherit keys: #{invalid_keys.join(", ")}. Valid keys: #{PROFILES[:none].keys.join(", ")}" if invalid_keys.any?
53
+ raise ArgumentError, "Invalid inherit keys: #{invalid_keys.join(', ')}. Valid keys: #{PROFILES[:none].keys.join(', ')}" if invalid_keys.any?
54
54
 
55
55
  # Merge with none profile to ensure all keys are present
56
56
  PROFILES[:none].merge(inherit)
@@ -31,17 +31,24 @@ module Axn
31
31
  # We allow overriding if this is a child class with a parent that has axn methods (inheritance scenario)
32
32
  # Otherwise, we raise an error for same-class method collisions
33
33
  if _should_raise_method_collision_error?(target, method_name)
34
- raise MountingError, "#{name.split("::").last} unable to attach -- method '#{method_name}' is already taken"
34
+ raise MountingError, "#{name.split('::').last} unable to attach -- method '#{method_name}' is already taken"
35
35
  end
36
36
 
37
37
  target.define_singleton_method(method_name, &)
38
38
  end
39
39
 
40
- private
41
-
42
40
  # Mount methods to the namespace and register the action class
43
41
  def mount_to_namespace(descriptor:, target:)
42
+ define_namespace_methods(descriptor:, target:)
43
+ # Register the action class as a constant in the namespace
44
44
  action_class_builder = Helpers::ClassBuilder.new(descriptor)
45
+ action_class_builder.mount(target, descriptor.name.to_s)
46
+ end
47
+
48
+ # Define namespace methods on the target's Axns namespace
49
+ # This is public so it can be called from the inherited callback
50
+ # without re-registering the constant (which is already created by mounted_axn_for)
51
+ def define_namespace_methods(descriptor:, target:)
45
52
  namespace = Helpers::NamespaceManager.get_or_create_namespace(target)
46
53
  name = descriptor.name
47
54
  descriptor_ref = descriptor
@@ -61,9 +68,6 @@ module Axn
61
68
  axn = descriptor_ref.mounted_axn_for(target:)
62
69
  axn.call_async(**kwargs)
63
70
  end
64
-
65
- # Register the action class as a constant in the namespace
66
- action_class_builder.mount(target, name.to_s)
67
71
  end
68
72
 
69
73
  # Check if we should raise an error for method collision
@@ -46,7 +46,7 @@ module Axn
46
46
 
47
47
  if exposed_fields.size > 1
48
48
  raise MountingError,
49
- "Cannot determine expose_return_as for existing axn class with multiple exposed fields: #{exposed_fields.join(", ")}. " \
49
+ "Cannot determine expose_return_as for existing axn class with multiple exposed fields: #{exposed_fields.join(', ')}. " \
50
50
  "Use a fresh block with mount_axn_method or ensure the axn class has exactly one exposed field."
51
51
  end
52
52
  end
@@ -85,7 +85,7 @@ module Axn
85
85
  exposed_fields.first # Single field, assume it's expose_return_as
86
86
  else
87
87
  raise MountingError,
88
- "Cannot determine expose_return_as for existing axn class with multiple exposed fields: #{exposed_fields.join(", ")}. " \
88
+ "Cannot determine expose_return_as for existing axn class with multiple exposed fields: #{exposed_fields.join(', ')}. " \
89
89
  "Use a fresh block with mount_axn_method or ensure the axn class has exactly one exposed field."
90
90
  end
91
91
  end
data/lib/axn/mountable.rb CHANGED
@@ -21,7 +21,6 @@ module Axn
21
21
  #
22
22
  # - `mount_axn` and `mount_axn_method`: `:lifecycle` - Inherits hooks, callbacks, messages, and async config (but not fields)
23
23
  # - `step`: `:none` - Completely independent to avoid conflicts
24
- # - `enqueue_all_via`: `:async_only` - Only inherits async configuration
25
24
  #
26
25
  # ### Inheritance Profiles
27
26
  #
@@ -53,12 +52,6 @@ module Axn
53
52
  # # Will NOT run log_start or track_success
54
53
  # end
55
54
  #
56
- # # enqueue_all_via uses :async_only (only inherits async config)
57
- # MyClass.enqueue_all_via do
58
- # # Can call enqueue (uses inherited async config)
59
- # # Does NOT inherit hooks, callbacks, or messages
60
- # end
61
- #
62
55
  # @example Custom inheritance control
63
56
  # # Override step default to inherit lifecycle
64
57
  # MyClass.step :my_step, inherit: :lifecycle do
@@ -75,6 +68,47 @@ module Axn
75
68
  def self.included(base)
76
69
  base.class_eval do
77
70
  class_attribute :_mounted_axn_descriptors, default: []
71
+
72
+ # Eagerly create action class constants for inherited descriptors
73
+ # (e.g. allow TeamsharesAPI::Company::Axns::Get.call to work *without* having to
74
+ # call TeamsharesAPI::Company.get! first)
75
+ def self.inherited(subclass)
76
+ super
77
+
78
+ # Only process if we have inherited descriptors from parent
79
+ return unless _mounted_axn_descriptors.any?
80
+
81
+ # Skip if subclass doesn't respond to _mounted_axn_descriptors
82
+ # This prevents recursion when creating action classes that inherit from target
83
+ return unless subclass.respond_to?(:_mounted_axn_descriptors)
84
+
85
+ # Skip if we're currently creating an action class (prevent infinite recursion)
86
+ # This is necessary because Axn::Factory.build creates classes that inherit from
87
+ # Axn (which includes Axn::Mountable), triggering inherited callbacks during
88
+ # action class creation.
89
+ superclass = subclass.superclass
90
+ creating_for = superclass&.instance_variable_get(:@_axn_creating_action_class_for)
91
+ return if creating_for
92
+
93
+ # Skip if this is an action class being created (they're in the Axns namespace)
94
+ # Action classes have names like "ParentClass::Axns::ActionName"
95
+ subclass_name = subclass.name
96
+ return if subclass_name&.include?("::Axns::")
97
+
98
+ # Eagerly create constants for all inherited descriptors
99
+ # mounted_axn_for will ensure namespace exists and create the constant
100
+ # If a child overrides, the new descriptor will replace the constant
101
+ _mounted_axn_descriptors.each do |descriptor|
102
+ # This will create the constant if it doesn't exist
103
+ descriptor.mounted_axn_for(target: subclass)
104
+ # Also define namespace methods on the child's namespace
105
+ # This ensures TeamsharesAPI::Company::Axns.get works even though
106
+ # the descriptor was originally mounted on TeamsharesAPI::Base
107
+ # We use define_namespace_methods instead of mount_to_namespace to
108
+ # avoid re-registering the constant (which is already created above)
109
+ descriptor.mount_strategy.define_namespace_methods(descriptor:, target: subclass)
110
+ end
111
+ end
78
112
  end
79
113
 
80
114
  MountingStrategies.all.each do |(_name, klass)|
@@ -26,7 +26,25 @@ module Axn
26
26
  end
27
27
 
28
28
  def file_path
29
- @file_path ||= name.underscore
29
+ @file_path ||= begin
30
+ path = name.underscore
31
+ # Strip the configured autoload namespace prefix if present
32
+ # e.g., if namespace is "Actions" and name is "Actions::Slack",
33
+ # strip "Actions::" to get "slack" instead of "actions/slack"
34
+ autoload_namespace = configured_autoload_namespace
35
+ if autoload_namespace && path.start_with?("#{autoload_namespace.underscore}/")
36
+ path.sub(%r{\A#{Regexp.escape(autoload_namespace.underscore)}/}, "")
37
+ else
38
+ path
39
+ end
40
+ end
41
+ end
42
+
43
+ def configured_autoload_namespace
44
+ return nil unless defined?(Axn) && Axn.config.rails.app_actions_autoload_namespace
45
+
46
+ namespace = Axn.config.rails.app_actions_autoload_namespace
47
+ namespace&.to_s
30
48
  end
31
49
 
32
50
  def expectations_with_types
@@ -12,6 +12,6 @@ class <%= class_name %>
12
12
 
13
13
  <% end -%>
14
14
  def call
15
- # TODO: Implement action logic
15
+ # TODO: Implement logic
16
16
  end
17
17
  end
data/lib/axn/result.rb CHANGED
@@ -11,7 +11,7 @@ module Axn
11
11
  def ok(msg = nil, **exposures)
12
12
  exposes = exposures.keys.to_h { |key| [key, { optional: true }] }
13
13
 
14
- Axn::Factory.build(exposes:, success: msg) do
14
+ Axn::Factory.build(exposes:, success: msg, log_calls: false, log_errors: false) do
15
15
  exposures.each do |key, value|
16
16
  expose(key, value)
17
17
  end
@@ -21,7 +21,7 @@ module Axn
21
21
  def error(msg = nil, **exposures, &block)
22
22
  exposes = exposures.keys.to_h { |key| [key, { optional: true }] }
23
23
 
24
- Axn::Factory.build(exposes:, error: msg) do
24
+ Axn::Factory.build(exposes:, error: msg, log_calls: false, log_errors: false) do
25
25
  exposures.each do |key, value|
26
26
  expose(key, value)
27
27
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ class Strategies
5
+ module Form
6
+ # @param expect [Symbol] the attribute name to expect in the context (e.g. :params)
7
+ # @param expose [Symbol] the attribute name to expose in the context (e.g. :form)
8
+ # @param type [Class, String] the form class to use, or a string constant path
9
+ # @param inject [Array<Symbol>] optional additional attributes to include in the form (e.g. [:user, :company])
10
+ # @yield block to define the form class when type is a string and the constant doesn't exist
11
+ def self.configure(expect: :params, expose: :form, type: nil, inject: nil, &block)
12
+ expect ||= :"#{expose.to_s.delete_suffix('_form')}_params"
13
+
14
+ # Aliasing to avoid shadowing/any confusion
15
+ expect_attr = expect
16
+ expose_attr = expose
17
+
18
+ Module.new do
19
+ extend ActiveSupport::Concern
20
+
21
+ included do
22
+ raise ArgumentError, "form strategy: must pass explicit :type parameter to `use :form` when applying to anonymous classes" if type.nil? && name.nil?
23
+
24
+ resolved_type = Axn::Strategies::Form.resolve_type(type, expose_attr, name, &block)
25
+
26
+ raise ArgumentError, "form strategy: #{resolved_type} must implement `valid?`" unless resolved_type.method_defined?(:valid?)
27
+
28
+ expects expect_attr, type: :params
29
+ exposes(expose_attr, type: resolved_type)
30
+
31
+ define_method expose_attr do
32
+ attrs_for_form = public_send(expect_attr)&.dup || {}
33
+
34
+ Array(inject).each do |ctx|
35
+ attrs_for_form[ctx] = public_send(ctx)
36
+ end
37
+
38
+ resolved_type.new(attrs_for_form)
39
+ end
40
+ memo expose_attr
41
+
42
+ before do
43
+ expose expose_attr => public_send(expose_attr)
44
+ fail! unless public_send(expose_attr).valid?
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ # Resolve the form type from the given parameters
51
+ # @param type [Class, String, nil] the form class, constant path, or nil for auto-detection
52
+ # @param expose_attr [Symbol] the attribute name to expose (used for auto-detection)
53
+ # @param action_name [String, nil] the name of the action class (used for auto-detection)
54
+ # @yield block to define the form class when type is a string and the constant doesn't exist
55
+ # @return [Class] the resolved form class
56
+ def self.resolve_type(type, expose_attr, action_name, &)
57
+ type ||= "#{action_name}::#{expose_attr.to_s.classify}"
58
+
59
+ if type.is_a?(Class)
60
+ raise ArgumentError, "form strategy: cannot provide block when type is a Class" if block_given?
61
+
62
+ return type
63
+ end
64
+
65
+ type.constantize.tap do
66
+ raise ArgumentError, "form strategy: cannot provide block when type constant #{type} already exists" if block_given?
67
+ end
68
+ rescue NameError
69
+ # Constant doesn't exist
70
+ raise ArgumentError, "form strategy: type constant #{type} does not exist and no block provided to define it" unless block_given?
71
+
72
+ # Create the class using the block, inheriting from Axn::FormObject
73
+ Class.new(Axn::FormObject).tap do |klass|
74
+ klass.class_eval(&)
75
+ assign_constant(type, klass)
76
+ end
77
+ end
78
+
79
+ # Helper method to assign a class to a constant path
80
+ # @param constant_path [String] the full constant path (e.g., "CreateUser::Form")
81
+ # @param klass [Class] the class to assign
82
+ def self.assign_constant(constant_path, klass)
83
+ parts = constant_path.split("::")
84
+ constant_name = parts.pop
85
+ parent_path = parts.join("::")
86
+
87
+ if parent_path.empty?
88
+ # Top-level constant
89
+ Object.const_set(constant_name, klass)
90
+ else
91
+ # Nested constant - ensure parent namespace exists
92
+ parent = parent_path.constantize
93
+ parent.const_set(constant_name, klass)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -9,9 +9,16 @@ module Axn
9
9
  raise NotImplementedError, "Transaction strategy requires ActiveRecord" unless defined?(ActiveRecord)
10
10
 
11
11
  around do |hooked|
12
+ early_completion = nil
12
13
  ActiveRecord::Base.transaction do
13
14
  hooked.call
15
+ rescue Axn::Internal::EarlyCompletion => e
16
+ # EarlyCompletion is not an error - it's a control flow mechanism
17
+ # Store it to re-raise after transaction commits
18
+ early_completion = e
14
19
  end
20
+ # Re-raise EarlyCompletion after transaction commits successfully
21
+ raise early_completion if early_completion
15
22
  end
16
23
  end
17
24
  end