cmdx 1.0.0 → 1.1.0

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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/prompts/rspec.md +20 -0
  3. data/.cursor/prompts/yardoc.md +8 -0
  4. data/.rubocop.yml +5 -0
  5. data/CHANGELOG.md +101 -49
  6. data/README.md +2 -1
  7. data/docs/ai_prompts.md +10 -0
  8. data/docs/basics/call.md +11 -2
  9. data/docs/basics/chain.md +10 -1
  10. data/docs/basics/context.md +9 -0
  11. data/docs/basics/setup.md +9 -0
  12. data/docs/callbacks.md +14 -37
  13. data/docs/configuration.md +68 -27
  14. data/docs/getting_started.md +11 -0
  15. data/docs/internationalization.md +148 -0
  16. data/docs/interruptions/exceptions.md +10 -1
  17. data/docs/interruptions/faults.md +11 -2
  18. data/docs/interruptions/halt.md +9 -0
  19. data/docs/logging.md +14 -4
  20. data/docs/middlewares.md +53 -43
  21. data/docs/outcomes/result.md +9 -0
  22. data/docs/outcomes/states.md +9 -0
  23. data/docs/outcomes/statuses.md +9 -0
  24. data/docs/parameters/coercions.md +58 -38
  25. data/docs/parameters/defaults.md +10 -1
  26. data/docs/parameters/definitions.md +9 -0
  27. data/docs/parameters/namespacing.md +9 -0
  28. data/docs/parameters/validations.md +8 -67
  29. data/docs/testing.md +22 -13
  30. data/docs/tips_and_tricks.md +9 -0
  31. data/docs/workflows.md +14 -4
  32. data/lib/cmdx/.DS_Store +0 -0
  33. data/lib/cmdx/callback.rb +36 -56
  34. data/lib/cmdx/callback_registry.rb +82 -73
  35. data/lib/cmdx/chain.rb +65 -122
  36. data/lib/cmdx/chain_inspector.rb +22 -115
  37. data/lib/cmdx/chain_serializer.rb +17 -148
  38. data/lib/cmdx/coercion.rb +49 -0
  39. data/lib/cmdx/coercion_registry.rb +94 -0
  40. data/lib/cmdx/coercions/array.rb +18 -36
  41. data/lib/cmdx/coercions/big_decimal.rb +21 -33
  42. data/lib/cmdx/coercions/boolean.rb +21 -40
  43. data/lib/cmdx/coercions/complex.rb +18 -31
  44. data/lib/cmdx/coercions/date.rb +20 -39
  45. data/lib/cmdx/coercions/date_time.rb +22 -39
  46. data/lib/cmdx/coercions/float.rb +19 -32
  47. data/lib/cmdx/coercions/hash.rb +22 -41
  48. data/lib/cmdx/coercions/integer.rb +20 -33
  49. data/lib/cmdx/coercions/rational.rb +20 -32
  50. data/lib/cmdx/coercions/string.rb +23 -31
  51. data/lib/cmdx/coercions/time.rb +24 -40
  52. data/lib/cmdx/coercions/virtual.rb +14 -31
  53. data/lib/cmdx/configuration.rb +57 -171
  54. data/lib/cmdx/context.rb +22 -165
  55. data/lib/cmdx/core_ext/hash.rb +42 -67
  56. data/lib/cmdx/core_ext/module.rb +35 -79
  57. data/lib/cmdx/core_ext/object.rb +63 -98
  58. data/lib/cmdx/correlator.rb +40 -156
  59. data/lib/cmdx/error.rb +37 -202
  60. data/lib/cmdx/errors.rb +165 -202
  61. data/lib/cmdx/fault.rb +55 -158
  62. data/lib/cmdx/faults.rb +26 -137
  63. data/lib/cmdx/immutator.rb +22 -109
  64. data/lib/cmdx/lazy_struct.rb +103 -187
  65. data/lib/cmdx/log_formatters/json.rb +14 -40
  66. data/lib/cmdx/log_formatters/key_value.rb +14 -40
  67. data/lib/cmdx/log_formatters/line.rb +14 -48
  68. data/lib/cmdx/log_formatters/logstash.rb +14 -57
  69. data/lib/cmdx/log_formatters/pretty_json.rb +14 -50
  70. data/lib/cmdx/log_formatters/pretty_key_value.rb +13 -46
  71. data/lib/cmdx/log_formatters/pretty_line.rb +16 -54
  72. data/lib/cmdx/log_formatters/raw.rb +19 -49
  73. data/lib/cmdx/logger.rb +20 -82
  74. data/lib/cmdx/logger_ansi.rb +18 -75
  75. data/lib/cmdx/logger_serializer.rb +24 -114
  76. data/lib/cmdx/middleware.rb +38 -60
  77. data/lib/cmdx/middleware_registry.rb +81 -77
  78. data/lib/cmdx/middlewares/correlate.rb +41 -226
  79. data/lib/cmdx/middlewares/timeout.rb +46 -185
  80. data/lib/cmdx/parameter.rb +120 -198
  81. data/lib/cmdx/parameter_evaluator.rb +231 -0
  82. data/lib/cmdx/parameter_inspector.rb +25 -56
  83. data/lib/cmdx/parameter_registry.rb +59 -84
  84. data/lib/cmdx/parameter_serializer.rb +23 -74
  85. data/lib/cmdx/railtie.rb +24 -107
  86. data/lib/cmdx/result.rb +254 -260
  87. data/lib/cmdx/result_ansi.rb +19 -85
  88. data/lib/cmdx/result_inspector.rb +27 -68
  89. data/lib/cmdx/result_logger.rb +18 -81
  90. data/lib/cmdx/result_serializer.rb +28 -132
  91. data/lib/cmdx/rspec/matchers.rb +28 -0
  92. data/lib/cmdx/rspec/result_matchers/be_executed.rb +42 -0
  93. data/lib/cmdx/rspec/result_matchers/be_failed_task.rb +94 -0
  94. data/lib/cmdx/rspec/result_matchers/be_skipped_task.rb +94 -0
  95. data/lib/cmdx/rspec/result_matchers/be_state_matchers.rb +59 -0
  96. data/lib/cmdx/rspec/result_matchers/be_status_matchers.rb +57 -0
  97. data/lib/cmdx/rspec/result_matchers/be_successful_task.rb +87 -0
  98. data/lib/cmdx/rspec/result_matchers/have_bad_outcome.rb +51 -0
  99. data/lib/cmdx/rspec/result_matchers/have_caused_failure.rb +58 -0
  100. data/lib/cmdx/rspec/result_matchers/have_chain_index.rb +59 -0
  101. data/lib/cmdx/rspec/result_matchers/have_context.rb +86 -0
  102. data/lib/cmdx/rspec/result_matchers/have_empty_metadata.rb +54 -0
  103. data/lib/cmdx/rspec/result_matchers/have_good_outcome.rb +52 -0
  104. data/lib/cmdx/rspec/result_matchers/have_metadata.rb +114 -0
  105. data/lib/cmdx/rspec/result_matchers/have_preserved_context.rb +66 -0
  106. data/lib/cmdx/rspec/result_matchers/have_received_thrown_failure.rb +64 -0
  107. data/lib/cmdx/rspec/result_matchers/have_runtime.rb +78 -0
  108. data/lib/cmdx/rspec/result_matchers/have_thrown_failure.rb +76 -0
  109. data/lib/cmdx/rspec/task_matchers/be_well_formed_task.rb +62 -0
  110. data/lib/cmdx/rspec/task_matchers/have_callback.rb +85 -0
  111. data/lib/cmdx/rspec/task_matchers/have_cmd_setting.rb +68 -0
  112. data/lib/cmdx/rspec/task_matchers/have_executed_callbacks.rb +92 -0
  113. data/lib/cmdx/rspec/task_matchers/have_middleware.rb +46 -0
  114. data/lib/cmdx/rspec/task_matchers/have_parameter.rb +181 -0
  115. data/lib/cmdx/task.rb +213 -425
  116. data/lib/cmdx/task_deprecator.rb +55 -0
  117. data/lib/cmdx/task_processor.rb +245 -0
  118. data/lib/cmdx/task_serializer.rb +22 -70
  119. data/lib/cmdx/utils/ansi_color.rb +13 -89
  120. data/lib/cmdx/utils/log_timestamp.rb +13 -42
  121. data/lib/cmdx/utils/monotonic_runtime.rb +13 -63
  122. data/lib/cmdx/utils/name_affix.rb +21 -71
  123. data/lib/cmdx/validator.rb +48 -0
  124. data/lib/cmdx/validator_registry.rb +86 -0
  125. data/lib/cmdx/validators/exclusion.rb +55 -94
  126. data/lib/cmdx/validators/format.rb +31 -85
  127. data/lib/cmdx/validators/inclusion.rb +65 -110
  128. data/lib/cmdx/validators/length.rb +117 -133
  129. data/lib/cmdx/validators/numeric.rb +123 -130
  130. data/lib/cmdx/validators/presence.rb +38 -79
  131. data/lib/cmdx/version.rb +1 -7
  132. data/lib/cmdx/workflow.rb +46 -339
  133. data/lib/cmdx.rb +1 -1
  134. data/lib/generators/cmdx/install_generator.rb +14 -31
  135. data/lib/generators/cmdx/task_generator.rb +39 -55
  136. data/lib/generators/cmdx/templates/install.rb +61 -11
  137. data/lib/generators/cmdx/workflow_generator.rb +41 -66
  138. data/lib/locales/ar.yml +35 -0
  139. data/lib/locales/cs.yml +35 -0
  140. data/lib/locales/da.yml +35 -0
  141. data/lib/locales/de.yml +35 -0
  142. data/lib/locales/el.yml +35 -0
  143. data/lib/locales/en.yml +19 -20
  144. data/lib/locales/es.yml +19 -20
  145. data/lib/locales/fi.yml +35 -0
  146. data/lib/locales/fr.yml +35 -0
  147. data/lib/locales/he.yml +35 -0
  148. data/lib/locales/hi.yml +35 -0
  149. data/lib/locales/it.yml +35 -0
  150. data/lib/locales/ja.yml +35 -0
  151. data/lib/locales/ko.yml +35 -0
  152. data/lib/locales/nl.yml +35 -0
  153. data/lib/locales/no.yml +35 -0
  154. data/lib/locales/pl.yml +35 -0
  155. data/lib/locales/pt.yml +35 -0
  156. data/lib/locales/ru.yml +35 -0
  157. data/lib/locales/sv.yml +35 -0
  158. data/lib/locales/th.yml +35 -0
  159. data/lib/locales/tr.yml +35 -0
  160. data/lib/locales/vi.yml +35 -0
  161. data/lib/locales/zh.yml +35 -0
  162. metadata +57 -8
  163. data/lib/cmdx/parameter_validator.rb +0 -81
  164. data/lib/cmdx/parameter_value.rb +0 -244
  165. data/lib/cmdx/parameters_inspector.rb +0 -72
  166. data/lib/cmdx/parameters_serializer.rb +0 -115
  167. data/lib/cmdx/rspec/result_matchers.rb +0 -917
  168. data/lib/cmdx/rspec/task_matchers.rb +0 -570
  169. data/lib/cmdx/validators/custom.rb +0 -102
data/lib/cmdx/fault.rb CHANGED
@@ -1,109 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- ##
5
- # Fault serves as the base exception class for task execution interruptions in CMDx.
6
- # It provides a structured way to halt task execution with specific reasons and metadata,
7
- # while offering advanced exception matching capabilities for precise error handling.
4
+ # Exception class for task execution faults with result context.
8
5
  #
9
- # Faults are automatically raised when using the bang `call!` method on tasks that
10
- # encounter `skip!` or `fail!` conditions. They carry the full context of the
11
- # interrupted task, including the result object with its metadata and execution state.
12
- #
13
- #
14
- # ## Fault Types
15
- #
16
- # CMDx provides two primary fault types:
17
- # - **CMDx::Skipped**: Raised when a task is skipped via `skip!`
18
- # - **CMDx::Failed**: Raised when a task fails via `fail!`
19
- #
20
- # ## Exception Handling Patterns
21
- #
22
- # Faults support multiple rescue patterns for flexible error handling:
23
- # - Standard rescue by fault type
24
- # - Task-specific matching with `for?`
25
- # - Custom matching with `matches?`
26
- #
27
- # @example Basic fault handling
28
- # begin
29
- # ProcessOrderTask.call!(order_id: 123)
30
- # rescue CMDx::Skipped => e
31
- # logger.info "Task skipped: #{e.message}"
32
- # e.result.metadata[:reason] #=> "Order already processed"
33
- # rescue CMDx::Failed => e
34
- # logger.error "Task failed: #{e.message}"
35
- # e.task.class.name #=> "ProcessOrderTask"
36
- # end
37
- #
38
- # @example Task-specific fault handling
39
- # begin
40
- # OrderProcessingWorkflow.call!(orders: orders)
41
- # rescue CMDx::Fault.for?(ProcessOrderTask, ValidateOrderTask) => e
42
- # # Handle faults only from specific task types
43
- # retry_order_processing(e.context.order_id)
44
- # end
45
- #
46
- # @example Advanced fault matching
47
- # begin
48
- # ProcessOrderTask.call!(order_id: 123)
49
- # rescue CMDx::Fault.matches? { |f| f.result.metadata[:code] == "INVENTORY_DEPLETED" } => e
50
- # # Handle specific fault conditions
51
- # schedule_restock_notification(e.context.order)
52
- # end
53
- #
54
- # @example Accessing fault context
55
- # begin
56
- # ProcessOrderTask.call!(order_id: 123)
57
- # rescue CMDx::Fault => e
58
- # e.result.status #=> "failed" or "skipped"
59
- # e.result.metadata[:reason] #=> "Insufficient inventory"
60
- # e.task.id #=> Task instance UUID
61
- # e.context.order_id #=> 123
62
- # e.chain.id #=> Chain instance UUID
63
- # end
64
- #
65
- # @example Fault propagation with throw!
66
- # class ProcessOrderTask < CMDx::Task
67
- # def call
68
- # validation_result = ValidateOrderTask.call(context)
69
- # throw!(validation_result) if validation_result.failed?
70
- #
71
- # # This will raise CMDx::Failed with validation task's metadata
72
- # end
73
- # end
74
- #
75
- # @see Result Result object containing fault details
76
- # @see Task Task execution methods (call vs call!)
77
- # @see CMDx::Skipped Specific fault type for skipped tasks
78
- # @see CMDx::Failed Specific fault type for failed tasks
79
- # @since 1.0.0
6
+ # Fault provides a specialized exception that carries task execution context
7
+ # including the failed result, task instance, and execution chain. It serves
8
+ # as the base class for specific fault types and provides factory methods
9
+ # for creating fault instances and conditional fault matchers.
80
10
  class Fault < Error
81
11
 
82
- __cmdx_attr_delegator :task, :chain, :context,
83
- to: :result
12
+ cmdx_attr_delegator :task, :chain, :context,
13
+ to: :result
84
14
 
85
- ##
86
- # @!attribute [r] result
87
- # @return [Result] the result object that caused this fault
15
+ # @return [CMDx::Result] the result object that caused this fault
88
16
  attr_reader :result
89
17
 
90
- ##
91
- # Initializes a new Fault with the given result object.
92
- # The fault message is derived from the result's metadata reason or falls back
93
- # to a localized default message.
18
+ # Creates a new fault instance with the given result context.
19
+ #
20
+ # The fault message is derived from the result's metadata reason or falls
21
+ # back to a default internationalized message if no reason is provided.
94
22
  #
95
- # @param result [Result] the result object containing fault details
23
+ # @param result [CMDx::Result] the failed task result that caused this fault
96
24
  #
97
- # @example Creating a fault from a failed result
98
- # result = ProcessOrderTask.call(order_id: 999) # Non-existent order
99
- # fault = Fault.new(result)
100
- # fault.message #=> "Order not found"
101
- # fault.result #=> <Result status: "failed">
25
+ # @return [Fault] the newly created fault instance
102
26
  #
103
- # @example Fault with I18n message
104
- # # With custom locale configuration
105
- # fault = Fault.new(result)
106
- # fault.message #=> Localized message from I18n
27
+ # @example Create a fault from a failed result
28
+ # result = CMDx::Result.new(task)
29
+ # result.fail!(reason: "Database connection failed")
30
+ # fault = CMDx::Fault.new(result)
31
+ # fault.message # => "Database connection failed"
107
32
  def initialize(result)
108
33
  @result = result
109
34
  super(result.metadata[:reason] || I18n.t("cmdx.faults.unspecified", default: "no reason given"))
@@ -111,52 +36,43 @@ module CMDx
111
36
 
112
37
  class << self
113
38
 
114
- ##
115
- # Builds a specific fault type based on the result's status.
116
- # Dynamically creates the appropriate fault subclass (Skipped, Failed, etc.)
117
- # based on the result's current status.
39
+ # Builds a fault instance based on the result's status.
40
+ #
41
+ # Creates a specific fault subclass by capitalizing the result status
42
+ # and looking up the corresponding fault class constant. This allows
43
+ # for status-specific fault types like Failed, Skipped, etc.
44
+ #
45
+ # @param result [CMDx::Result] the failed task result
118
46
  #
119
- # @param result [Result] the result object to build a fault from
120
47
  # @return [Fault] a fault instance of the appropriate subclass
121
48
  #
122
- # @example Building a skipped fault
123
- # result = MyTask.call(param: "value")
124
- # result.skip!(reason: "Not needed")
125
- # fault = Fault.build(result)
126
- # fault.class #=> CMDx::Skipped
49
+ # @raise [NameError] if no fault class exists for the result status
127
50
  #
128
- # @example Building a failed fault
129
- # result = MyTask.call(param: "invalid")
130
- # result.fail!(reason: "Validation error")
131
- # fault = Fault.build(result)
132
- # fault.class #=> CMDx::Failed
51
+ # @example Build a fault for a failed result
52
+ # result = CMDx::Result.new(task)
53
+ # result.fail!
54
+ # fault = CMDx::Fault.build(result)
55
+ # fault.class # => CMDx::Failed
133
56
  def build(result)
134
57
  fault = CMDx.const_get(result.status.capitalize)
135
58
  fault.new(result)
136
59
  end
137
60
 
138
- ##
139
- # Creates a fault matcher that only matches faults from specific task classes.
140
- # This enables precise exception handling based on the task type that caused the fault.
61
+ # Creates a fault matcher that matches faults from specific task classes.
62
+ #
63
+ # Returns a temporary fault class that can be used in rescue clauses
64
+ # to catch faults only from the specified task types. The matcher uses
65
+ # the === operator to check if the fault's task is an instance of any
66
+ # of the given task classes.
141
67
  #
142
68
  # @param tasks [Array<Class>] task classes to match against
143
- # @return [Class] a temporary fault class with custom matching logic
144
69
  #
145
- # @example Matching specific task types
146
- # begin
147
- # OrderWorkflow.call!(orders: orders)
148
- # rescue CMDx::Fault.for?(ProcessOrderTask, ValidateOrderTask) => e
149
- # # Only handle faults from these specific task types
150
- # handle_order_processing_error(e)
151
- # end
70
+ # @return [Class] a temporary fault class that matches the specified tasks
152
71
  #
153
- # @example Multiple task matching
154
- # payment_tasks = [ProcessPaymentTask, ValidateCardTask, ChargeCardTask]
155
- # begin
156
- # PaymentWorkflow.call!(payment_data: data)
157
- # rescue CMDx::Failed.for?(*payment_tasks) => e
158
- # # Handle failures from any payment-related task
159
- # process_payment_failure(e)
72
+ # @example Match faults from specific task classes
73
+ # rescue CMDx::Fault.for?(UserCreateTask, UserUpdateTask) => fault
74
+ # # Handle faults only from user-related tasks
75
+ # logger.error "User operation failed: #{fault.message}"
160
76
  # end
161
77
  def for?(*tasks)
162
78
  temp_fault = Class.new(self) do
@@ -168,41 +84,22 @@ module CMDx
168
84
  temp_fault.tap { |c| c.instance_variable_set(:@tasks, tasks) }
169
85
  end
170
86
 
171
- ##
172
- # Creates a fault matcher with custom matching logic via a block.
173
- # This enables sophisticated fault matching based on any aspect of the fault,
174
- # including result metadata, task state, or context values.
87
+ # Creates a fault matcher that matches faults based on a custom condition.
175
88
  #
176
- # @param block [Proc] block that receives the fault and returns true/false for matching
177
- # @return [Class] a temporary fault class with custom matching logic
178
- # @raise [ArgumentError] if no block is provided
89
+ # Returns a temporary fault class that can be used in rescue clauses
90
+ # to catch faults that satisfy the given block condition. The matcher
91
+ # uses the === operator to evaluate the block against the fault instance.
179
92
  #
180
- # @example Matching by error code
181
- # begin
182
- # ProcessOrderTask.call!(order_id: 123)
183
- # rescue CMDx::Fault.matches? { |f| f.result.metadata[:error_code] == "PAYMENT_DECLINED" } => e
184
- # # Handle specific payment errors
185
- # retry_with_different_payment_method(e.context)
186
- # end
93
+ # @param block [Proc] the condition block to evaluate against fault instances
187
94
  #
188
- # @example Matching by context values
189
- # begin
190
- # ProcessOrderTask.call!(order_id: 123)
191
- # rescue CMDx::Fault.matches? { |f| f.context.order_value > 1000 } => e
192
- # # Handle high-value order failures differently
193
- # escalate_to_manager(e)
194
- # end
95
+ # @return [Class] a temporary fault class that matches the block condition
96
+ #
97
+ # @raise [ArgumentError] if no block is provided
195
98
  #
196
- # @example Complex matching logic
197
- # begin
198
- # WorkflowProcessor.call!(items: items)
199
- # rescue CMDx::Fault.matches? { |f|
200
- # f.result.failed? &&
201
- # f.result.metadata[:reason]&.include?("timeout") &&
202
- # f.chain.results.count(&:failed?) < 3
203
- # } => e
204
- # # Retry if it's a timeout with fewer than 3 failures in the chain
205
- # retry_with_longer_timeout(e)
99
+ # @example Match faults based on custom condition
100
+ # rescue CMDx::Fault.matches? { |f| f.task.context.user_id == current_user.id } => fault
101
+ # # Handle faults only for current user's operations
102
+ # notify_user_of_failure(fault)
206
103
  # end
207
104
  def matches?(&block)
208
105
  raise ArgumentError, "block required" unless block_given?
data/lib/cmdx/faults.rb CHANGED
@@ -2,166 +2,55 @@
2
2
 
3
3
  module CMDx
4
4
 
5
- ##
6
- # Skipped is a specific fault type raised when a task execution is intentionally
7
- # skipped via the `skip!` method. This represents a controlled interruption where
8
- # the task determines that execution is not necessary or appropriate under the
9
- # current conditions.
5
+ # Fault raised when a task is intentionally skipped during execution.
10
6
  #
11
- # Skipped faults are typically used for:
12
- # - Conditional logic where certain conditions make execution unnecessary
13
- # - Early returns when prerequisites are not met
14
- # - Business logic that determines the operation is redundant
15
- # - Graceful handling of edge cases that don't constitute errors
16
- #
17
- # @example Basic skip usage
18
- # class ProcessOrderTask < CMDx::Task
19
- # required :order_id, type: :integer
7
+ # This fault occurs when a task determines it should not execute based on
8
+ # its current context or conditions. Skipped tasks are not considered failures
9
+ # but rather intentional bypasses of task execution logic.
20
10
  #
11
+ # @example Task that skips based on conditions
12
+ # class ProcessPaymentTask < CMDx::Task
21
13
  # def call
22
- # context.order = Order.find(order_id)
23
- # skip!(reason: "Order already processed") if context.order.processed?
24
- #
25
- # context.order.process!
14
+ # skip!(reason: "Payment already processed") if payment_exists?
26
15
  # end
27
16
  # end
28
17
  #
29
- # # Non-bang call returns result
30
- # result = ProcessOrderTask.call(order_id: 123)
31
- # result.skipped? #=> true
32
- # result.metadata[:reason] #=> "Order already processed"
18
+ # result = ProcessPaymentTask.call(payment_id: 123)
19
+ # # raises CMDx::Skipped when payment already exists
33
20
  #
34
- # # Bang call raises exception
21
+ # @example Catching skipped faults
35
22
  # begin
36
- # ProcessOrderTask.call!(order_id: 123)
23
+ # MyTask.call!(data: "invalid")
37
24
  # rescue CMDx::Skipped => e
38
- # puts "Skipped: #{e.message}"
39
- # end
40
- #
41
- # @example Conditional skip logic
42
- # class SendNotificationTask < CMDx::Task
43
- # required :user_id, type: :integer
44
- # optional :force, type: :boolean, default: false
45
- #
46
- # def call
47
- # context.user = User.find(user_id)
48
- #
49
- # unless force || context.user.notifications_enabled?
50
- # skip!(reason: "User has notifications disabled")
51
- # end
52
- #
53
- # NotificationService.send(context.user)
54
- # end
25
+ # puts "Task was skipped: #{e.message}"
55
26
  # end
56
- #
57
- # @example Handling skipped tasks in workflows
58
- # begin
59
- # OrderProcessingWorkflow.call!(orders: orders)
60
- # rescue CMDx::Skipped => e
61
- # # Log skipped operations but continue processing
62
- # logger.info "Skipped processing: #{e.message}"
63
- # end
64
- #
65
- # @see Fault Base fault class with advanced matching capabilities
66
- # @see Failed Failed fault type for error conditions
67
- # @see Result#skip! Method for triggering skipped faults
68
- # @since 1.0.0
69
27
  Skipped = Class.new(Fault)
70
28
 
71
- ##
72
- # Failed is a specific fault type raised when a task execution encounters an
73
- # error condition via the `fail!` method. This represents a controlled failure
74
- # where the task explicitly determines that execution cannot continue successfully.
29
+ # Fault raised when a task execution fails due to errors or validation failures.
75
30
  #
76
- # Failed faults are typically used for:
77
- # - Validation errors that prevent successful execution
78
- # - Business rule violations that constitute failures
79
- # - Resource unavailability or constraint violations
80
- # - Explicit error conditions that require attention
81
- #
82
- # @example Basic failure usage
83
- # class ProcessPaymentTask < CMDx::Task
84
- # required :payment_amount, type: :float
85
- # required :payment_method, type: :string
86
- #
87
- # def call
88
- # unless payment_amount > 0
89
- # fail!(reason: "Payment amount must be positive", code: "INVALID_AMOUNT")
90
- # end
31
+ # This fault occurs when a task encounters an error condition, validation failure,
32
+ # or any other condition that prevents successful completion. Failed tasks indicate
33
+ # that the intended operation could not be completed successfully.
91
34
  #
92
- # unless valid_payment_method?
93
- # fail!(reason: "Invalid payment method", code: "INVALID_METHOD")
94
- # end
95
- #
96
- # process_payment
97
- # end
98
- # end
99
- #
100
- # # Non-bang call returns result
101
- # result = ProcessPaymentTask.call(payment_amount: -10, payment_method: "card")
102
- # result.failed? #=> true
103
- # result.metadata[:reason] #=> "Payment amount must be positive"
104
- # result.metadata[:code] #=> "INVALID_AMOUNT"
105
- #
106
- # # Bang call raises exception
107
- # begin
108
- # ProcessPaymentTask.call!(payment_amount: -10, payment_method: "card")
109
- # rescue CMDx::Failed => e
110
- # puts "Failed: #{e.message}"
111
- # puts "Error code: #{e.result.metadata[:code]}"
112
- # end
113
- #
114
- # @example Validation failure with detailed metadata
115
- # class CreateUserTask < CMDx::Task
35
+ # @example Task that fails due to validation
36
+ # class ValidateUserTask < CMDx::Task
116
37
  # required :email, type: :string
117
- # required :password, type: :string
118
38
  #
119
39
  # def call
120
- # if User.exists?(email: email)
121
- # fail!(
122
- # "Email already exists",
123
- # code: "EMAIL_EXISTS",
124
- # field: "email",
125
- # suggested_action: "Use different email or login instead"
126
- # )
127
- # end
128
- #
129
- # context.user = User.create!(email: email, password: password)
40
+ # fail!(reason: "Invalid email format") unless valid_email?
130
41
  # end
131
42
  # end
132
43
  #
133
- # @example Handling specific failure types
44
+ # result = ValidateUserTask.call(email: "invalid-email")
45
+ # # raises CMDx::Failed when email is invalid
46
+ #
47
+ # @example Catching failed faults
134
48
  # begin
135
- # ProcessOrderTask.call!(order_id: 123)
136
- # rescue CMDx::Failed.matches? { |f| f.result.metadata[:code] == "PAYMENT_DECLINED" } => e
137
- # # Handle payment failures specifically
138
- # retry_with_backup_payment_method(e.context)
49
+ # RiskyTask.call!(data: "problematic")
139
50
  # rescue CMDx::Failed => e
140
- # # Handle all other failures
141
- # log_failure_and_notify_support(e)
51
+ # puts "Task failed: #{e.message}"
52
+ # puts "Original task: #{e.task.class.name}"
142
53
  # end
143
- #
144
- # @example Failure propagation in complex workflows
145
- # class OrderFulfillmentTask < CMDx::Task
146
- # def call
147
- # payment_result = ProcessPaymentTask.call(context)
148
- #
149
- # if payment_result.failed?
150
- # fail!(
151
- # "Cannot fulfill order due to payment failure",
152
- # code: "PAYMENT_REQUIRED",
153
- # original_error: payment_result.metadata
154
- # )
155
- # end
156
- #
157
- # fulfill_order
158
- # end
159
- # end
160
- #
161
- # @see Fault Base fault class with advanced matching capabilities
162
- # @see Skipped Skipped fault type for conditional interruptions
163
- # @see Result#fail! Method for triggering failed faults
164
- # @since 1.0.0
165
54
  Failed = Class.new(Fault)
166
55
 
167
56
  end
@@ -1,126 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CMDx
4
- ##
5
- # Immutator provides task finalization by freezing objects to prevent mutation
6
- # after task execution is complete. This ensures task immutability and prevents
7
- # accidental side effects or modifications to completed task instances.
4
+ # Freezes task objects after execution to ensure immutability.
8
5
  #
9
- # The Immutator is automatically called during the task termination phase as part
10
- # of the execution lifecycle. It freezes the task instance, its result, and
11
- # associated objects to maintain data integrity and enforce the single-use pattern
12
- # of CMDx tasks.
13
- #
14
- # ## Freezing Strategy
15
- #
16
- # The Immutator employs a selective freezing strategy:
17
- # 1. **Task Instance**: Always frozen to prevent method calls and modifications
18
- # 2. **Result Object**: Always frozen to preserve execution outcome
19
- # 3. **Context Object**: Frozen only for the first task in a chain (index 0)
20
- # 4. **Chain Object**: Frozen only for the first task in a chain (index 0)
21
- #
22
- # This selective approach allows subsequent tasks in a workflow or chain to continue
23
- # using the shared context and chain objects while ensuring completed tasks remain
24
- # immutable.
25
- #
26
- # ## Test Environment Handling
27
- #
28
- # In test environments (Rails or Rack), freezing is automatically disabled to
29
- # prevent conflicts with test frameworks that may need to stub or mock frozen
30
- # objects. This ensures smooth testing without compromising the immutability
31
- # guarantees in production environments.
32
- #
33
- # @example Task execution with automatic freezing
34
- # class ProcessOrderTask < CMDx::Task
35
- # required :order_id, type: :integer
36
- #
37
- # def call
38
- # context.order = Order.find(order_id)
39
- # context.order.process!
40
- # end
41
- # end
42
- #
43
- # result = ProcessOrderTask.call(order_id: 123)
44
- # result.frozen? #=> true
45
- # result.task.frozen? #=> true
46
- # result.context.frozen? #=> true (if first task in chain)
47
- #
48
- # @example Attempting to modify frozen task (will raise error)
49
- # result = ProcessOrderTask.call(order_id: 123)
50
- # result.context.new_field = "value" #=> FrozenError
51
- # result.task.call #=> FrozenError
52
- #
53
- # @example Workflow execution with selective freezing
54
- # class OrderWorkflow < CMDx::Workflow
55
- # def call
56
- # ProcessOrderTask.call(context)
57
- # SendEmailTask.call(context)
58
- # end
59
- # end
60
- #
61
- # result = OrderWorkflow.call(order_id: 123)
62
- # # First task freezes context and chain
63
- # # Second task can still use unfrozen context for execution
64
- # # But both task instances are individually frozen
65
- #
66
- # @example Test environment behavior
67
- # # In test environment (SKIP_CMDX_FREEZING=1)
68
- # result = ProcessOrderTask.call(order_id: 123)
69
- # result.frozen? #=> false (freezing disabled)
70
- # result.task.frozen? #=> false (allows stubbing/mocking)
71
- #
72
- # @see Task Task execution lifecycle
73
- # @see Result Result object that gets frozen
74
- # @see Context Context object that may get frozen
75
- # @see Chain Chain object that may get frozen
76
- # @since 1.0.0
6
+ # This module provides the final step in the task lifecycle by freezing
7
+ # task instances and their associated objects to prevent further modification.
8
+ # The freezing behavior can be controlled via environment variables and
9
+ # is conditionally applied based on the task's position in the execution chain.
77
10
  module Immutator
78
11
 
79
12
  module_function
80
13
 
81
- ##
82
- # Freezes task-related objects to ensure immutability after execution.
83
- # This method is called automatically during task termination and implements
84
- # a selective freezing strategy based on task position within a chain.
85
- #
86
- # The freezing process:
87
- # 1. Checks if running in test environment and skips freezing if so
88
- # 2. Always freezes the task instance and its result
89
- # 3. Freezes context and chain only for the first task (index 0) in a chain
90
- #
91
- # This selective approach ensures that:
92
- # - Completed tasks cannot be modified or re-executed
93
- # - Results remain immutable and trustworthy
94
- # - Shared objects (context/chain) remain available for subsequent tasks
95
- # - Test environments can continue to function with mocking/stubbing
14
+ # Freezes task objects after execution to make them immutable.
96
15
  #
97
- # @param task [Task] the task instance to freeze along with its associated objects
98
- # @return [void]
16
+ # Always freezes the task and its result. For the first task in a chain
17
+ # (index 0), also freezes the context and chain, then clears the chain.
18
+ # Freezing can be skipped entirely by setting the SKIP_CMDX_FREEZING
19
+ # environment variable to a truthy value.
99
20
  #
100
- # @example First task in chain (freezes everything)
101
- # task = ProcessOrderTask.call(order_id: 123)
102
- # # task.result.index == 0
103
- # Immutator.call(task)
104
- # # Freezes: task, result, context, chain
21
+ # @param task [Task] the task instance to freeze after execution
105
22
  #
106
- # @example Subsequent task in chain (selective freezing)
107
- # # After first task has run
108
- # task = SendEmailTask.call(context)
109
- # # task.result.index == 1
110
- # Immutator.call(task)
111
- # # Freezes: task, result (context and chain remain unfrozen)
23
+ # @return [nil] always returns nil
112
24
  #
113
- # @example Test environment (no freezing)
114
- # ENV["RAILS_ENV"] = "test"
115
- # task = ProcessOrderTask.call(order_id: 123)
116
- # Immutator.call(task)
117
- # # No objects are frozen, allows test stubbing
25
+ # @raise [StandardError] if any freeze operation fails
118
26
  #
119
- # @note This method is automatically called by the task execution framework
120
- # and should not typically be called directly by user code.
27
+ # @example Freeze a completed task
28
+ # task = MyTask.new(user_id: 123)
29
+ # task.process
30
+ # CMDx::Immutator.call(task)
31
+ # task.frozen? # => true
121
32
  #
122
- # @note Freezing is skipped entirely in test environments to prevent conflicts
123
- # with test frameworks that need to stub or mock objects.
33
+ # @example Skip freezing for testing
34
+ # ENV["SKIP_CMDX_FREEZING"] = "1"
35
+ # CMDx::Immutator.call(task)
36
+ # task.frozen? # => false
124
37
  def call(task)
125
38
  # Stubbing on frozen objects is not allowed
126
39
  skip_freezing = ENV.fetch("SKIP_CMDX_FREEZING", false)