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
@@ -8,7 +8,7 @@ The core boilerplate is pretty minimal:
8
8
 
9
9
  ```ruby
10
10
  class Foo
11
- include Action
11
+ include Axn
12
12
 
13
13
  def call
14
14
  # ... do some stuff here?
@@ -22,14 +22,15 @@ The first step is to determine what arguments you expect to be passed into `call
22
22
 
23
23
  If you want to expose any results to the caller, declare that via the `exposes` keyword.
24
24
 
25
- Both of these optionally accept `type:`, `allow_nil:`, `allow_blank:`, and any other ActiveModel validation (see: [reference](/reference/class)).
25
+ Both of these optionally accept `type:`, `optional:`, `allow_nil:`, `allow_blank:`, and any other ActiveModel validation (see: [reference](/reference/class)).
26
26
 
27
27
 
28
28
  ```ruby
29
29
  class Foo
30
- include Action
30
+ include Axn
31
31
 
32
32
  expects :name, type: String # [!code focus:2]
33
+ expects :email, type: String, optional: true # [!code focus:2]
33
34
  exposes :meaning_of_life
34
35
 
35
36
  def call
@@ -42,13 +43,15 @@ end
42
43
 
43
44
  Once the interface is defined, you're primarily focused on defining the `call` method.
44
45
 
45
- To abort execution with a specific error message, call `fail!`.
46
+ To abort execution with a specific error message, call `fail!`. You can also provide exposures as keyword arguments.
47
+
48
+ To complete execution early with a success result, call `done!` with an optional success message and exposures as keyword arguments.
46
49
 
47
50
  If you declare that your action `exposes` anything, you need to actually `expose` it.
48
51
 
49
52
  ```ruby
50
53
  class Foo
51
- include Action
54
+ include Axn
52
55
 
53
56
  expects :name, type: String
54
57
  exposes :meaning_of_life
@@ -64,6 +67,84 @@ end
64
67
 
65
68
  See [the reference doc](/reference/instance) for a few more handy helper methods (e.g. `#log`).
66
69
 
70
+ ### Convenient failure with context
71
+
72
+ Both `fail!` and `done!` can accept keyword arguments to expose data before halting execution:
73
+
74
+ ```ruby
75
+ class UserValidator
76
+ include Axn
77
+
78
+ expects :email
79
+ exposes :error_code, :field
80
+
81
+ def call
82
+ if email.blank?
83
+ fail!("Email is required", error_code: 422, field: "email")
84
+ end
85
+
86
+ # ... validation logic
87
+ end
88
+ end
89
+ ```
90
+
91
+ ## Early completion with `done!`
92
+
93
+ The `done!` method allows you to complete an action early with a success result, bypassing the rest of the execution:
94
+
95
+ ```ruby
96
+ class UserLookup
97
+ include Axn
98
+
99
+ expects :user_id
100
+ exposes :user, :cached
101
+
102
+ def call
103
+ # Check cache first
104
+ cached_user = Rails.cache.read("user:#{user_id}")
105
+ if cached_user
106
+ done!("User found in cache", user: cached_user, cached: true) # Early completion with exposures
107
+ end
108
+
109
+ # This won't execute if done! was called above
110
+ user = User.find(user_id)
111
+ expose user: user, cached: false
112
+ end
113
+ end
114
+ ```
115
+
116
+ ### Important behavior notes
117
+
118
+ **Hook execution:**
119
+ - `done!` **skips** any `after` hooks (or `call` method if called from a `before` hook)
120
+ - `around` hooks **will complete** normally, allowing transactions and tracing to finish properly
121
+ - If you want code that executes on both normal AND early success, use an `on_success` callback instead of an `after` hook
122
+
123
+ **Transaction handling:**
124
+ - `done!` is implemented internally via an exception, so it **will roll back** manually applied `ActiveRecord::Base.transaction` blocks
125
+ - Use the [`use :transaction` strategy](/strategies/transaction) instead - transactions applied via this strategy will **NOT** be rolled back by `done!`
126
+ - This ensures database consistency while allowing early completion
127
+
128
+ **Validation:**
129
+ - Outbound validation (required `exposes`) still runs even with early completion
130
+ - If required fields are not provided, the action will fail despite the early completion
131
+
132
+ ```ruby
133
+ class BadExample
134
+ include Axn
135
+
136
+ expects :user_id
137
+ exposes :user # Required field
138
+
139
+ def call
140
+ done!("Early completion") # This will FAIL - user not exposed
141
+ end
142
+ end
143
+
144
+ BadExample.call(user_id: 123).ok? # => false
145
+ BadExample.call(user_id: 123).exception # => Axn::OutboundValidationError
146
+ ```
147
+
67
148
  ## Customizing messages
68
149
 
69
150
  The default `error` and `success` message strings ("Something went wrong" / "Action completed successfully", respectively) _are_ technically safe to show users, but you'll often want to set them to something more useful.
@@ -74,7 +155,7 @@ For instance, configuring the action like this:
74
155
 
75
156
  ```ruby
76
157
  class Foo
77
- include Action
158
+ include Axn
78
159
 
79
160
  expects :name, type: String
80
161
  exposes :meaning_of_life
@@ -106,7 +187,7 @@ You can also use conditional error messages with the `prefix:` keyword and combi
106
187
 
107
188
  ```ruby
108
189
  class ValidationAction
109
- include Action
190
+ include Axn
110
191
 
111
192
  expects :input
112
193
 
@@ -123,11 +204,14 @@ class ValidationAction
123
204
  end
124
205
 
125
206
  class ApiAction
126
- include Action
207
+ include Axn
127
208
 
128
209
  expects :data
129
210
 
130
- # 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
131
215
  error from: ValidationAction, prefix: "API Error: " do |e|
132
216
  "Request validation failed: #{e.message}"
133
217
  end
@@ -135,6 +219,12 @@ class ApiAction
135
219
  # Or use prefix only (falls back to exception message)
136
220
  error from: ValidationAction, prefix: "API Error: "
137
221
 
222
+ # Match multiple child actions
223
+ error from: [ValidationAction, AnotherAction]
224
+
225
+ # Match any child action
226
+ error from: true
227
+
138
228
  def call
139
229
  ValidationAction.call!(input: data)
140
230
  end
@@ -142,9 +232,11 @@ end
142
232
  ```
143
233
 
144
234
  This configuration provides:
235
+ - Simple error message inheritance without requiring prefix or handler
145
236
  - Consistent error message formatting with prefixes
146
237
  - Automatic fallback to exception messages when no custom message is provided
147
238
  - Proper error message inheritance from nested actions
239
+ - Support for matching multiple child actions or any child action
148
240
 
149
241
  ::: warning Message Ordering
150
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.
@@ -152,7 +244,7 @@ This configuration provides:
152
244
  **Correct order:**
153
245
  ```ruby
154
246
  class Foo
155
- include Action
247
+ include Axn
156
248
 
157
249
  # Static fallback messages first
158
250
  success "Default success message"
@@ -175,13 +267,44 @@ In addition to `#call`, there are a few additional pieces to be aware of:
175
267
 
176
268
  `before`, `after`, and `around` hooks are supported. They can receive a block directly, or the symbol name of a local method.
177
269
 
178
- Note execution is halted whenever `fail!` 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).
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.
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
179
302
 
180
303
  For instance, given this configuration:
181
304
 
182
305
  ```ruby
183
306
  class Foo
184
- include Action
307
+ include Axn
185
308
 
186
309
  before { log("before hook") } # [!code focus:2]
187
310
  after :log_after
@@ -220,6 +343,62 @@ This follows the natural pattern of setup (general → specific) and teardown (s
220
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.
221
344
 
222
345
  ## Strategies
346
+
223
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.
224
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
225
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
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)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Async
5
+ class Adapters
6
+ module ActiveJob
7
+ extend ActiveSupport::Concern
8
+
9
+ def self._running_in_background?
10
+ defined?(ActiveJob) && ActiveJob::Base.current_job.present?
11
+ end
12
+
13
+ included do
14
+ raise LoadError, "ActiveJob is not available. Please add 'activejob' to your Gemfile." unless defined?(::ActiveJob::Base)
15
+
16
+ # Validate that kwargs are not provided for ActiveJob
17
+ if _async_config&.any?
18
+ raise ArgumentError, "ActiveJob adapter requires a configuration block. Use `async :active_job do ... end` instead of passing keyword arguments."
19
+ end
20
+ end
21
+
22
+ class_methods do
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)
28
+ job = active_job_proxy_class
29
+
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"])
39
+ end
40
+ end
41
+
42
+ job.perform_later(kwargs)
43
+ end
44
+
45
+ def active_job_proxy_class
46
+ @active_job_proxy_class ||= create_active_job_proxy_class
47
+ end
48
+
49
+ def create_active_job_proxy_class
50
+ # Store reference to the original action class
51
+ action_class = self
52
+
53
+ # Create the ActiveJob proxy class
54
+ Class.new(::ActiveJob::Base).tap do |proxy|
55
+ # Give the job class a meaningful name for logging and debugging
56
+ job_name = "#{name}::ActiveJobProxy"
57
+ const_set("ActiveJobProxy", proxy)
58
+ proxy.define_singleton_method(:name) { job_name }
59
+
60
+ # Apply the async configuration block if it exists
61
+ proxy.class_eval(&_async_config_block) if _async_config_block
62
+
63
+ # Define the perform method
64
+ proxy.define_method(:perform) do |job_context = {}|
65
+ # Call the original action class with the job context
66
+ action_class.call!(**job_context)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Async
5
+ class Adapters
6
+ module Disabled
7
+ def self._running_in_background?
8
+ false
9
+ end
10
+
11
+ def self.included(base)
12
+ base.class_eval do
13
+ # Validate that kwargs are not provided for Disabled adapter
14
+ raise ArgumentError, "Disabled adapter does not accept configuration options." if _async_config&.any?
15
+ raise ArgumentError, "Disabled adapter does not accept configuration block." if _async_config_block
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.
20
+ def self.call_async(**kwargs)
21
+ # Remove _async parameter to avoid confusion in error message
22
+ kwargs.delete(:_async)
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
32
+ raise NotImplementedError,
33
+ "Async execution is explicitly disabled for #{name}. " \
34
+ "Use `async :sidekiq` or `async :active_job` to enable background processing."
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Async
5
+ class Adapters
6
+ module Sidekiq
7
+ extend ActiveSupport::Concern
8
+
9
+ def self._running_in_background?
10
+ defined?(::Sidekiq) && ::Sidekiq.server?
11
+ end
12
+
13
+ included do
14
+ raise LoadError, "Sidekiq is not available. Please add 'sidekiq' to your Gemfile." unless defined?(::Sidekiq)
15
+
16
+ # Use Sidekiq::Job if available (Sidekiq 7+), otherwise error
17
+ raise LoadError, "Sidekiq::Job is not available. Please check your Sidekiq version." unless defined?(::Sidekiq::Job)
18
+
19
+ include ::Sidekiq::Job
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
+
26
+ # Apply configuration block if present
27
+ class_eval(&_async_config_block) if _async_config_block
28
+
29
+ # Apply kwargs configuration if present
30
+ sidekiq_options(**_async_config) if _async_config&.any?
31
+ end
32
+
33
+ class_methods do
34
+ private
35
+
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)
41
+
42
+ # Convert kwargs to string keys and handle GlobalID conversion
43
+ job_kwargs = Axn::Util::GlobalIdSerialization.serialize(kwargs)
44
+
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)
51
+ end
52
+ end
53
+
54
+ perform_async(job_kwargs)
55
+ end
56
+ end
57
+
58
+ def perform(*args)
59
+ context = Axn::Util::GlobalIdSerialization.deserialize(args.first)
60
+
61
+ # Always use bang version so sidekiq can retry if we failed
62
+ self.class.call!(**context)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/internal/registry"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module Axn
7
+ module Async
8
+ class AdapterNotFound < Axn::Internal::Registry::NotFound; end
9
+ class DuplicateAdapterError < Axn::Internal::Registry::DuplicateError; end
10
+
11
+ class Adapters < Axn::Internal::Registry
12
+ class << self
13
+ def registry_directory = __dir__
14
+
15
+ private
16
+
17
+ def item_type = "Adapter"
18
+ def not_found_error_class = AdapterNotFound
19
+ def duplicate_error_class = DuplicateAdapterError
20
+ end
21
+ end
22
+
23
+ # Trigger registry loading to ensure adapters are available
24
+ Adapters.all
25
+ end
26
+ end
@@ -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