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
@@ -208,7 +208,10 @@ class ApiAction
208
208
 
209
209
  expects :data
210
210
 
211
- # Combine prefix with from for consistent error formatting
211
+ # Simply inherit child's error (prefix and handler are optional)
212
+ error from: ValidationAction
213
+
214
+ # Or combine prefix with from for consistent error formatting
212
215
  error from: ValidationAction, prefix: "API Error: " do |e|
213
216
  "Request validation failed: #{e.message}"
214
217
  end
@@ -216,6 +219,12 @@ class ApiAction
216
219
  # Or use prefix only (falls back to exception message)
217
220
  error from: ValidationAction, prefix: "API Error: "
218
221
 
222
+ # Match multiple child actions
223
+ error from: [ValidationAction, AnotherAction]
224
+
225
+ # Match any child action
226
+ error from: true
227
+
219
228
  def call
220
229
  ValidationAction.call!(input: data)
221
230
  end
@@ -223,9 +232,11 @@ end
223
232
  ```
224
233
 
225
234
  This configuration provides:
235
+ - Simple error message inheritance without requiring prefix or handler
226
236
  - Consistent error message formatting with prefixes
227
237
  - Automatic fallback to exception messages when no custom message is provided
228
238
  - Proper error message inheritance from nested actions
239
+ - Support for matching multiple child actions or any child action
229
240
 
230
241
  ::: warning Message Ordering
231
242
  **Important**: When using conditional messages, always define your static fallback messages **first** in your class, before any conditional messages. This ensures proper fallback behavior.
@@ -258,6 +269,37 @@ In addition to `#call`, there are a few additional pieces to be aware of:
258
269
 
259
270
  Note execution is halted whenever `fail!` is called, `done!` is called, or an exception is raised (so a `before` block failure won't execute `call` or `after`, while an `after` block failure will make `result.ok?` be false even though `call` completed successfully). The `done!` method specifically skips `after` hooks and any remaining `call` method execution, but allows `around` hooks to complete normally.
260
271
 
272
+ #### Around hooks
273
+
274
+ Around hooks wrap the entire action execution, including before and after hooks. They receive a block that represents the next step in the chain:
275
+
276
+ ```ruby
277
+ class Foo
278
+ include Axn
279
+
280
+ around :with_timing
281
+ around do |chain|
282
+ log("outer around start")
283
+ chain.call
284
+ log("outer around end")
285
+ end
286
+
287
+ def call
288
+ log("in call")
289
+ end
290
+
291
+ private
292
+
293
+ def with_timing(chain)
294
+ start = Time.current
295
+ chain.call
296
+ log("Took #{Time.current - start}s")
297
+ end
298
+ end
299
+ ```
300
+
301
+ #### Before/After example
302
+
261
303
  For instance, given this configuration:
262
304
 
263
305
  ```ruby
@@ -301,6 +343,62 @@ This follows the natural pattern of setup (general → specific) and teardown (s
301
343
  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.
302
344
 
303
345
  ## Strategies
346
+
304
347
  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.
305
348
 
349
+ ::: info Optional Peer Libraries
350
+ Axn provides enhanced functionality when certain peer libraries are available:
351
+
352
+ - **Rails**: Automatic engine loading, autoloading for `app/actions`, and generators
353
+ - **Faraday**: Enables the [Client Strategy](/strategies/client) for HTTP API integrations
354
+ - **memo_wise**: Extends built-in `memo` helper to support methods with arguments (see [Memoization recipe](/recipes/memoization))
355
+
356
+ These are all optional—Axn works great without them, but they unlock additional features when present.
357
+ :::
358
+
359
+ ## Advanced: Default call behavior
360
+
361
+ ::: tip For Experienced Users
362
+ This section covers an advanced shortcut. If you're new to Axn, start by explicitly defining your `call` method.
363
+ :::
364
+
365
+ If you don't define a `call` method, Axn provides a default implementation that automatically exposes all declared exposures by calling methods with matching names. This allows you to simplify actions that only need to compute and expose values:
366
+
367
+ ```ruby
368
+ class CertificatesByDestination
369
+ include Axn
370
+ exposes :certs_by_destination, type: Hash
371
+
372
+ private
373
+
374
+ def certs_by_destination
375
+ # Your logic here - automatically exposed
376
+ { "dest1" => "cert1", "dest2" => "cert2" }
377
+ end
378
+ end
379
+ ```
380
+
381
+ This is equivalent to:
382
+
383
+ ```ruby
384
+ class CertificatesByDestination
385
+ include Axn
386
+ exposes :certs_by_destination, type: Hash
387
+
388
+ def call
389
+ expose certs_by_destination: certs_by_destination
390
+ end
391
+
392
+ private
393
+
394
+ def certs_by_destination
395
+ { "dest1" => "cert1", "dest2" => "cert2" }
396
+ end
397
+ end
306
398
  ```
399
+
400
+ **Important notes:**
401
+ - The default `call` requires a method matching each declared exposure (unless a `default` is provided)
402
+ - If a method is missing and no default is provided, the action will fail with a helpful error message
403
+ - You can still override `call` to implement custom logic when needed
404
+ - If a method returns `nil` for an exposed-only field with no default, it's treated as missing (user-defined methods that legitimately return `nil` should use `expose` explicitly or provide a default)
@@ -6,6 +6,10 @@ module Axn
6
6
  module ActiveJob
7
7
  extend ActiveSupport::Concern
8
8
 
9
+ def self._running_in_background?
10
+ defined?(ActiveJob) && ActiveJob::Base.current_job.present?
11
+ end
12
+
9
13
  included do
10
14
  raise LoadError, "ActiveJob is not available. Please add 'activejob' to your Gemfile." unless defined?(::ActiveJob::Base)
11
15
 
@@ -16,23 +20,28 @@ module Axn
16
20
  end
17
21
 
18
22
  class_methods do
19
- def call_async(**kwargs)
23
+ private
24
+
25
+ # Implements adapter-specific enqueueing logic for ActiveJob.
26
+ # Note: Adapters must implement _enqueue_async_job and must NOT override call_async.
27
+ def _enqueue_async_job(kwargs)
20
28
  job = active_job_proxy_class
21
29
 
22
- if kwargs[:_async].is_a?(Hash)
23
- options = kwargs.delete(:_async)
24
- if options[:wait_until]
25
- job = job.set(wait_until: options[:wait_until])
26
- elsif options[:wait]
27
- job = job.set(wait: options[:wait])
30
+ # Extract and normalize _async options (removes _async from kwargs)
31
+ normalized_options = _extract_and_normalize_async_options(kwargs)
32
+
33
+ # Process normalized async options if present
34
+ if normalized_options
35
+ if normalized_options["wait_until"]
36
+ job = job.set(wait_until: normalized_options["wait_until"])
37
+ elsif normalized_options["wait"]
38
+ job = job.set(wait: normalized_options["wait"])
28
39
  end
29
40
  end
30
41
 
31
- job.perform_later(**kwargs)
42
+ job.perform_later(kwargs)
32
43
  end
33
44
 
34
- private
35
-
36
45
  def active_job_proxy_class
37
46
  @active_job_proxy_class ||= create_active_job_proxy_class
38
47
  end
@@ -4,16 +4,31 @@ module Axn
4
4
  module Async
5
5
  class Adapters
6
6
  module Disabled
7
+ def self._running_in_background?
8
+ false
9
+ end
10
+
7
11
  def self.included(base)
8
12
  base.class_eval do
9
13
  # Validate that kwargs are not provided for Disabled adapter
10
14
  raise ArgumentError, "Disabled adapter does not accept configuration options." if _async_config&.any?
11
15
  raise ArgumentError, "Disabled adapter does not accept configuration block." if _async_config_block
12
16
 
17
+ # Exception to the adapter pattern: Disabled adapter overrides call_async directly
18
+ # to raise immediately without emitting notifications or logging.
19
+ # Other adapters must NOT override call_async and should only implement _enqueue_async_job.
13
20
  def self.call_async(**kwargs)
14
21
  # Remove _async parameter to avoid confusion in error message
15
22
  kwargs.delete(:_async)
16
23
 
24
+ # Don't emit notification or log - just raise immediately
25
+ raise NotImplementedError,
26
+ "Async execution is explicitly disabled for #{name}. " \
27
+ "Use `async :sidekiq` or `async :active_job` to enable background processing."
28
+ end
29
+
30
+ def self._enqueue_async_job(kwargs)
31
+ # This should never be called since call_async raises, but define it for completeness
17
32
  raise NotImplementedError,
18
33
  "Async execution is explicitly disabled for #{name}. " \
19
34
  "Use `async :sidekiq` or `async :active_job` to enable background processing."
@@ -6,6 +6,10 @@ module Axn
6
6
  module Sidekiq
7
7
  extend ActiveSupport::Concern
8
8
 
9
+ def self._running_in_background?
10
+ defined?(::Sidekiq) && ::Sidekiq.server?
11
+ end
12
+
9
13
  included do
10
14
  raise LoadError, "Sidekiq is not available. Please add 'sidekiq' to your Gemfile." unless defined?(::Sidekiq)
11
15
 
@@ -14,6 +18,11 @@ module Axn
14
18
 
15
19
  include ::Sidekiq::Job
16
20
 
21
+ # Sidekiq's processor calls .new on the worker class from outside the class hierarchy
22
+ # (see Sidekiq::Processor#dispatch which does `klass.new`).
23
+ # Since Axn::Core makes :new private, we need to restore it for Sidekiq workers.
24
+ public_class_method :new
25
+
17
26
  # Apply configuration block if present
18
27
  class_eval(&_async_config_block) if _async_config_block
19
28
 
@@ -22,48 +31,32 @@ module Axn
22
31
  end
23
32
 
24
33
  class_methods do
25
- def call_async(**kwargs)
26
- job_kwargs = _params_to_global_id(kwargs)
27
-
28
- if kwargs[:_async].is_a?(Hash)
29
- options = kwargs.delete(:_async)
30
- if options[:wait_until]
31
- return perform_at(options[:wait_until], job_kwargs)
32
- elsif options[:wait]
33
- return perform_in(options[:wait], job_kwargs)
34
- end
35
- end
34
+ private
36
35
 
37
- perform_async(job_kwargs)
38
- end
36
+ # Implements adapter-specific enqueueing logic for Sidekiq.
37
+ # Note: Adapters must implement _enqueue_async_job and must NOT override call_async.
38
+ def _enqueue_async_job(kwargs)
39
+ # Extract and normalize _async options (removes _async from kwargs)
40
+ normalized_options = _extract_and_normalize_async_options(kwargs)
39
41
 
40
- def _params_to_global_id(context = {})
41
- return {} if context.nil?
42
+ # Convert kwargs to string keys and handle GlobalID conversion
43
+ job_kwargs = Axn::Util::GlobalIdSerialization.serialize(kwargs)
42
44
 
43
- context.stringify_keys.each_with_object({}) do |(key, value), hash|
44
- if value.respond_to?(:to_global_id)
45
- hash["#{key}_as_global_id"] = value.to_global_id.to_s
46
- else
47
- hash[key] = value
45
+ # Process normalized async options if present
46
+ if normalized_options
47
+ if normalized_options["wait_until"]
48
+ return perform_at(normalized_options["wait_until"], job_kwargs)
49
+ elsif normalized_options["wait"]
50
+ return perform_in(normalized_options["wait"], job_kwargs)
48
51
  end
49
52
  end
50
- end
51
53
 
52
- def _params_from_global_id(params)
53
- return {} if params.nil?
54
-
55
- params.each_with_object({}) do |(key, value), hash|
56
- if key.end_with?("_as_global_id")
57
- hash[key.delete_suffix("_as_global_id")] = GlobalID::Locator.locate(value)
58
- else
59
- hash[key] = value
60
- end
61
- end.symbolize_keys
54
+ perform_async(job_kwargs)
62
55
  end
63
56
  end
64
57
 
65
58
  def perform(*args)
66
- context = self.class._params_from_global_id(args.first)
59
+ context = Axn::Util::GlobalIdSerialization.deserialize(args.first)
67
60
 
68
61
  # Always use bang version so sidekiq can retry if we failed
69
62
  self.class.call!(**context)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Async
5
+ module BatchEnqueue
6
+ # Stores the configuration for a single enqueues_each declaration
7
+ class Config
8
+ attr_reader :field, :from, :via, :filter_block
9
+
10
+ def initialize(field:, from:, via:, filter_block:)
11
+ @field = field
12
+ @from = from
13
+ @via = via
14
+ @filter_block = filter_block
15
+ end
16
+
17
+ # Resolves the source collection for iteration
18
+ # Can be a lambda, a symbol (method name on target), or inferred from model: true
19
+ def resolve_source(target:)
20
+ return from.call if from.is_a?(Proc)
21
+ return target.send(from) if from.is_a?(Symbol)
22
+
23
+ # Infer from field's model config if 'from' is nil
24
+ field_config = target.internal_field_configs.find { |c| c.field == field }
25
+ model_opts = field_config&.validations&.dig(:model)
26
+ model_class = model_opts[:klass] if model_opts.is_a?(Hash)
27
+
28
+ unless model_class
29
+ raise ArgumentError,
30
+ "enqueues_each :#{field} requires `from:` option or a `model:` declaration " \
31
+ "on `expects :#{field}` to infer the source collection."
32
+ end
33
+ model_class.all
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/async/batch_enqueue/config"
4
+
5
+ module Axn
6
+ module Async
7
+ # BatchEnqueue provides declarative batch enqueueing for Axn actions.
8
+ #
9
+ # Fields with `model:` declarations are automatically inferred for iteration.
10
+ # Use `enqueues_each` to override defaults, add filtering, or iterate non-model fields.
11
+ # All Axn classes have `enqueue_all` defined, which validates configuration and
12
+ # executes iteration asynchronously via EnqueueAllOrchestrator.
13
+ #
14
+ # @example Auto-inference from model: (no enqueues_each needed)
15
+ # class SyncCompany
16
+ # include Axn
17
+ # async :sidekiq
18
+ #
19
+ # expects :company, model: Company # Auto-inferred: Company.all
20
+ #
21
+ # def call
22
+ # # sync logic
23
+ # end
24
+ # end
25
+ #
26
+ # SyncCompany.enqueue_all # Automatically iterates Company.all
27
+ #
28
+ # @example With explicit source override
29
+ # enqueues_each :company, from: -> { Company.active }
30
+ #
31
+ # @example With extraction (passes company_id instead of company object)
32
+ # enqueues_each :company_id, from: -> { Company.active }, via: :id
33
+ #
34
+ # @example With filter block
35
+ # enqueues_each :company do |company|
36
+ # company.active? && !company.in_exit?
37
+ # end
38
+ #
39
+ # @example Override on enqueue_all call
40
+ # # Override with enumerable (replaces source)
41
+ # SyncCompany.enqueue_all(company: Company.active.limit(10))
42
+ #
43
+ # # Override with scalar (makes it static, no iteration)
44
+ # SyncCompany.enqueue_all(company: Company.find(123))
45
+ #
46
+ # @example Multi-field cross-product
47
+ # enqueues_each :user, from: -> { User.active }
48
+ # enqueues_each :company, from: -> { Company.active }
49
+ # # Produces user_count × company_count jobs
50
+ module BatchEnqueue
51
+ extend ActiveSupport::Concern
52
+
53
+ included do
54
+ class_attribute :_batch_enqueue_configs, default: []
55
+ end
56
+
57
+ # DSL methods for batch enqueueing
58
+ module DSL
59
+ # Batch enqueue jobs for this action.
60
+ #
61
+ # Validates async is configured, validates static args, then executes
62
+ # iteration asynchronously via EnqueueAllOrchestrator.
63
+ #
64
+ # Fields with `model:` declarations are automatically inferred for iteration.
65
+ # You can override iteration by passing enumerables (to replace source) or
66
+ # scalars (to make fields static) as kwargs.
67
+ #
68
+ # @param static_args [Hash] Arguments to pass to every enqueued job.
69
+ # - Scalar values: Treated as static args (passed to all jobs)
70
+ # - Enumerable values: Treated as iteration sources (overrides configured sources)
71
+ # - Exception: Arrays/Sets are static when field expects enumerable type
72
+ # @return [String] Job ID from the async adapter
73
+ # @raise [NotImplementedError] If async is not configured
74
+ # @raise [MissingEnqueuesEachError] If expects exist but no iteration config found
75
+ # @raise [ArgumentError] If required static fields are missing
76
+ def enqueue_all(**static_args)
77
+ EnqueueAllOrchestrator.enqueue_for(self, **static_args)
78
+ end
79
+
80
+ # Declare a field to iterate over for batch enqueueing.
81
+ #
82
+ # Note: Fields with `model:` declarations are automatically inferred, so
83
+ # `enqueues_each` is only needed to override defaults, add filtering, or
84
+ # iterate non-model fields.
85
+ #
86
+ # @param field [Symbol] The field name from expects to iterate over
87
+ # @param from [Proc, Symbol, nil] The source collection.
88
+ # - Proc/lambda: Called to get the collection
89
+ # - Symbol: Method name on the action class
90
+ # - nil: Inferred from field's `model:` declaration (Model.all)
91
+ # @param via [Symbol, nil] Optional attribute to extract from each item (e.g., :id)
92
+ # @param block [Proc, nil] Optional filter block - return truthy to enqueue, falsy to skip
93
+ def enqueues_each(field, from: nil, via: nil, &filter_block)
94
+ self._batch_enqueue_configs += [Config.new(field:, from:, via:, filter_block:)]
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end