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.
- checksums.yaml +4 -4
- data/.cursor/prompts/rspec.md +20 -0
- data/.cursor/prompts/yardoc.md +8 -0
- data/.rubocop.yml +5 -0
- data/CHANGELOG.md +101 -49
- data/README.md +2 -1
- data/docs/ai_prompts.md +10 -0
- data/docs/basics/call.md +11 -2
- data/docs/basics/chain.md +10 -1
- data/docs/basics/context.md +9 -0
- data/docs/basics/setup.md +9 -0
- data/docs/callbacks.md +14 -37
- data/docs/configuration.md +68 -27
- data/docs/getting_started.md +11 -0
- data/docs/internationalization.md +148 -0
- data/docs/interruptions/exceptions.md +10 -1
- data/docs/interruptions/faults.md +11 -2
- data/docs/interruptions/halt.md +9 -0
- data/docs/logging.md +14 -4
- data/docs/middlewares.md +53 -43
- data/docs/outcomes/result.md +9 -0
- data/docs/outcomes/states.md +9 -0
- data/docs/outcomes/statuses.md +9 -0
- data/docs/parameters/coercions.md +58 -38
- data/docs/parameters/defaults.md +10 -1
- data/docs/parameters/definitions.md +9 -0
- data/docs/parameters/namespacing.md +9 -0
- data/docs/parameters/validations.md +8 -67
- data/docs/testing.md +22 -13
- data/docs/tips_and_tricks.md +9 -0
- data/docs/workflows.md +14 -4
- data/lib/cmdx/.DS_Store +0 -0
- data/lib/cmdx/callback.rb +36 -56
- data/lib/cmdx/callback_registry.rb +82 -73
- data/lib/cmdx/chain.rb +65 -122
- data/lib/cmdx/chain_inspector.rb +22 -115
- data/lib/cmdx/chain_serializer.rb +17 -148
- data/lib/cmdx/coercion.rb +49 -0
- data/lib/cmdx/coercion_registry.rb +94 -0
- data/lib/cmdx/coercions/array.rb +18 -36
- data/lib/cmdx/coercions/big_decimal.rb +21 -33
- data/lib/cmdx/coercions/boolean.rb +21 -40
- data/lib/cmdx/coercions/complex.rb +18 -31
- data/lib/cmdx/coercions/date.rb +20 -39
- data/lib/cmdx/coercions/date_time.rb +22 -39
- data/lib/cmdx/coercions/float.rb +19 -32
- data/lib/cmdx/coercions/hash.rb +22 -41
- data/lib/cmdx/coercions/integer.rb +20 -33
- data/lib/cmdx/coercions/rational.rb +20 -32
- data/lib/cmdx/coercions/string.rb +23 -31
- data/lib/cmdx/coercions/time.rb +24 -40
- data/lib/cmdx/coercions/virtual.rb +14 -31
- data/lib/cmdx/configuration.rb +57 -171
- data/lib/cmdx/context.rb +22 -165
- data/lib/cmdx/core_ext/hash.rb +42 -67
- data/lib/cmdx/core_ext/module.rb +35 -79
- data/lib/cmdx/core_ext/object.rb +63 -98
- data/lib/cmdx/correlator.rb +40 -156
- data/lib/cmdx/error.rb +37 -202
- data/lib/cmdx/errors.rb +165 -202
- data/lib/cmdx/fault.rb +55 -158
- data/lib/cmdx/faults.rb +26 -137
- data/lib/cmdx/immutator.rb +22 -109
- data/lib/cmdx/lazy_struct.rb +103 -187
- data/lib/cmdx/log_formatters/json.rb +14 -40
- data/lib/cmdx/log_formatters/key_value.rb +14 -40
- data/lib/cmdx/log_formatters/line.rb +14 -48
- data/lib/cmdx/log_formatters/logstash.rb +14 -57
- data/lib/cmdx/log_formatters/pretty_json.rb +14 -50
- data/lib/cmdx/log_formatters/pretty_key_value.rb +13 -46
- data/lib/cmdx/log_formatters/pretty_line.rb +16 -54
- data/lib/cmdx/log_formatters/raw.rb +19 -49
- data/lib/cmdx/logger.rb +20 -82
- data/lib/cmdx/logger_ansi.rb +18 -75
- data/lib/cmdx/logger_serializer.rb +24 -114
- data/lib/cmdx/middleware.rb +38 -60
- data/lib/cmdx/middleware_registry.rb +81 -77
- data/lib/cmdx/middlewares/correlate.rb +41 -226
- data/lib/cmdx/middlewares/timeout.rb +46 -185
- data/lib/cmdx/parameter.rb +120 -198
- data/lib/cmdx/parameter_evaluator.rb +231 -0
- data/lib/cmdx/parameter_inspector.rb +25 -56
- data/lib/cmdx/parameter_registry.rb +59 -84
- data/lib/cmdx/parameter_serializer.rb +23 -74
- data/lib/cmdx/railtie.rb +24 -107
- data/lib/cmdx/result.rb +254 -260
- data/lib/cmdx/result_ansi.rb +19 -85
- data/lib/cmdx/result_inspector.rb +27 -68
- data/lib/cmdx/result_logger.rb +18 -81
- data/lib/cmdx/result_serializer.rb +28 -132
- data/lib/cmdx/rspec/matchers.rb +28 -0
- data/lib/cmdx/rspec/result_matchers/be_executed.rb +42 -0
- data/lib/cmdx/rspec/result_matchers/be_failed_task.rb +94 -0
- data/lib/cmdx/rspec/result_matchers/be_skipped_task.rb +94 -0
- data/lib/cmdx/rspec/result_matchers/be_state_matchers.rb +59 -0
- data/lib/cmdx/rspec/result_matchers/be_status_matchers.rb +57 -0
- data/lib/cmdx/rspec/result_matchers/be_successful_task.rb +87 -0
- data/lib/cmdx/rspec/result_matchers/have_bad_outcome.rb +51 -0
- data/lib/cmdx/rspec/result_matchers/have_caused_failure.rb +58 -0
- data/lib/cmdx/rspec/result_matchers/have_chain_index.rb +59 -0
- data/lib/cmdx/rspec/result_matchers/have_context.rb +86 -0
- data/lib/cmdx/rspec/result_matchers/have_empty_metadata.rb +54 -0
- data/lib/cmdx/rspec/result_matchers/have_good_outcome.rb +52 -0
- data/lib/cmdx/rspec/result_matchers/have_metadata.rb +114 -0
- data/lib/cmdx/rspec/result_matchers/have_preserved_context.rb +66 -0
- data/lib/cmdx/rspec/result_matchers/have_received_thrown_failure.rb +64 -0
- data/lib/cmdx/rspec/result_matchers/have_runtime.rb +78 -0
- data/lib/cmdx/rspec/result_matchers/have_thrown_failure.rb +76 -0
- data/lib/cmdx/rspec/task_matchers/be_well_formed_task.rb +62 -0
- data/lib/cmdx/rspec/task_matchers/have_callback.rb +85 -0
- data/lib/cmdx/rspec/task_matchers/have_cmd_setting.rb +68 -0
- data/lib/cmdx/rspec/task_matchers/have_executed_callbacks.rb +92 -0
- data/lib/cmdx/rspec/task_matchers/have_middleware.rb +46 -0
- data/lib/cmdx/rspec/task_matchers/have_parameter.rb +181 -0
- data/lib/cmdx/task.rb +213 -425
- data/lib/cmdx/task_deprecator.rb +55 -0
- data/lib/cmdx/task_processor.rb +245 -0
- data/lib/cmdx/task_serializer.rb +22 -70
- data/lib/cmdx/utils/ansi_color.rb +13 -89
- data/lib/cmdx/utils/log_timestamp.rb +13 -42
- data/lib/cmdx/utils/monotonic_runtime.rb +13 -63
- data/lib/cmdx/utils/name_affix.rb +21 -71
- data/lib/cmdx/validator.rb +48 -0
- data/lib/cmdx/validator_registry.rb +86 -0
- data/lib/cmdx/validators/exclusion.rb +55 -94
- data/lib/cmdx/validators/format.rb +31 -85
- data/lib/cmdx/validators/inclusion.rb +65 -110
- data/lib/cmdx/validators/length.rb +117 -133
- data/lib/cmdx/validators/numeric.rb +123 -130
- data/lib/cmdx/validators/presence.rb +38 -79
- data/lib/cmdx/version.rb +1 -7
- data/lib/cmdx/workflow.rb +46 -339
- data/lib/cmdx.rb +1 -1
- data/lib/generators/cmdx/install_generator.rb +14 -31
- data/lib/generators/cmdx/task_generator.rb +39 -55
- data/lib/generators/cmdx/templates/install.rb +61 -11
- data/lib/generators/cmdx/workflow_generator.rb +41 -66
- data/lib/locales/ar.yml +35 -0
- data/lib/locales/cs.yml +35 -0
- data/lib/locales/da.yml +35 -0
- data/lib/locales/de.yml +35 -0
- data/lib/locales/el.yml +35 -0
- data/lib/locales/en.yml +19 -20
- data/lib/locales/es.yml +19 -20
- data/lib/locales/fi.yml +35 -0
- data/lib/locales/fr.yml +35 -0
- data/lib/locales/he.yml +35 -0
- data/lib/locales/hi.yml +35 -0
- data/lib/locales/it.yml +35 -0
- data/lib/locales/ja.yml +35 -0
- data/lib/locales/ko.yml +35 -0
- data/lib/locales/nl.yml +35 -0
- data/lib/locales/no.yml +35 -0
- data/lib/locales/pl.yml +35 -0
- data/lib/locales/pt.yml +35 -0
- data/lib/locales/ru.yml +35 -0
- data/lib/locales/sv.yml +35 -0
- data/lib/locales/th.yml +35 -0
- data/lib/locales/tr.yml +35 -0
- data/lib/locales/vi.yml +35 -0
- data/lib/locales/zh.yml +35 -0
- metadata +57 -8
- data/lib/cmdx/parameter_validator.rb +0 -81
- data/lib/cmdx/parameter_value.rb +0 -244
- data/lib/cmdx/parameters_inspector.rb +0 -72
- data/lib/cmdx/parameters_serializer.rb +0 -115
- data/lib/cmdx/rspec/result_matchers.rb +0 -917
- data/lib/cmdx/rspec/task_matchers.rb +0 -570
- 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
|
-
#
|
10
|
-
#
|
11
|
-
#
|
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
|
-
|
83
|
-
|
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
|
-
#
|
92
|
-
# The fault message is derived from the result's metadata reason or falls
|
93
|
-
# to a
|
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
|
23
|
+
# @param result [CMDx::Result] the failed task result that caused this fault
|
96
24
|
#
|
97
|
-
# @
|
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
|
104
|
-
#
|
105
|
-
#
|
106
|
-
# fault
|
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
|
-
#
|
116
|
-
#
|
117
|
-
#
|
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
|
-
# @
|
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
|
129
|
-
# result =
|
130
|
-
# result.fail!
|
131
|
-
# fault = Fault.build(result)
|
132
|
-
# fault.class
|
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
|
-
#
|
140
|
-
#
|
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
|
-
# @
|
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
|
154
|
-
#
|
155
|
-
#
|
156
|
-
#
|
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
|
-
#
|
177
|
-
#
|
178
|
-
#
|
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
|
-
# @
|
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
|
-
# @
|
189
|
-
#
|
190
|
-
#
|
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
|
197
|
-
#
|
198
|
-
#
|
199
|
-
#
|
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
|
-
#
|
12
|
-
#
|
13
|
-
#
|
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
|
-
#
|
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
|
-
#
|
30
|
-
#
|
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
|
-
#
|
21
|
+
# @example Catching skipped faults
|
35
22
|
# begin
|
36
|
-
#
|
23
|
+
# MyTask.call!(data: "invalid")
|
37
24
|
# rescue CMDx::Skipped => e
|
38
|
-
# puts "
|
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
|
-
#
|
77
|
-
#
|
78
|
-
#
|
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
|
-
#
|
93
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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
|
-
#
|
141
|
-
#
|
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
|
data/lib/cmdx/immutator.rb
CHANGED
@@ -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
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
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
|
-
#
|
98
|
-
#
|
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
|
-
# @
|
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
|
-
# @
|
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
|
-
# @
|
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
|
-
# @
|
120
|
-
#
|
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
|
-
# @
|
123
|
-
#
|
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)
|