cmdx 1.1.1 → 1.5.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/.DS_Store +0 -0
- data/.cursor/prompts/docs.md +4 -1
- data/.cursor/prompts/llms.md +20 -0
- data/.cursor/prompts/rspec.md +4 -1
- data/.cursor/prompts/yardoc.md +3 -2
- data/.cursor/rules/cursor-instructions.mdc +56 -1
- data/.irbrc +6 -0
- data/.rubocop.yml +29 -18
- data/.ruby-version +1 -1
- data/CHANGELOG.md +6 -128
- data/LLM.md +3317 -0
- data/README.md +68 -44
- data/docs/attributes/coercions.md +162 -0
- data/docs/attributes/defaults.md +90 -0
- data/docs/attributes/definitions.md +281 -0
- data/docs/attributes/naming.md +78 -0
- data/docs/attributes/validations.md +309 -0
- data/docs/basics/chain.md +56 -249
- data/docs/basics/context.md +56 -289
- data/docs/basics/execution.md +114 -0
- data/docs/basics/setup.md +37 -334
- data/docs/callbacks.md +89 -467
- data/docs/deprecation.md +91 -174
- data/docs/getting_started.md +212 -202
- data/docs/internationalization.md +11 -647
- data/docs/interruptions/exceptions.md +23 -198
- data/docs/interruptions/faults.md +71 -151
- data/docs/interruptions/halt.md +109 -186
- data/docs/logging.md +44 -256
- data/docs/middlewares.md +113 -426
- data/docs/outcomes/result.md +81 -228
- data/docs/outcomes/states.md +33 -221
- data/docs/outcomes/statuses.md +21 -311
- data/docs/tips_and_tricks.md +120 -70
- data/docs/workflows.md +99 -283
- data/lib/cmdx/.DS_Store +0 -0
- data/lib/cmdx/attribute.rb +229 -0
- data/lib/cmdx/attribute_registry.rb +94 -0
- data/lib/cmdx/attribute_value.rb +193 -0
- data/lib/cmdx/callback_registry.rb +69 -77
- data/lib/cmdx/chain.rb +56 -73
- data/lib/cmdx/coercion_registry.rb +52 -68
- data/lib/cmdx/coercions/array.rb +19 -18
- data/lib/cmdx/coercions/big_decimal.rb +20 -24
- data/lib/cmdx/coercions/boolean.rb +26 -25
- data/lib/cmdx/coercions/complex.rb +21 -22
- data/lib/cmdx/coercions/date.rb +25 -23
- data/lib/cmdx/coercions/date_time.rb +24 -25
- data/lib/cmdx/coercions/float.rb +25 -22
- data/lib/cmdx/coercions/hash.rb +31 -32
- data/lib/cmdx/coercions/integer.rb +30 -24
- data/lib/cmdx/coercions/rational.rb +29 -24
- data/lib/cmdx/coercions/string.rb +19 -22
- data/lib/cmdx/coercions/symbol.rb +37 -0
- data/lib/cmdx/coercions/time.rb +26 -25
- data/lib/cmdx/configuration.rb +49 -108
- data/lib/cmdx/context.rb +222 -44
- data/lib/cmdx/deprecator.rb +61 -0
- data/lib/cmdx/errors.rb +42 -252
- data/lib/cmdx/exceptions.rb +39 -0
- data/lib/cmdx/faults.rb +78 -39
- data/lib/cmdx/freezer.rb +51 -0
- data/lib/cmdx/identifier.rb +30 -0
- data/lib/cmdx/locale.rb +52 -0
- data/lib/cmdx/log_formatters/json.rb +21 -22
- data/lib/cmdx/log_formatters/key_value.rb +20 -22
- data/lib/cmdx/log_formatters/line.rb +15 -22
- data/lib/cmdx/log_formatters/logstash.rb +22 -23
- data/lib/cmdx/log_formatters/raw.rb +16 -22
- data/lib/cmdx/middleware_registry.rb +70 -74
- data/lib/cmdx/middlewares/correlate.rb +90 -54
- data/lib/cmdx/middlewares/runtime.rb +58 -0
- data/lib/cmdx/middlewares/timeout.rb +48 -68
- data/lib/cmdx/railtie.rb +12 -45
- data/lib/cmdx/result.rb +229 -314
- data/lib/cmdx/task.rb +194 -366
- data/lib/cmdx/utils/call.rb +49 -0
- data/lib/cmdx/utils/condition.rb +71 -0
- data/lib/cmdx/utils/format.rb +61 -0
- data/lib/cmdx/validator_registry.rb +63 -72
- data/lib/cmdx/validators/exclusion.rb +38 -67
- data/lib/cmdx/validators/format.rb +48 -49
- data/lib/cmdx/validators/inclusion.rb +43 -74
- data/lib/cmdx/validators/length.rb +91 -154
- data/lib/cmdx/validators/numeric.rb +87 -162
- data/lib/cmdx/validators/presence.rb +37 -50
- data/lib/cmdx/version.rb +1 -1
- data/lib/cmdx/worker.rb +178 -0
- data/lib/cmdx/workflow.rb +85 -81
- data/lib/cmdx.rb +19 -13
- data/lib/generators/cmdx/install_generator.rb +14 -13
- data/lib/generators/cmdx/task_generator.rb +25 -50
- data/lib/generators/cmdx/templates/install.rb +11 -46
- data/lib/generators/cmdx/templates/task.rb.tt +3 -2
- data/lib/locales/en.yml +18 -4
- data/src/cmdx-logo.png +0 -0
- metadata +32 -116
- data/docs/ai_prompts.md +0 -393
- data/docs/basics/call.md +0 -317
- data/docs/configuration.md +0 -344
- data/docs/parameters/coercions.md +0 -396
- data/docs/parameters/defaults.md +0 -335
- data/docs/parameters/definitions.md +0 -446
- data/docs/parameters/namespacing.md +0 -378
- data/docs/parameters/validations.md +0 -405
- data/docs/testing.md +0 -553
- data/lib/cmdx/callback.rb +0 -53
- data/lib/cmdx/chain_inspector.rb +0 -56
- data/lib/cmdx/chain_serializer.rb +0 -63
- data/lib/cmdx/coercion.rb +0 -57
- data/lib/cmdx/coercions/virtual.rb +0 -29
- data/lib/cmdx/core_ext/hash.rb +0 -83
- data/lib/cmdx/core_ext/module.rb +0 -98
- data/lib/cmdx/core_ext/object.rb +0 -125
- data/lib/cmdx/correlator.rb +0 -122
- data/lib/cmdx/error.rb +0 -60
- data/lib/cmdx/fault.rb +0 -140
- data/lib/cmdx/immutator.rb +0 -52
- data/lib/cmdx/lazy_struct.rb +0 -246
- data/lib/cmdx/log_formatters/pretty_json.rb +0 -40
- data/lib/cmdx/log_formatters/pretty_key_value.rb +0 -38
- data/lib/cmdx/log_formatters/pretty_line.rb +0 -41
- data/lib/cmdx/logger.rb +0 -49
- data/lib/cmdx/logger_ansi.rb +0 -68
- data/lib/cmdx/logger_serializer.rb +0 -116
- data/lib/cmdx/middleware.rb +0 -70
- data/lib/cmdx/parameter.rb +0 -312
- data/lib/cmdx/parameter_evaluator.rb +0 -231
- data/lib/cmdx/parameter_inspector.rb +0 -66
- data/lib/cmdx/parameter_registry.rb +0 -106
- data/lib/cmdx/parameter_serializer.rb +0 -59
- data/lib/cmdx/result_ansi.rb +0 -71
- data/lib/cmdx/result_inspector.rb +0 -71
- data/lib/cmdx/result_logger.rb +0 -59
- data/lib/cmdx/result_serializer.rb +0 -104
- data/lib/cmdx/rspec/matchers.rb +0 -28
- data/lib/cmdx/rspec/result_matchers/be_executed.rb +0 -42
- data/lib/cmdx/rspec/result_matchers/be_failed_task.rb +0 -94
- data/lib/cmdx/rspec/result_matchers/be_skipped_task.rb +0 -94
- data/lib/cmdx/rspec/result_matchers/be_state_matchers.rb +0 -59
- data/lib/cmdx/rspec/result_matchers/be_status_matchers.rb +0 -57
- data/lib/cmdx/rspec/result_matchers/be_successful_task.rb +0 -87
- data/lib/cmdx/rspec/result_matchers/have_bad_outcome.rb +0 -51
- data/lib/cmdx/rspec/result_matchers/have_caused_failure.rb +0 -58
- data/lib/cmdx/rspec/result_matchers/have_chain_index.rb +0 -59
- data/lib/cmdx/rspec/result_matchers/have_context.rb +0 -86
- data/lib/cmdx/rspec/result_matchers/have_empty_metadata.rb +0 -54
- data/lib/cmdx/rspec/result_matchers/have_good_outcome.rb +0 -52
- data/lib/cmdx/rspec/result_matchers/have_metadata.rb +0 -114
- data/lib/cmdx/rspec/result_matchers/have_preserved_context.rb +0 -66
- data/lib/cmdx/rspec/result_matchers/have_received_thrown_failure.rb +0 -64
- data/lib/cmdx/rspec/result_matchers/have_runtime.rb +0 -78
- data/lib/cmdx/rspec/result_matchers/have_thrown_failure.rb +0 -76
- data/lib/cmdx/rspec/task_matchers/be_well_formed_task.rb +0 -62
- data/lib/cmdx/rspec/task_matchers/have_callback.rb +0 -85
- data/lib/cmdx/rspec/task_matchers/have_cmd_setting.rb +0 -68
- data/lib/cmdx/rspec/task_matchers/have_executed_callbacks.rb +0 -92
- data/lib/cmdx/rspec/task_matchers/have_middleware.rb +0 -46
- data/lib/cmdx/rspec/task_matchers/have_parameter.rb +0 -181
- data/lib/cmdx/task_deprecator.rb +0 -52
- data/lib/cmdx/task_processor.rb +0 -246
- data/lib/cmdx/task_serializer.rb +0 -57
- data/lib/cmdx/utils/ansi_color.rb +0 -73
- data/lib/cmdx/utils/log_timestamp.rb +0 -36
- data/lib/cmdx/utils/monotonic_runtime.rb +0 -34
- data/lib/cmdx/utils/name_affix.rb +0 -52
- data/lib/cmdx/validator.rb +0 -57
- data/lib/generators/cmdx/templates/workflow.rb.tt +0 -7
- data/lib/generators/cmdx/workflow_generator.rb +0 -84
- data/lib/locales/ar.yml +0 -35
- data/lib/locales/cs.yml +0 -35
- data/lib/locales/da.yml +0 -35
- data/lib/locales/de.yml +0 -35
- data/lib/locales/el.yml +0 -35
- data/lib/locales/es.yml +0 -35
- data/lib/locales/fi.yml +0 -35
- data/lib/locales/fr.yml +0 -35
- data/lib/locales/he.yml +0 -35
- data/lib/locales/hi.yml +0 -35
- data/lib/locales/it.yml +0 -35
- data/lib/locales/ja.yml +0 -35
- data/lib/locales/ko.yml +0 -35
- data/lib/locales/nl.yml +0 -35
- data/lib/locales/no.yml +0 -35
- data/lib/locales/pl.yml +0 -35
- data/lib/locales/pt.yml +0 -35
- data/lib/locales/ru.yml +0 -35
- data/lib/locales/sv.yml +0 -35
- data/lib/locales/th.yml +0 -35
- data/lib/locales/tr.yml +0 -35
- data/lib/locales/vi.yml +0 -35
- data/lib/locales/zh.yml +0 -35
data/LLM.md
ADDED
@@ -0,0 +1,3317 @@
|
|
1
|
+
# CMDx Documentation
|
2
|
+
|
3
|
+
This file contains all the CMDx documentation consolidated from the docs directory.
|
4
|
+
|
5
|
+
---
|
6
|
+
|
7
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/getting_started.md
|
8
|
+
---
|
9
|
+
|
10
|
+
# Getting Started
|
11
|
+
|
12
|
+
CMDx is a Ruby framework for building maintainable, observable business logic through composable command objects. Design robust workflows with automatic attribute validation, structured error handling, comprehensive logging, and intelligent execution flow control.
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
Add CMDx to your Gemfile:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
gem 'cmdx'
|
20
|
+
```
|
21
|
+
|
22
|
+
For Rails applications, generate the configuration:
|
23
|
+
|
24
|
+
```bash
|
25
|
+
rails generate cmdx:install
|
26
|
+
```
|
27
|
+
|
28
|
+
This creates `config/initializers/cmdx.rb` file.
|
29
|
+
|
30
|
+
## Configuration Hierarchy
|
31
|
+
|
32
|
+
CMDx follows a two-tier configuration hierarchy:
|
33
|
+
|
34
|
+
1. **Global Configuration**: Framework-wide defaults
|
35
|
+
2. **Task Settings**: Class-level overrides via `settings`
|
36
|
+
|
37
|
+
> [!IMPORTANT]
|
38
|
+
> Task-level settings take precedence over global configuration. Settings are inherited from superclasses and can be overridden in subclasses.
|
39
|
+
|
40
|
+
## Global Configuration
|
41
|
+
|
42
|
+
Global configuration settings apply to all tasks inherited from `CMDx::Task`.
|
43
|
+
Globally these settings are initialized with sensible defaults.
|
44
|
+
|
45
|
+
### Breakpoints
|
46
|
+
|
47
|
+
Breakpoints control when `execute!` raises faults.
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
CMDx.configure do |config|
|
51
|
+
config.task_breakpoints = "skipped"
|
52
|
+
config.workflow_breakpoints = ["skipped", "failed"]
|
53
|
+
end
|
54
|
+
```
|
55
|
+
|
56
|
+
### Logging
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
CMDx.configure do |config|
|
60
|
+
config.logger = CustomLogger.new($stdout)
|
61
|
+
end
|
62
|
+
```
|
63
|
+
|
64
|
+
### Middlewares
|
65
|
+
|
66
|
+
```ruby
|
67
|
+
CMDx.configure do |config|
|
68
|
+
# Via callable (must respond to `call(task, options)`)
|
69
|
+
config.middlewares.register CMDx::Middlewares::Timeout
|
70
|
+
|
71
|
+
# Via proc or lambda
|
72
|
+
config.middlewares.register proc { |task, options|
|
73
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
74
|
+
result = yield
|
75
|
+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
76
|
+
Rails.logger.debug { "task completed in #{((end_time - start_time) * 1000).round(2)}ms" }
|
77
|
+
result
|
78
|
+
}
|
79
|
+
|
80
|
+
# With options
|
81
|
+
config.middlewares.register AuditTrailMiddleware, service_name: "document_processor"
|
82
|
+
|
83
|
+
# Remove middleware
|
84
|
+
config.middlewares.deregister CMDx::Middlewares::Timeout
|
85
|
+
end
|
86
|
+
```
|
87
|
+
|
88
|
+
> [!NOTE]
|
89
|
+
> Middlewares are executed in registration order. Each middleware wraps the next, creating an execution chain around task logic.
|
90
|
+
|
91
|
+
### Callbacks
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
CMDx.configure do |config|
|
95
|
+
# Via method
|
96
|
+
config.callbacks.register :before_execution, :initialize_user_session
|
97
|
+
|
98
|
+
# Via callable (must respond to `call(task)`)
|
99
|
+
config.callbacks.register :on_success, LogUserActivity
|
100
|
+
|
101
|
+
# Via proc or lambda
|
102
|
+
config.callbacks.register :on_complete, proc { |task|
|
103
|
+
execution_time = task.metadata[:runtime]
|
104
|
+
Metrics.timer("task.execution_time", execution_time, tags: ["task:#{task.class.name.underscore}"])
|
105
|
+
}
|
106
|
+
|
107
|
+
# With options
|
108
|
+
config.callbacks.register :on_failure, :send_alert_notification, if: :critical_task?
|
109
|
+
|
110
|
+
# Remove callback
|
111
|
+
config.callbacks.deregister :on_success, LogUserActivity
|
112
|
+
end
|
113
|
+
```
|
114
|
+
|
115
|
+
### Coercions
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
CMDx.configure do |config|
|
119
|
+
# Via callable (must respond to `call(value, options)`)
|
120
|
+
config.coercions.register :currency, CurrencyCoercion
|
121
|
+
|
122
|
+
# Via method (must match signature `def coordinates_coercion(value, options)`)
|
123
|
+
config.coercions.register :coordinates, :coordinates_coercion
|
124
|
+
|
125
|
+
# Via proc or lambda
|
126
|
+
config.coercions.register :tag_list, proc { |value, options|
|
127
|
+
delimiter = options[:delimiter] || ','
|
128
|
+
max_tags = options[:max_tags] || 50
|
129
|
+
|
130
|
+
tags = value.to_s.split(delimiter).map(&:strip).reject(&:empty?)
|
131
|
+
tags.first(max_tags)
|
132
|
+
}
|
133
|
+
|
134
|
+
# Remove coercion
|
135
|
+
config.coercions.deregister :currency
|
136
|
+
end
|
137
|
+
```
|
138
|
+
|
139
|
+
### Validators
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
CMDx.configure do |config|
|
143
|
+
# Via callable (must respond to `call(value, options)`)
|
144
|
+
config.validators.register :username, UsernameValidator
|
145
|
+
|
146
|
+
# Via method (must match signature `def url_validator(value, options)`)
|
147
|
+
config.validators.register :url, :url_validator
|
148
|
+
|
149
|
+
# Via proc or lambda
|
150
|
+
config.validators.register :access_token, proc { |value, options|
|
151
|
+
expected_prefix = options[:prefix] || "tok_"
|
152
|
+
minimum_length = options[:min_length] || 40
|
153
|
+
|
154
|
+
value.start_with?(expected_prefix) && value.length >= minimum_length
|
155
|
+
}
|
156
|
+
|
157
|
+
# Remove validator
|
158
|
+
config.validators.deregister :username
|
159
|
+
end
|
160
|
+
```
|
161
|
+
|
162
|
+
## Task Configuration
|
163
|
+
|
164
|
+
### Settings
|
165
|
+
|
166
|
+
Override global configuration for specific tasks using `settings`:
|
167
|
+
|
168
|
+
```ruby
|
169
|
+
class GenerateInvoice < CMDx::Task
|
170
|
+
settings(
|
171
|
+
# Global configuration overrides
|
172
|
+
task_breakpoints: ["failed"], # Breakpoint override
|
173
|
+
workflow_breakpoints: [], # Breakpoint override
|
174
|
+
logger: CustomLogger.new($stdout), # Custom logger
|
175
|
+
|
176
|
+
# Task configuration settings
|
177
|
+
breakpoints: ["failed"], # Contextual pointer for :task_breakpoints and :workflow_breakpoints
|
178
|
+
log_level: :info, # Log level override
|
179
|
+
log_formatter: CMDx::LogFormatters::Json.new # Log formatter override
|
180
|
+
tags: ["billing", "financial"], # Logging tags
|
181
|
+
deprecated: true # Task deprecations
|
182
|
+
)
|
183
|
+
|
184
|
+
def work
|
185
|
+
# Your logic here...
|
186
|
+
end
|
187
|
+
end
|
188
|
+
```
|
189
|
+
|
190
|
+
> [!TIP]
|
191
|
+
> Use task-level settings for tasks that require special handling, such as financial reporting, external API integrations, or critical system operations.
|
192
|
+
|
193
|
+
### Registrations
|
194
|
+
|
195
|
+
Register middlewares, callbacks, coercions, and validators on a specific task.
|
196
|
+
Deregister options that should not be available.
|
197
|
+
|
198
|
+
```ruby
|
199
|
+
class SendCampaignEmail < CMDx::Task
|
200
|
+
# Middlewares
|
201
|
+
register :middleware, CMDx::Middlewares::Timeout
|
202
|
+
deregister :middleware, AuditTrailMiddleware
|
203
|
+
|
204
|
+
# Callbacks
|
205
|
+
register :callback, :on_complete, proc { |task|
|
206
|
+
runtime = task.metadata[:runtime]
|
207
|
+
Analytics.track("email_campaign.sent", runtime, tags: ["task:#{task.class.name}"])
|
208
|
+
}
|
209
|
+
deregister :callback, :before_execution, :initialize_user_session
|
210
|
+
|
211
|
+
# Coercions
|
212
|
+
register :coercion, :currency, CurrencyCoercion
|
213
|
+
deregister :coercion, :coordinates
|
214
|
+
|
215
|
+
# Validators
|
216
|
+
register :validator, :username, :username_validator
|
217
|
+
deregister :validator, :url
|
218
|
+
|
219
|
+
def work
|
220
|
+
# Your logic here...
|
221
|
+
end
|
222
|
+
end
|
223
|
+
```
|
224
|
+
|
225
|
+
## Configuration Management
|
226
|
+
|
227
|
+
### Access
|
228
|
+
|
229
|
+
```ruby
|
230
|
+
# Global configuration access
|
231
|
+
CMDx.configuration.logger #=> <Logger instance>
|
232
|
+
CMDx.configuration.task_breakpoints #=> ["failed"]
|
233
|
+
CMDx.configuration.middlewares.registry #=> [<Middleware>, ...]
|
234
|
+
|
235
|
+
# Task configuration access
|
236
|
+
class ProcessUpload < CMDx::Task
|
237
|
+
settings(tags: ["files", "storage"])
|
238
|
+
|
239
|
+
def work
|
240
|
+
self.class.settings[:logger] #=> Global configuration value
|
241
|
+
self.class.settings[:tags] #=> Task configuration value => ["files", "storage"]
|
242
|
+
end
|
243
|
+
end
|
244
|
+
```
|
245
|
+
|
246
|
+
### Resetting
|
247
|
+
|
248
|
+
> [!WARNING]
|
249
|
+
> Resetting configuration affects the entire application. Use primarily in test environments or during application initialization.
|
250
|
+
|
251
|
+
```ruby
|
252
|
+
# Reset to framework defaults
|
253
|
+
CMDx.reset_configuration!
|
254
|
+
|
255
|
+
# Verify reset
|
256
|
+
CMDx.configuration.task_breakpoints #=> ["failed"] (default)
|
257
|
+
CMDx.configuration.middlewares.registry #=> Empty registry
|
258
|
+
|
259
|
+
# Commonly used in test setup (RSpec example)
|
260
|
+
RSpec.configure do |config|
|
261
|
+
config.before(:each) do
|
262
|
+
CMDx.reset_configuration!
|
263
|
+
end
|
264
|
+
end
|
265
|
+
```
|
266
|
+
|
267
|
+
## Task Generator
|
268
|
+
|
269
|
+
Generate new CMDx tasks quickly using the built-in generator:
|
270
|
+
|
271
|
+
```bash
|
272
|
+
rails generate cmdx:task ModerateBlogPost
|
273
|
+
```
|
274
|
+
|
275
|
+
This creates a new task file with the basic structure:
|
276
|
+
|
277
|
+
```ruby
|
278
|
+
# app/tasks/moderate_blog_post.rb
|
279
|
+
class ModerateBlogPost < CMDx::Task
|
280
|
+
def work
|
281
|
+
# Your logic here...
|
282
|
+
end
|
283
|
+
end
|
284
|
+
```
|
285
|
+
|
286
|
+
> [!TIP]
|
287
|
+
> Use **present tense verbs + noun** for task names, eg: `ModerateBlogPost`, `ScheduleAppointment`, `ValidateDocument`
|
288
|
+
|
289
|
+
---
|
290
|
+
|
291
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/basics/setup.md
|
292
|
+
---
|
293
|
+
|
294
|
+
# Basics - Setup
|
295
|
+
|
296
|
+
Tasks are the core building blocks of CMDx, encapsulating business logic within structured, reusable objects. Each task represents a unit of work with automatic attribute validation, error handling, and execution tracking.
|
297
|
+
|
298
|
+
## Structure
|
299
|
+
|
300
|
+
Tasks inherit from `CMDx::Task` and require only a `work` method:
|
301
|
+
|
302
|
+
```ruby
|
303
|
+
class ValidateDocument < CMDx::Task
|
304
|
+
def work
|
305
|
+
# Your logic here...
|
306
|
+
end
|
307
|
+
end
|
308
|
+
```
|
309
|
+
|
310
|
+
An exception will be raised if a work method is not defined.
|
311
|
+
|
312
|
+
```ruby
|
313
|
+
class IncompleteTask < CMDx::Task
|
314
|
+
# No `work` method defined
|
315
|
+
end
|
316
|
+
|
317
|
+
IncompleteTask.execute #=> raises CMDx::UndefinedMethodError
|
318
|
+
```
|
319
|
+
|
320
|
+
## Inheritance
|
321
|
+
|
322
|
+
All configuration options are inheritable by any child classes.
|
323
|
+
Create a base class to share common configuration across tasks:
|
324
|
+
|
325
|
+
```ruby
|
326
|
+
class ApplicationTask < CMDx::Task
|
327
|
+
register :middleware, SecurityMiddleware
|
328
|
+
|
329
|
+
before_execution :initialize_request_tracking
|
330
|
+
|
331
|
+
attribute :session_id
|
332
|
+
|
333
|
+
private
|
334
|
+
|
335
|
+
def initialize_request_tracking
|
336
|
+
context.tracking_id ||= SecureRandom.uuid
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
class SyncInventory < ApplicationTask
|
341
|
+
def work
|
342
|
+
# Your logic here...
|
343
|
+
end
|
344
|
+
end
|
345
|
+
```
|
346
|
+
|
347
|
+
## Lifecycle
|
348
|
+
|
349
|
+
Tasks follow a predictable call pattern with specific states and statuses:
|
350
|
+
|
351
|
+
> [!CAUTION]
|
352
|
+
> Tasks are single-use objects. Once executed, they are frozen and cannot be executed again.
|
353
|
+
|
354
|
+
| Stage | State | Status | Description |
|
355
|
+
|-------|-------|--------|-------------|
|
356
|
+
| **Instantiation** | `initialized` | `success` | Task created with context |
|
357
|
+
| **Validation** | `executing` | `success`/`failed` | Attributes validated |
|
358
|
+
| **Execution** | `executing` | `success`/`failed`/`skipped` | `work` method runs |
|
359
|
+
| **Completion** | `executed` | `success`/`failed`/`skipped` | Result finalized |
|
360
|
+
| **Freezing** | `executed` | `success`/`failed`/`skipped` | Task becomes immutable |
|
361
|
+
|
362
|
+
---
|
363
|
+
|
364
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/basics/execution.md
|
365
|
+
---
|
366
|
+
|
367
|
+
# Basics - Execution
|
368
|
+
|
369
|
+
Task execution in CMDx provides two distinct methods that handle success and halt scenarios differently. Understanding when to use each method is crucial for proper error handling and control flow in your application workflows.
|
370
|
+
|
371
|
+
## Methods Overview
|
372
|
+
|
373
|
+
Tasks are single-use objects. Once executed, they are frozen and cannot be executed again.
|
374
|
+
Create a new instance for subsequent executions.
|
375
|
+
|
376
|
+
| Method | Returns | Exceptions | Use Case |
|
377
|
+
|--------|---------|------------|----------|
|
378
|
+
| `execute` | Always returns `CMDx::Result` | Never raises | Predictable result handling |
|
379
|
+
| `execute!` | Returns `CMDx::Result` on success | Raises `CMDx::Fault` when skipped or failed | Exception-based control flow |
|
380
|
+
|
381
|
+
## Non-bang Execution
|
382
|
+
|
383
|
+
The `execute` method always returns a `CMDx::Result` object regardless of execution outcome.
|
384
|
+
This is the preferred method for most use cases.
|
385
|
+
|
386
|
+
Any unhandled exceptions will be caught and returned as a task failure.
|
387
|
+
|
388
|
+
```ruby
|
389
|
+
result = CreateAccount.execute(email: "user@example.com")
|
390
|
+
|
391
|
+
# Check execution state
|
392
|
+
result.success? #=> true/false
|
393
|
+
result.failed? #=> true/false
|
394
|
+
result.skipped? #=> true/false
|
395
|
+
|
396
|
+
# Access result data
|
397
|
+
result.context.email #=> "user@example.com"
|
398
|
+
result.state #=> "complete"
|
399
|
+
result.status #=> "success"
|
400
|
+
```
|
401
|
+
|
402
|
+
## Bang Execution
|
403
|
+
|
404
|
+
The bang `execute!` method raises a `CMDx::Fault` based exception when tasks fail or are skipped, and returns a `CMDx::Result` object only on success.
|
405
|
+
|
406
|
+
It raises any unhandled non-fault exceptions caused during execution.
|
407
|
+
|
408
|
+
| Exception | Raised When |
|
409
|
+
|-----------|-------------|
|
410
|
+
| `CMDx::FailFault` | Task execution fails |
|
411
|
+
| `CMDx::SkipFault` | Task execution is skipped |
|
412
|
+
|
413
|
+
> [!IMPORTANT]
|
414
|
+
> `execute!` behavior depends on the `task_breakpoints` or `workflow_breakpoints` configuration. By default, it raises exceptions only on failures.
|
415
|
+
|
416
|
+
```ruby
|
417
|
+
begin
|
418
|
+
result = CreateAccount.execute!(email: "user@example.com")
|
419
|
+
SendWelcomeEmail.execute(result.context)
|
420
|
+
rescue CMDx::Fault => e
|
421
|
+
ScheduleAccountRetryJob.perform_later(e.result.context.email)
|
422
|
+
rescue CMDx::SkipFault => e
|
423
|
+
Rails.logger.info("Account creation skipped: #{e.result.reason}")
|
424
|
+
rescue Exception => e
|
425
|
+
ErrorTracker.capture(unhandled_exception: e)
|
426
|
+
end
|
427
|
+
```
|
428
|
+
|
429
|
+
## Direct Instantiation
|
430
|
+
|
431
|
+
Tasks can be instantiated directly for advanced use cases, testing, and custom execution patterns:
|
432
|
+
|
433
|
+
```ruby
|
434
|
+
# Direct instantiation
|
435
|
+
task = CreateAccount.new(email: "user@example.com", send_welcome: true)
|
436
|
+
|
437
|
+
# Access properties before execution
|
438
|
+
task.id #=> "abc123..." (unique task ID)
|
439
|
+
task.context.email #=> "user@example.com"
|
440
|
+
task.context.send_welcome #=> true
|
441
|
+
task.result.state #=> "initialized"
|
442
|
+
result.status #=> "success"
|
443
|
+
|
444
|
+
# Manual execution
|
445
|
+
task.execute
|
446
|
+
# or
|
447
|
+
task.execute!
|
448
|
+
|
449
|
+
task.result.success? #=> true/false
|
450
|
+
```
|
451
|
+
|
452
|
+
## Result Details
|
453
|
+
|
454
|
+
The `Result` object provides comprehensive execution information:
|
455
|
+
|
456
|
+
```ruby
|
457
|
+
result = CreateAccount.execute(email: "user@example.com")
|
458
|
+
|
459
|
+
# Execution metadata
|
460
|
+
result.id #=> "abc123..." (unique execution ID)
|
461
|
+
result.task #=> CreateAccount instance (frozen)
|
462
|
+
result.chain #=> Task execution chain
|
463
|
+
|
464
|
+
# Context and metadata
|
465
|
+
result.context #=> Context with all task data
|
466
|
+
result.metadata #=> Hash with execution metadata
|
467
|
+
|
468
|
+
---
|
469
|
+
|
470
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/basics/context.md
|
471
|
+
---
|
472
|
+
|
473
|
+
# Basics - Context
|
474
|
+
|
475
|
+
Task context provides flexible data storage, access, and sharing within task execution. It serves as the primary data container for all task inputs, intermediate results, and outputs.
|
476
|
+
|
477
|
+
## Assigning Data
|
478
|
+
|
479
|
+
Context is automatically populated with all inputs passed to a task. All keys are normalized to symbols for consistent access:
|
480
|
+
|
481
|
+
```ruby
|
482
|
+
# Direct execution
|
483
|
+
CalculateShipping.execute(weight: 2.5, destination: "CA")
|
484
|
+
|
485
|
+
# Instance creation
|
486
|
+
CalculateShipping.new(weight: 2.5, "destination" => "CA")
|
487
|
+
```
|
488
|
+
|
489
|
+
> [!IMPORTANT]
|
490
|
+
> String keys are automatically converted to symbols. Use symbols for consistency in your code.
|
491
|
+
|
492
|
+
## Accessing Data
|
493
|
+
|
494
|
+
Context provides multiple access patterns with automatic nil safety:
|
495
|
+
|
496
|
+
```ruby
|
497
|
+
class CalculateShipping < CMDx::Task
|
498
|
+
def work
|
499
|
+
# Method style access (preferred)
|
500
|
+
weight = context.weight
|
501
|
+
destination = context.destination
|
502
|
+
|
503
|
+
# Hash style access
|
504
|
+
service_type = context[:service_type]
|
505
|
+
options = context["options"]
|
506
|
+
|
507
|
+
# Safe access with defaults
|
508
|
+
rush_delivery = context.fetch!(:rush_delivery, false)
|
509
|
+
carrier = context.dig(:options, :carrier)
|
510
|
+
|
511
|
+
# Shorter alias
|
512
|
+
cost = ctx.weight * ctx.rate_per_pound # ctx aliases context
|
513
|
+
end
|
514
|
+
end
|
515
|
+
```
|
516
|
+
|
517
|
+
> [!IMPORTANT]
|
518
|
+
> Accessing undefined context attributes returns `nil` instead of raising errors, enabling graceful handling of optional attributes.
|
519
|
+
|
520
|
+
## Modifying Context
|
521
|
+
|
522
|
+
Context supports dynamic modification during task execution:
|
523
|
+
|
524
|
+
```ruby
|
525
|
+
class CalculateShipping < CMDx::Task
|
526
|
+
def work
|
527
|
+
# Direct assignment
|
528
|
+
context.carrier = Carrier.find_by(code: context.carrier_code)
|
529
|
+
context.package = Package.new(weight: context.weight)
|
530
|
+
context.calculated_at = Time.now
|
531
|
+
|
532
|
+
# Hash-style assignment
|
533
|
+
context[:status] = "calculating"
|
534
|
+
context["tracking_number"] = "SHIP#{SecureRandom.hex(6)}"
|
535
|
+
|
536
|
+
# Conditional assignment
|
537
|
+
context.insurance_included ||= false
|
538
|
+
|
539
|
+
# Batch updates
|
540
|
+
context.merge!(
|
541
|
+
status: "completed",
|
542
|
+
shipping_cost: calculate_cost,
|
543
|
+
estimated_delivery: Time.now + 3.days
|
544
|
+
)
|
545
|
+
|
546
|
+
# Remove sensitive data
|
547
|
+
context.delete!(:credit_card_token)
|
548
|
+
end
|
549
|
+
|
550
|
+
private
|
551
|
+
|
552
|
+
def calculate_cost
|
553
|
+
base_rate = context.weight * context.rate_per_pound
|
554
|
+
base_rate + (base_rate * context.tax_percentage)
|
555
|
+
end
|
556
|
+
end
|
557
|
+
```
|
558
|
+
|
559
|
+
> [!TIP]
|
560
|
+
> Use context for both input values and intermediate results. This creates natural data flow through your task execution pipeline.
|
561
|
+
|
562
|
+
## Data Sharing
|
563
|
+
|
564
|
+
Context enables seamless data flow between related tasks in complex workflows:
|
565
|
+
|
566
|
+
```ruby
|
567
|
+
# During execution
|
568
|
+
class CalculateShipping < CMDx::Task
|
569
|
+
def work
|
570
|
+
# Validate shipping data
|
571
|
+
validation_result = ValidateAddress.execute(context)
|
572
|
+
|
573
|
+
# Via context
|
574
|
+
CalculateInsurance.execute(context)
|
575
|
+
|
576
|
+
# Via result
|
577
|
+
NotifyShippingCalculated.execute(validation_result)
|
578
|
+
|
579
|
+
# Context now contains accumulated data from all tasks
|
580
|
+
context.address_validated #=> true (from validation)
|
581
|
+
context.insurance_calculated #=> true (from insurance)
|
582
|
+
context.notification_sent #=> true (from notification)
|
583
|
+
end
|
584
|
+
end
|
585
|
+
|
586
|
+
# After execution
|
587
|
+
result = CalculateShipping.execute(destination: "New York, NY")
|
588
|
+
|
589
|
+
CreateShippingLabel.execute(result)
|
590
|
+
```
|
591
|
+
|
592
|
+
---
|
593
|
+
|
594
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/basics/chain.md
|
595
|
+
---
|
596
|
+
|
597
|
+
# Basics - Chain
|
598
|
+
|
599
|
+
Chains automatically group related task executions within a thread, providing unified tracking, correlation, and execution context management. Each thread maintains its own chain through thread-local storage, eliminating the need for manual coordination.
|
600
|
+
|
601
|
+
## Management
|
602
|
+
|
603
|
+
Each thread maintains its own chain context through thread-local storage, providing automatic isolation without manual coordination.
|
604
|
+
|
605
|
+
> [!WARNING]
|
606
|
+
> Chain operations are thread-local. Never share chain references across threads as this can lead to race conditions and data corruption.
|
607
|
+
|
608
|
+
```ruby
|
609
|
+
# Thread A
|
610
|
+
Thread.new do
|
611
|
+
result = ImportDataset.execute(file_path: "/data/batch1.csv")
|
612
|
+
result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5"
|
613
|
+
end
|
614
|
+
|
615
|
+
# Thread B (completely separate chain)
|
616
|
+
Thread.new do
|
617
|
+
result = ImportDataset.execute(file_path: "/data/batch2.csv")
|
618
|
+
result.chain.id #=> "z3a42b95-c821-7892-b156-dd7c921fe2a3"
|
619
|
+
end
|
620
|
+
|
621
|
+
# Access current thread's chain
|
622
|
+
CMDx::Chain.current #=> Returns current chain or nil
|
623
|
+
CMDx::Chain.clear #=> Clears current thread's chain
|
624
|
+
```
|
625
|
+
|
626
|
+
## Links
|
627
|
+
|
628
|
+
Every task execution automatically creates or joins the current thread's chain:
|
629
|
+
|
630
|
+
> [!IMPORTANT]
|
631
|
+
> Chain creation is automatic and transparent. You don't need to manually manage chain lifecycle.
|
632
|
+
|
633
|
+
```ruby
|
634
|
+
class ImportDataset < CMDx::Task
|
635
|
+
def work
|
636
|
+
# First task creates new chain
|
637
|
+
result1 = ValidateHeaders.execute(file_path: context.file_path)
|
638
|
+
result1.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5"
|
639
|
+
result1.chain.results.size #=> 1
|
640
|
+
|
641
|
+
# Second task joins existing chain
|
642
|
+
result2 = SendNotification.execute(to: "admin@company.com")
|
643
|
+
result2.chain.id == result1.chain.id #=> true
|
644
|
+
result2.chain.results.size #=> 2
|
645
|
+
|
646
|
+
# Both results reference the same chain
|
647
|
+
result1.chain.results == result2.chain.results #=> true
|
648
|
+
end
|
649
|
+
end
|
650
|
+
```
|
651
|
+
|
652
|
+
## Inheritance
|
653
|
+
|
654
|
+
When tasks call subtasks within the same thread, all executions automatically inherit the current chain, creating a unified execution trail.
|
655
|
+
|
656
|
+
```ruby
|
657
|
+
class ImportDataset < CMDx::Task
|
658
|
+
def work
|
659
|
+
context.dataset = Dataset.find(context.dataset_id)
|
660
|
+
|
661
|
+
# Subtasks automatically inherit current chain
|
662
|
+
ValidateSchema.execute
|
663
|
+
TransformData.execute!(context)
|
664
|
+
SaveToDatabase.execute(dataset_id: context.dataset_id)
|
665
|
+
end
|
666
|
+
end
|
667
|
+
|
668
|
+
result = ImportDataset.execute(dataset_id: 456)
|
669
|
+
chain = result.chain
|
670
|
+
|
671
|
+
# All tasks share the same chain
|
672
|
+
chain.results.size #=> 4 (main task + 3 subtasks)
|
673
|
+
chain.results.map { |r| r.task.class }
|
674
|
+
#=> [ImportDataset, ValidateSchema, TransformData, SaveToDatabase]
|
675
|
+
```
|
676
|
+
|
677
|
+
## Structure
|
678
|
+
|
679
|
+
Chains provide comprehensive execution information with state delegation:
|
680
|
+
|
681
|
+
> [!IMPORTANT]
|
682
|
+
> Chain state always reflects the first (outer-most) task result, not individual subtask outcomes. Subtasks maintain their own success/failure states.
|
683
|
+
|
684
|
+
```ruby
|
685
|
+
result = ImportDataset.execute(dataset_id: 456)
|
686
|
+
chain = result.chain
|
687
|
+
|
688
|
+
# Chain identification
|
689
|
+
chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5"
|
690
|
+
chain.results #=> Array of all results in execution order
|
691
|
+
|
692
|
+
# State delegation (from first/outer-most result)
|
693
|
+
chain.state #=> "complete"
|
694
|
+
chain.status #=> "success"
|
695
|
+
chain.outcome #=> "success"
|
696
|
+
|
697
|
+
# Access individual results
|
698
|
+
chain.results.each_with_index do |result, index|
|
699
|
+
puts "#{index}: #{result.task.class} - #{result.status}"
|
700
|
+
end
|
701
|
+
```
|
702
|
+
|
703
|
+
---
|
704
|
+
|
705
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/interruptions/halt.md
|
706
|
+
---
|
707
|
+
|
708
|
+
# Interruptions - Halt
|
709
|
+
|
710
|
+
Halting stops task execution with explicit intent signaling. Tasks provide two primary halt methods that control execution flow and result in different outcomes.
|
711
|
+
|
712
|
+
## Skipping
|
713
|
+
|
714
|
+
`skip!` communicates that the task is to be intentionally bypassed. This represents a controlled, intentional interruption where the task determines that execution is not necessary or appropriate.
|
715
|
+
|
716
|
+
> [!IMPORTANT]
|
717
|
+
> Skipping is a no-op, not a failure or error and are considered successful outcomes.
|
718
|
+
|
719
|
+
```ruby
|
720
|
+
class ProcessInventory < CMDx::Task
|
721
|
+
def work
|
722
|
+
# Without a reason
|
723
|
+
skip! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name)
|
724
|
+
|
725
|
+
# With a reason
|
726
|
+
skip!("Warehouse closed") unless Time.now.hour.between?(8, 18)
|
727
|
+
|
728
|
+
inventory = Inventory.find(context.inventory_id)
|
729
|
+
|
730
|
+
if inventory.already_counted?
|
731
|
+
skip!("Inventory already counted today")
|
732
|
+
else
|
733
|
+
inventory.count!
|
734
|
+
end
|
735
|
+
end
|
736
|
+
end
|
737
|
+
|
738
|
+
result = ProcessInventory.execute(inventory_id: 456)
|
739
|
+
|
740
|
+
# Executed
|
741
|
+
result.status #=> "skipped"
|
742
|
+
|
743
|
+
# Without a reason
|
744
|
+
result.reason #=> "no reason given"
|
745
|
+
|
746
|
+
# With a reason
|
747
|
+
result.reason #=> "Warehouse closed"
|
748
|
+
```
|
749
|
+
|
750
|
+
## Failing
|
751
|
+
|
752
|
+
`fail!` communicates that the task encountered an impediment that prevents successful completion. This represents controlled failure where the task explicitly determines that execution cannot continue.
|
753
|
+
|
754
|
+
```ruby
|
755
|
+
class ProcessRefund < CMDx::Task
|
756
|
+
def work
|
757
|
+
# Without a reason
|
758
|
+
fail! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name)
|
759
|
+
|
760
|
+
refund = Refund.find(context.refund_id)
|
761
|
+
|
762
|
+
# With a reason
|
763
|
+
if refund.expired?
|
764
|
+
fail!("Refund period has expired")
|
765
|
+
elsif !refund.amount.positive?
|
766
|
+
fail!("Refund amount must be positive")
|
767
|
+
else
|
768
|
+
refund.process!
|
769
|
+
end
|
770
|
+
end
|
771
|
+
end
|
772
|
+
|
773
|
+
result = ProcessRefund.execute(refund_id: 789)
|
774
|
+
|
775
|
+
# Executed
|
776
|
+
result.status #=> "failed"
|
777
|
+
|
778
|
+
# Without a reason
|
779
|
+
result.reason #=> "no reason given"
|
780
|
+
|
781
|
+
# With a reason
|
782
|
+
result.reason #=> "Refund period has expired"
|
783
|
+
```
|
784
|
+
|
785
|
+
## Metadata Enrichment
|
786
|
+
|
787
|
+
Both halt methods accept metadata to provide additional context about the interruption. Metadata is stored as a hash and becomes available through the result object.
|
788
|
+
|
789
|
+
```ruby
|
790
|
+
class ProcessRenewal < CMDx::Task
|
791
|
+
def work
|
792
|
+
license = License.find(context.license_id)
|
793
|
+
|
794
|
+
if license.already_renewed?
|
795
|
+
# Without metadata
|
796
|
+
skip!("License already renewed")
|
797
|
+
end
|
798
|
+
|
799
|
+
unless license.renewal_eligible?
|
800
|
+
# With metadata
|
801
|
+
fail!(
|
802
|
+
"License not eligible for renewal",
|
803
|
+
error_code: "LICENSE.NOT_ELIGIBLE",
|
804
|
+
retry_after: Time.current + 30.days
|
805
|
+
)
|
806
|
+
end
|
807
|
+
|
808
|
+
process_renewal
|
809
|
+
end
|
810
|
+
end
|
811
|
+
|
812
|
+
result = ProcessRenewal.execute(license_id: 567)
|
813
|
+
|
814
|
+
# Without metadata
|
815
|
+
result.metadata #=> {}
|
816
|
+
|
817
|
+
# With metadata
|
818
|
+
result.metadata #=> {
|
819
|
+
# error_code: "LICENSE.NOT_ELIGIBLE",
|
820
|
+
# retry_after: <Time 30 days from now>
|
821
|
+
# }
|
822
|
+
```
|
823
|
+
|
824
|
+
## State Transitions
|
825
|
+
|
826
|
+
Halt methods trigger specific state and status transitions:
|
827
|
+
|
828
|
+
| Method | State | Status | Outcome |
|
829
|
+
|--------|-------|--------|---------|
|
830
|
+
| `skip!` | `interrupted` | `skipped` | `good? = true`, `bad? = true` |
|
831
|
+
| `fail!` | `interrupted` | `failed` | `good? = false`, `bad? = true` |
|
832
|
+
|
833
|
+
```ruby
|
834
|
+
result = ProcessRenewal.execute(license_id: 567)
|
835
|
+
|
836
|
+
# State information
|
837
|
+
result.state #=> "interrupted"
|
838
|
+
result.status #=> "skipped" or "failed"
|
839
|
+
result.interrupted? #=> true
|
840
|
+
result.complete? #=> false
|
841
|
+
|
842
|
+
# Outcome categorization
|
843
|
+
result.good? #=> true for skipped, false for failed
|
844
|
+
result.bad? #=> true for both skipped and failed
|
845
|
+
```
|
846
|
+
|
847
|
+
## Execution Behavior
|
848
|
+
|
849
|
+
Halt methods behave differently depending on the call method used:
|
850
|
+
|
851
|
+
### Non-bang execution
|
852
|
+
|
853
|
+
Returns result object without raising exceptions:
|
854
|
+
|
855
|
+
```ruby
|
856
|
+
result = ProcessRefund.execute(refund_id: 789)
|
857
|
+
|
858
|
+
case result.status
|
859
|
+
when "success"
|
860
|
+
puts "Refund processed: $#{result.context.refund.amount}"
|
861
|
+
when "skipped"
|
862
|
+
puts "Refund skipped: #{result.reason}"
|
863
|
+
when "failed"
|
864
|
+
puts "Refund failed: #{result.reason}"
|
865
|
+
handle_refund_error(result.metadata[:error_code])
|
866
|
+
end
|
867
|
+
```
|
868
|
+
|
869
|
+
### Bang execution
|
870
|
+
|
871
|
+
Raises exceptions for halt conditions based on `task_breakpoints` configuration:
|
872
|
+
|
873
|
+
```ruby
|
874
|
+
begin
|
875
|
+
result = ProcessRefund.execute!(refund_id: 789)
|
876
|
+
puts "Success: Refund processed"
|
877
|
+
rescue CMDx::SkipFault => e
|
878
|
+
puts "Skipped: #{e.message}"
|
879
|
+
rescue CMDx::FailFault => e
|
880
|
+
puts "Failed: #{e.message}"
|
881
|
+
handle_refund_failure(e.result.metadata[:error_code])
|
882
|
+
end
|
883
|
+
```
|
884
|
+
|
885
|
+
## Best Practices
|
886
|
+
|
887
|
+
Always try to provide a `reason` when using halt methods. This provides clear context for debugging and creates meaningful exception messages.
|
888
|
+
|
889
|
+
```ruby
|
890
|
+
# Good: Clear, specific reason
|
891
|
+
skip!("Document processing paused for compliance review")
|
892
|
+
fail!("File format not supported by processor", code: "FORMAT_UNSUPPORTED")
|
893
|
+
|
894
|
+
# Acceptable: Generic, non-specific reason
|
895
|
+
skip!("Paused")
|
896
|
+
fail!("Unsupported")
|
897
|
+
|
898
|
+
# Bad: Default, cannot determine reason
|
899
|
+
skip! #=> "no reason given"
|
900
|
+
fail! #=> "no reason given"
|
901
|
+
|
902
|
+
---
|
903
|
+
|
904
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/interruptions/faults.md
|
905
|
+
---
|
906
|
+
|
907
|
+
# Interruptions - Faults
|
908
|
+
|
909
|
+
Faults are exception mechanisms that halt task execution via `skip!` and `fail!` methods. When tasks execute with the `execute!` method, fault exceptions matching the task's interruption status are raised, enabling sophisticated exception handling and control flow patterns.
|
910
|
+
|
911
|
+
## Fault Types
|
912
|
+
|
913
|
+
| Type | Triggered By | Use Case |
|
914
|
+
|------|--------------|----------|
|
915
|
+
| `CMDx::Fault` | Base class | Catch-all for any interruption |
|
916
|
+
| `CMDx::SkipFault` | `skip!` method | Optional processing, early returns |
|
917
|
+
| `CMDx::FailFault` | `fail!` method | Validation errors, processing failures |
|
918
|
+
|
919
|
+
> [!IMPORTANT]
|
920
|
+
> All fault exceptions inherit from `CMDx::Fault` and provide access to the complete task execution context including result, task, context, and chain information.
|
921
|
+
|
922
|
+
## Fault Handling
|
923
|
+
|
924
|
+
```ruby
|
925
|
+
begin
|
926
|
+
ProcessTicket.execute!(ticket_id: 456)
|
927
|
+
rescue CMDx::SkipFault => e
|
928
|
+
logger.info "Ticket processing skipped: #{e.message}"
|
929
|
+
schedule_retry(e.context.ticket_id)
|
930
|
+
rescue CMDx::FailFault => e
|
931
|
+
logger.error "Ticket processing failed: #{e.message}"
|
932
|
+
notify_admin(e.context.assigned_agent, e.result.metadata[:error_code])
|
933
|
+
rescue CMDx::Fault => e
|
934
|
+
logger.warn "Ticket processing interrupted: #{e.message}"
|
935
|
+
rollback_changes
|
936
|
+
end
|
937
|
+
```
|
938
|
+
|
939
|
+
## Data Access
|
940
|
+
|
941
|
+
Faults provide comprehensive access to execution context, eg:
|
942
|
+
|
943
|
+
```ruby
|
944
|
+
begin
|
945
|
+
LicenseActivation.execute!(license_key: key, machine_id: machine)
|
946
|
+
rescue CMDx::Fault => e
|
947
|
+
# Result information
|
948
|
+
e.result.state #=> "interrupted"
|
949
|
+
e.result.status #=> "failed" or "skipped"
|
950
|
+
e.result.reason #=> "License key already activated"
|
951
|
+
|
952
|
+
# Task information
|
953
|
+
e.task.class #=> <LicenseActivation>
|
954
|
+
e.task.id #=> "abc123..."
|
955
|
+
|
956
|
+
# Context data
|
957
|
+
e.context.license_key #=> "ABC-123-DEF"
|
958
|
+
e.context.machine_id #=> "[FILTERED]"
|
959
|
+
|
960
|
+
# Chain information
|
961
|
+
e.chain.id #=> "def456..."
|
962
|
+
e.chain.size #=> 3
|
963
|
+
end
|
964
|
+
```
|
965
|
+
|
966
|
+
## Advanced Matching
|
967
|
+
|
968
|
+
### Task-Specific Matching
|
969
|
+
|
970
|
+
Use `for?` to handle faults only from specific task classes, enabling targeted exception handling in complex workflows.
|
971
|
+
|
972
|
+
```ruby
|
973
|
+
begin
|
974
|
+
DocumentWorkflow.execute!(document_data: data)
|
975
|
+
rescue CMDx::FailFault.for?(FormatValidator, ContentProcessor) => e
|
976
|
+
# Handle only document-related failures
|
977
|
+
retry_with_alternate_parser(e.context)
|
978
|
+
rescue CMDx::SkipFault.for?(VirusScanner, ContentFilter) => e
|
979
|
+
# Handle security-related skips
|
980
|
+
quarantine_for_review(e.context.document_id)
|
981
|
+
end
|
982
|
+
```
|
983
|
+
|
984
|
+
### Custom Logic Matching
|
985
|
+
|
986
|
+
```ruby
|
987
|
+
begin
|
988
|
+
ReportGenerator.execute!(report: report_data)
|
989
|
+
rescue CMDx::Fault.matches? { |f| f.context.data_size > 10_000 } => e
|
990
|
+
escalate_large_dataset_failure(e)
|
991
|
+
rescue CMDx::FailFault.matches? { |f| f.result.metadata[:attempt_count] > 3 } => e
|
992
|
+
abandon_report_generation(e)
|
993
|
+
rescue CMDx::Fault.matches? { |f| f.result.metadata[:error_type] == "memory" } => e
|
994
|
+
increase_memory_and_retry(e)
|
995
|
+
end
|
996
|
+
```
|
997
|
+
|
998
|
+
## Fault Propagation
|
999
|
+
|
1000
|
+
Use `throw!` to propagate failures while preserving fault context and maintaining the error chain for debugging.
|
1001
|
+
|
1002
|
+
### Basic Propagation
|
1003
|
+
|
1004
|
+
```ruby
|
1005
|
+
class ReportGenerator < CMDx::Task
|
1006
|
+
def work
|
1007
|
+
# Throw if skipped or failed
|
1008
|
+
validation_result = DataValidator.execute(context)
|
1009
|
+
throw!(validation_result)
|
1010
|
+
|
1011
|
+
# Only throw if skipped
|
1012
|
+
check_permissions = CheckPermissions.execute(context)
|
1013
|
+
throw!(check_permissions) if check_permissions.skipped?
|
1014
|
+
|
1015
|
+
# Only throw if failed
|
1016
|
+
data_result = DataProcessor.execute(context)
|
1017
|
+
throw!(data_result) if data_result.failed?
|
1018
|
+
|
1019
|
+
# Continue processing
|
1020
|
+
generate_report
|
1021
|
+
end
|
1022
|
+
end
|
1023
|
+
```
|
1024
|
+
|
1025
|
+
### Additional Metadata
|
1026
|
+
|
1027
|
+
```ruby
|
1028
|
+
class BatchProcessor < CMDx::Task
|
1029
|
+
def work
|
1030
|
+
step_result = FileValidation.execute(context)
|
1031
|
+
|
1032
|
+
if step_result.failed?
|
1033
|
+
throw!(step_result, {
|
1034
|
+
batch_stage: "validation",
|
1035
|
+
can_retry: true,
|
1036
|
+
next_step: "file_repair"
|
1037
|
+
})
|
1038
|
+
end
|
1039
|
+
|
1040
|
+
continue_batch
|
1041
|
+
end
|
1042
|
+
end
|
1043
|
+
```
|
1044
|
+
|
1045
|
+
## Chain Analysis
|
1046
|
+
|
1047
|
+
Results provide methods to analyze fault propagation and identify original failure sources in complex execution chains.
|
1048
|
+
|
1049
|
+
```ruby
|
1050
|
+
result = DocumentWorkflow.execute(invalid_data)
|
1051
|
+
|
1052
|
+
if result.failed?
|
1053
|
+
# Trace the original failure
|
1054
|
+
original = result.caused_failure
|
1055
|
+
if original
|
1056
|
+
puts "Original failure: #{original.task.class.name}"
|
1057
|
+
puts "Reason: #{original.reason}"
|
1058
|
+
end
|
1059
|
+
|
1060
|
+
# Find what propagated the failure
|
1061
|
+
thrower = result.threw_failure
|
1062
|
+
puts "Propagated by: #{thrower.task.class.name}" if thrower
|
1063
|
+
|
1064
|
+
# Analyze failure type
|
1065
|
+
case
|
1066
|
+
when result.caused_failure?
|
1067
|
+
puts "This task was the original source"
|
1068
|
+
when result.threw_failure?
|
1069
|
+
puts "This task propagated a failure"
|
1070
|
+
when result.thrown_failure?
|
1071
|
+
puts "This task failed due to propagation"
|
1072
|
+
end
|
1073
|
+
end
|
1074
|
+
```
|
1075
|
+
|
1076
|
+
---
|
1077
|
+
|
1078
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/interruptions/exceptions.md
|
1079
|
+
---
|
1080
|
+
|
1081
|
+
# Interruptions - Exceptions
|
1082
|
+
|
1083
|
+
CMDx provides robust exception handling that differs between the `execute` and `execute!` methods. Understanding how unhandled exceptions are processed is crucial for building reliable task execution flows and implementing proper error handling strategies.
|
1084
|
+
|
1085
|
+
## Exception Handling
|
1086
|
+
|
1087
|
+
> [!IMPORTANT]
|
1088
|
+
> When designing tasks try not to `raise` your own exceptions directly, instead use `skip!` or `fail!` to signal intent clearly.
|
1089
|
+
|
1090
|
+
### Non-bang execution
|
1091
|
+
|
1092
|
+
The `execute` method captures **all** unhandled exceptions and converts them to failed results, ensuring predictable behavior and consistent result processing.
|
1093
|
+
|
1094
|
+
```ruby
|
1095
|
+
class CompressDocument < CMDx::Task
|
1096
|
+
def work
|
1097
|
+
document = Document.find(context.document_id)
|
1098
|
+
document.compress!
|
1099
|
+
end
|
1100
|
+
end
|
1101
|
+
|
1102
|
+
result = CompressDocument.execute(document_id: "unknown-doc-id")
|
1103
|
+
result.state #=> "interrupted"
|
1104
|
+
result.status #=> "failed"
|
1105
|
+
result.failed? #=> true
|
1106
|
+
result.reason #=> "[ActiveRecord::NotFoundError] record not found"
|
1107
|
+
result.cause #=> <ActiveRecord::NotFoundError>
|
1108
|
+
```
|
1109
|
+
|
1110
|
+
### Bang execution
|
1111
|
+
|
1112
|
+
The `execute!` method allows unhandled exceptions to propagate, enabling standard Ruby exception handling while respecting CMDx fault configuration.
|
1113
|
+
|
1114
|
+
```ruby
|
1115
|
+
class CompressDocument < CMDx::Task
|
1116
|
+
def work
|
1117
|
+
document = Document.find(context.document_id)
|
1118
|
+
document.compress!
|
1119
|
+
end
|
1120
|
+
end
|
1121
|
+
|
1122
|
+
begin
|
1123
|
+
CompressDocument.execute!(document_id: "unknown-doc-id")
|
1124
|
+
rescue ActiveRecord::NotFoundError => e
|
1125
|
+
puts "Handle exception: #{e.message}"
|
1126
|
+
end
|
1127
|
+
```
|
1128
|
+
|
1129
|
+
---
|
1130
|
+
|
1131
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/outcomes/result.md
|
1132
|
+
---
|
1133
|
+
|
1134
|
+
# Outcomes - Result
|
1135
|
+
|
1136
|
+
The result object is the comprehensive return value of task execution, providing complete information about the execution outcome, state, timing, and any data produced during the task lifecycle. Results serve as the primary interface for inspecting task execution outcomes and chaining task operations.
|
1137
|
+
|
1138
|
+
## Result Attributes
|
1139
|
+
|
1140
|
+
Every result provides access to essential execution information:
|
1141
|
+
|
1142
|
+
> [!IMPORTANT]
|
1143
|
+
> Result objects are immutable after task execution completes and reflect the final state.
|
1144
|
+
|
1145
|
+
```ruby
|
1146
|
+
result = BuildApplication.execute(version: "1.2.3")
|
1147
|
+
|
1148
|
+
# Object data
|
1149
|
+
result.task #=> <BuildApplication>
|
1150
|
+
result.context #=> <CMDx::Context>
|
1151
|
+
result.chain #=> <CMDx::Chain>
|
1152
|
+
|
1153
|
+
# Execution data
|
1154
|
+
result.state #=> "interrupted"
|
1155
|
+
result.status #=> "failed"
|
1156
|
+
|
1157
|
+
# Fault data
|
1158
|
+
result.reason #=> "Build tool not found"
|
1159
|
+
result.cause #=> <CMDx::FailFault>
|
1160
|
+
result.metadata #=> { error_code: "BUILD_TOOL.NOT_FOUND" }
|
1161
|
+
```
|
1162
|
+
|
1163
|
+
## Lifecycle Information
|
1164
|
+
|
1165
|
+
Results provide comprehensive methods for checking execution state and status:
|
1166
|
+
|
1167
|
+
```ruby
|
1168
|
+
result = BuildApplication.execute(version: "1.2.3")
|
1169
|
+
|
1170
|
+
# State predicates (execution lifecycle)
|
1171
|
+
result.complete? #=> true (successful completion)
|
1172
|
+
result.interrupted? #=> false (no interruption)
|
1173
|
+
result.executed? #=> true (execution finished)
|
1174
|
+
|
1175
|
+
# Status predicates (execution outcome)
|
1176
|
+
result.success? #=> true (successful execution)
|
1177
|
+
result.failed? #=> false (no failure)
|
1178
|
+
result.skipped? #=> false (not skipped)
|
1179
|
+
|
1180
|
+
# Outcome categorization
|
1181
|
+
result.good? #=> true (success or skipped)
|
1182
|
+
result.bad? #=> false (skipped or failed)
|
1183
|
+
```
|
1184
|
+
|
1185
|
+
## Outcome Analysis
|
1186
|
+
|
1187
|
+
Results provide unified outcome determination depending on the fault causal chain:
|
1188
|
+
|
1189
|
+
```ruby
|
1190
|
+
result = BuildApplication.execute(version: "1.2.3")
|
1191
|
+
|
1192
|
+
result.outcome #=> "success" (state and status)
|
1193
|
+
```
|
1194
|
+
|
1195
|
+
## Chain Analysis
|
1196
|
+
|
1197
|
+
Use these methods to trace the root cause of faults or trace the cause points.
|
1198
|
+
|
1199
|
+
```ruby
|
1200
|
+
result = DeploymentWorkflow.execute(app_name: "webapp")
|
1201
|
+
|
1202
|
+
if result.failed?
|
1203
|
+
# Find the original cause of failure
|
1204
|
+
if original_failure = result.caused_failure
|
1205
|
+
puts "Root cause: #{original_failure.task.class.name}"
|
1206
|
+
puts "Reason: #{original_failure.reason}"
|
1207
|
+
end
|
1208
|
+
|
1209
|
+
# Find what threw the failure to this result
|
1210
|
+
if throwing_task = result.threw_failure
|
1211
|
+
puts "Failure source: #{throwing_task.task.class.name}"
|
1212
|
+
puts "Reason: #{throwing_task.reason}"
|
1213
|
+
end
|
1214
|
+
|
1215
|
+
# Failure classification
|
1216
|
+
result.caused_failure? #=> true if this result was the original cause
|
1217
|
+
result.threw_failure? #=> true if this result threw a failure
|
1218
|
+
result.thrown_failure? #=> true if this result received a thrown failure
|
1219
|
+
end
|
1220
|
+
```
|
1221
|
+
|
1222
|
+
## Index and Position
|
1223
|
+
|
1224
|
+
Results track their position within execution chains:
|
1225
|
+
|
1226
|
+
```ruby
|
1227
|
+
result = BuildApplication.execute(version: "1.2.3")
|
1228
|
+
|
1229
|
+
# Position in execution sequence
|
1230
|
+
result.index #=> 0 (first task in chain)
|
1231
|
+
|
1232
|
+
# Access via chain
|
1233
|
+
result.chain.results[result.index] == result #=> true
|
1234
|
+
```
|
1235
|
+
|
1236
|
+
## Handlers
|
1237
|
+
|
1238
|
+
Use result handlers for clean, functional-style conditional logic. Handlers return the result object, enabling method chaining and fluent interfaces.
|
1239
|
+
|
1240
|
+
```ruby
|
1241
|
+
result = BuildApplication.execute(version: "1.2.3")
|
1242
|
+
|
1243
|
+
# Status-based handlers
|
1244
|
+
result
|
1245
|
+
.on_success { |result| notify_deployment_ready(result) }
|
1246
|
+
.on_failed { |result| handle_build_failure(result) }
|
1247
|
+
.on_skipped { |result| log_skip_reason(result) }
|
1248
|
+
|
1249
|
+
# State-based handlers
|
1250
|
+
result
|
1251
|
+
.on_complete { |result| update_build_status(result) }
|
1252
|
+
.on_interrupted { |result| cleanup_partial_artifacts(result) }
|
1253
|
+
|
1254
|
+
# Outcome-based handlers
|
1255
|
+
result
|
1256
|
+
.on_good { |result| increment_success_counter(result) }
|
1257
|
+
.on_bad { |result| alert_operations_team(result) }
|
1258
|
+
```
|
1259
|
+
|
1260
|
+
## Pattern Matching
|
1261
|
+
|
1262
|
+
Results support Ruby's pattern matching through array and hash deconstruction:
|
1263
|
+
|
1264
|
+
> [!IMPORTANT]
|
1265
|
+
> Pattern matching requires Ruby 3.0+
|
1266
|
+
|
1267
|
+
### Array Pattern
|
1268
|
+
|
1269
|
+
```ruby
|
1270
|
+
result = BuildApplication.execute(version: "1.2.3")
|
1271
|
+
|
1272
|
+
case result
|
1273
|
+
in ["complete", "success"]
|
1274
|
+
redirect_to build_success_page
|
1275
|
+
in ["interrupted", "failed"]
|
1276
|
+
retry_build_with_backoff(result)
|
1277
|
+
in ["interrupted", "skipped"]
|
1278
|
+
log_skip_and_continue
|
1279
|
+
end
|
1280
|
+
```
|
1281
|
+
|
1282
|
+
### Hash Pattern
|
1283
|
+
|
1284
|
+
```ruby
|
1285
|
+
result = BuildApplication.execute(version: "1.2.3")
|
1286
|
+
|
1287
|
+
case result
|
1288
|
+
in { state: "complete", status: "success" }
|
1289
|
+
celebrate_build_success
|
1290
|
+
in { status: "failed", metadata: { retryable: true } }
|
1291
|
+
schedule_build_retry(result)
|
1292
|
+
in { bad: true, metadata: { reason: String => reason } }
|
1293
|
+
escalate_build_error("Build failed: #{reason}")
|
1294
|
+
end
|
1295
|
+
```
|
1296
|
+
|
1297
|
+
### Pattern Guards
|
1298
|
+
|
1299
|
+
```ruby
|
1300
|
+
case result
|
1301
|
+
in { status: "failed", metadata: { attempts: n } } if n < 3
|
1302
|
+
retry_build_with_delay(result, n * 2)
|
1303
|
+
in { status: "failed", metadata: { attempts: n } } if n >= 3
|
1304
|
+
mark_build_permanently_failed(result)
|
1305
|
+
in { runtime: time } if time > performance_threshold
|
1306
|
+
investigate_build_performance(result)
|
1307
|
+
end
|
1308
|
+
```
|
1309
|
+
|
1310
|
+
---
|
1311
|
+
|
1312
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/outcomes/states.md
|
1313
|
+
---
|
1314
|
+
|
1315
|
+
# Outcomes - States
|
1316
|
+
|
1317
|
+
States represent the execution lifecycle condition of task execution, tracking
|
1318
|
+
the progress of tasks through their complete execution journey. States provide
|
1319
|
+
insight into where a task is in its lifecycle and enable lifecycle-based
|
1320
|
+
decision making and monitoring.
|
1321
|
+
|
1322
|
+
## Definitions
|
1323
|
+
|
1324
|
+
| State | Description |
|
1325
|
+
| ----- | ----------- |
|
1326
|
+
| `initialized` | Task created but execution not yet started. Default state for new tasks. |
|
1327
|
+
| `executing` | Task is actively running its business logic. Transient state during execution. |
|
1328
|
+
| `complete` | Task finished execution successfully without any interruption or halt. |
|
1329
|
+
| `interrupted` | Task execution was stopped due to a fault, exception, or explicit halt. |
|
1330
|
+
|
1331
|
+
State-Status combinations:
|
1332
|
+
|
1333
|
+
| State | Status | Meaning |
|
1334
|
+
| ----- | ------ | ------- |
|
1335
|
+
| `initialized` | `success` | Task created, not yet executed |
|
1336
|
+
| `executing` | `success` | Task currently running |
|
1337
|
+
| `complete` | `success` | Task finished successfully |
|
1338
|
+
| `complete` | `skipped` | Task finished by skipping execution |
|
1339
|
+
| `interrupted` | `failed` | Task stopped due to failure |
|
1340
|
+
| `interrupted` | `skipped` | Task stopped by skip condition |
|
1341
|
+
|
1342
|
+
## Transitions
|
1343
|
+
|
1344
|
+
> [!CAUTION]
|
1345
|
+
> States are automatically managed during task execution and should **never** be modified manually. State transitions are handled internally by the CMDx framework.
|
1346
|
+
|
1347
|
+
```ruby
|
1348
|
+
# Valid state transition flow
|
1349
|
+
initialized → executing → complete (successful execution)
|
1350
|
+
initialized → executing → interrupted (skipped/failed execution)
|
1351
|
+
```
|
1352
|
+
|
1353
|
+
## Predicates
|
1354
|
+
|
1355
|
+
Use state predicates to check the current execution lifecycle:
|
1356
|
+
|
1357
|
+
```ruby
|
1358
|
+
result = ProcessVideoUpload.execute
|
1359
|
+
|
1360
|
+
# Individual state checks
|
1361
|
+
result.initialized? #=> false (after execution)
|
1362
|
+
result.executing? #=> false (after execution)
|
1363
|
+
result.complete? #=> true (successful completion)
|
1364
|
+
result.interrupted? #=> false (no interruption)
|
1365
|
+
|
1366
|
+
# State categorization
|
1367
|
+
result.executed? #=> true (complete OR interrupted)
|
1368
|
+
```
|
1369
|
+
|
1370
|
+
## Handlers
|
1371
|
+
|
1372
|
+
Use state-based handlers for lifecycle event handling. The `on_executed` handler is particularly useful for cleanup operations that should run regardless of success, skipped, or failure.
|
1373
|
+
|
1374
|
+
```ruby
|
1375
|
+
result = ProcessVideoUpload.execute
|
1376
|
+
|
1377
|
+
# Individual state handlers
|
1378
|
+
result
|
1379
|
+
.on_complete { |result| send_upload_notification(result) }
|
1380
|
+
.on_interrupted { |result| cleanup_temp_files(result) }
|
1381
|
+
.on_executed { |result| log_upload_metrics(result) }
|
1382
|
+
```
|
1383
|
+
|
1384
|
+
---
|
1385
|
+
|
1386
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/outcomes/statuses.md
|
1387
|
+
---
|
1388
|
+
|
1389
|
+
# Outcomes - Statuses
|
1390
|
+
|
1391
|
+
Statuses represent the business outcome of task execution logic, indicating how the task's business logic concluded. Statuses differ from execution states by focusing on the business outcome rather than the technical execution lifecycle. Understanding statuses is crucial for implementing proper business logic branching and error handling.
|
1392
|
+
|
1393
|
+
## Definitions
|
1394
|
+
|
1395
|
+
| Status | Description |
|
1396
|
+
| ------ | ----------- |
|
1397
|
+
| `success` | Task execution completed successfully with expected business outcome. Default status for all tasks. |
|
1398
|
+
| `skipped` | Task intentionally stopped execution because conditions weren't met or continuation was unnecessary. |
|
1399
|
+
| `failed` | Task stopped execution due to business rule violations, validation errors, or exceptions. |
|
1400
|
+
|
1401
|
+
## Transitions
|
1402
|
+
|
1403
|
+
> [!IMPORTANT]
|
1404
|
+
> Status transitions are unidirectional and final. Once a task is marked as skipped or failed, it cannot return to success status. Design your business logic accordingly.
|
1405
|
+
|
1406
|
+
```ruby
|
1407
|
+
# Valid status transitions
|
1408
|
+
success → skipped # via skip!
|
1409
|
+
success → failed # via fail! or exception
|
1410
|
+
|
1411
|
+
# Invalid transitions (will raise errors)
|
1412
|
+
skipped → success # ❌ Cannot transition
|
1413
|
+
skipped → failed # ❌ Cannot transition
|
1414
|
+
failed → success # ❌ Cannot transition
|
1415
|
+
failed → skipped # ❌ Cannot transition
|
1416
|
+
```
|
1417
|
+
|
1418
|
+
## Predicates
|
1419
|
+
|
1420
|
+
Use status predicates to check execution outcomes:
|
1421
|
+
|
1422
|
+
```ruby
|
1423
|
+
result = ProcessNotification.execute
|
1424
|
+
|
1425
|
+
# Individual status checks
|
1426
|
+
result.success? #=> true/false
|
1427
|
+
result.skipped? #=> true/false
|
1428
|
+
result.failed? #=> true/false
|
1429
|
+
|
1430
|
+
# Outcome categorization
|
1431
|
+
result.good? #=> true if success OR skipped
|
1432
|
+
result.bad? #=> true if skipped OR failed (not success)
|
1433
|
+
```
|
1434
|
+
|
1435
|
+
## Handlers
|
1436
|
+
|
1437
|
+
Use status-based handlers for business logic branching. The `on_good` and `on_bad` handlers are particularly useful for handling success/skip vs failed outcomes respectively.
|
1438
|
+
|
1439
|
+
```ruby
|
1440
|
+
result = ProcessNotification.execute
|
1441
|
+
|
1442
|
+
# Individual status handlers
|
1443
|
+
result
|
1444
|
+
.on_success { |result| mark_notification_sent(result) }
|
1445
|
+
.on_skipped { |result| log_notification_skipped(result) }
|
1446
|
+
.on_failed { |result| queue_retry_notification(result) }
|
1447
|
+
|
1448
|
+
# Outcome-based handlers
|
1449
|
+
result
|
1450
|
+
.on_good { |result| update_message_stats(result) }
|
1451
|
+
.on_bad { |result| track_delivery_failure(result) }
|
1452
|
+
```
|
1453
|
+
|
1454
|
+
---
|
1455
|
+
|
1456
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/attributes/definitions.md
|
1457
|
+
---
|
1458
|
+
|
1459
|
+
# Attributes - Definitions
|
1460
|
+
|
1461
|
+
Attributes define the interface between task callers and implementation, enabling automatic validation, type coercion, and method generation. They provide a contract to verify that task execution arguments match expected requirements and structure.
|
1462
|
+
|
1463
|
+
## Declarations
|
1464
|
+
|
1465
|
+
> [!TIP]
|
1466
|
+
> Prefer using the `required` and `optional` alias for `attributes` for brevity and to clearly signal intent.
|
1467
|
+
|
1468
|
+
### Optional
|
1469
|
+
|
1470
|
+
Optional attributes return `nil` when not provided.
|
1471
|
+
|
1472
|
+
```ruby
|
1473
|
+
class ScheduleEvent < CMDx::Task
|
1474
|
+
attribute :title
|
1475
|
+
attributes :duration, :location
|
1476
|
+
|
1477
|
+
# Alias for attributes (preferred)
|
1478
|
+
optional :description
|
1479
|
+
optional :visibility, :attendees
|
1480
|
+
|
1481
|
+
def work
|
1482
|
+
title #=> "Team Standup"
|
1483
|
+
duration #=> 30
|
1484
|
+
location #=> nil
|
1485
|
+
description #=> nil
|
1486
|
+
visibility #=> nil
|
1487
|
+
attendees #=> ["alice@company.com", "bob@company.com"]
|
1488
|
+
end
|
1489
|
+
end
|
1490
|
+
|
1491
|
+
# Attributes passed as keyword arguments
|
1492
|
+
ScheduleEvent.execute(
|
1493
|
+
title: "Team Standup",
|
1494
|
+
duration: 30,
|
1495
|
+
attendees: ["alice@company.com", "bob@company.com"]
|
1496
|
+
)
|
1497
|
+
```
|
1498
|
+
|
1499
|
+
### Required
|
1500
|
+
|
1501
|
+
Required attributes must be provided in call arguments or task execution will fail.
|
1502
|
+
|
1503
|
+
```ruby
|
1504
|
+
class PublishArticle < CMDx::Task
|
1505
|
+
attribute :title, required: true
|
1506
|
+
attributes :content, :author_id, required: true
|
1507
|
+
|
1508
|
+
# Alias for attributes => required: true (preferred)
|
1509
|
+
required :category
|
1510
|
+
required :status, :tags
|
1511
|
+
|
1512
|
+
def work
|
1513
|
+
title #=> "Getting Started with Ruby"
|
1514
|
+
content #=> "This is a comprehensive guide..."
|
1515
|
+
author_id #=> 42
|
1516
|
+
category #=> "programming"
|
1517
|
+
status #=> :published
|
1518
|
+
tags #=> ["ruby", "beginner"]
|
1519
|
+
end
|
1520
|
+
end
|
1521
|
+
|
1522
|
+
# Attributes passed as keyword arguments
|
1523
|
+
PublishArticle.execute(
|
1524
|
+
title: "Getting Started with Ruby",
|
1525
|
+
content: "This is a comprehensive guide...",
|
1526
|
+
author_id: 42,
|
1527
|
+
category: "programming",
|
1528
|
+
status: :published,
|
1529
|
+
tags: ["ruby", "beginner"]
|
1530
|
+
)
|
1531
|
+
```
|
1532
|
+
|
1533
|
+
## Sources
|
1534
|
+
|
1535
|
+
Attributes delegate to accessible objects within the task. The default source is `:context`, but any accessible method or object can serve as an attribute source.
|
1536
|
+
|
1537
|
+
### Context
|
1538
|
+
|
1539
|
+
```ruby
|
1540
|
+
class BackupDatabase < CMDx::Task
|
1541
|
+
# Default source is :context
|
1542
|
+
required :database_name
|
1543
|
+
optional :compression_level
|
1544
|
+
|
1545
|
+
# Explicitly specify context source
|
1546
|
+
attribute :backup_path, source: :context
|
1547
|
+
|
1548
|
+
def work
|
1549
|
+
database_name #=> context.database_name
|
1550
|
+
backup_path #=> context.backup_path
|
1551
|
+
compression_level #=> context.compression_level
|
1552
|
+
end
|
1553
|
+
end
|
1554
|
+
```
|
1555
|
+
|
1556
|
+
### Symbol References
|
1557
|
+
|
1558
|
+
Reference instance methods by symbol for dynamic source values:
|
1559
|
+
|
1560
|
+
```ruby
|
1561
|
+
class BackupDatabase < CMDx::Task
|
1562
|
+
attributes :host, :credentials, source: :database_config
|
1563
|
+
|
1564
|
+
# Access from declared attributes
|
1565
|
+
attribute :connection_string, source: :credentials
|
1566
|
+
|
1567
|
+
def work
|
1568
|
+
# Your logic here...
|
1569
|
+
end
|
1570
|
+
|
1571
|
+
private
|
1572
|
+
|
1573
|
+
def database_config
|
1574
|
+
@database_config ||= DatabaseConfig.find(context.database_name)
|
1575
|
+
end
|
1576
|
+
end
|
1577
|
+
```
|
1578
|
+
|
1579
|
+
### Proc or Lambda
|
1580
|
+
|
1581
|
+
Use anonymous functions for dynamic source values:
|
1582
|
+
|
1583
|
+
```ruby
|
1584
|
+
class BackupDatabase < CMDx::Task
|
1585
|
+
# Proc
|
1586
|
+
attribute :timestamp, source: proc { Time.current }
|
1587
|
+
|
1588
|
+
# Lambda
|
1589
|
+
attribute :server, source: -> { Current.server }
|
1590
|
+
end
|
1591
|
+
```
|
1592
|
+
|
1593
|
+
### Class or Module
|
1594
|
+
|
1595
|
+
For complex source logic, use classes or modules:
|
1596
|
+
|
1597
|
+
```ruby
|
1598
|
+
class DatabaseResolver
|
1599
|
+
def self.call(task)
|
1600
|
+
Database.find(task.context.database_name)
|
1601
|
+
end
|
1602
|
+
end
|
1603
|
+
|
1604
|
+
class BackupDatabase < CMDx::Task
|
1605
|
+
# Class or Module
|
1606
|
+
attribute :schema, source: DatabaseResolver
|
1607
|
+
|
1608
|
+
# Instance
|
1609
|
+
attribute :metadata, source: DatabaseResolver.new
|
1610
|
+
end
|
1611
|
+
```
|
1612
|
+
|
1613
|
+
## Nesting
|
1614
|
+
|
1615
|
+
Nested attributes enable complex attribute structures where child attributes automatically inherit their parent as the source. This allows validation and access of structured data.
|
1616
|
+
|
1617
|
+
> [!NOTE]
|
1618
|
+
> All options available to top-level attributes are available to nested attributes, eg: naming, coercions, and validations
|
1619
|
+
|
1620
|
+
```ruby
|
1621
|
+
class ConfigureServer < CMDx::Task
|
1622
|
+
# Required parent with required children
|
1623
|
+
required :network_config do
|
1624
|
+
required :hostname, :port, :protocol, :subnet
|
1625
|
+
optional :load_balancer
|
1626
|
+
attribute :firewall_rules
|
1627
|
+
end
|
1628
|
+
|
1629
|
+
# Optional parent with conditional children
|
1630
|
+
optional :ssl_config do
|
1631
|
+
required :certificate_path, :private_key # Only required if ssl_config provided
|
1632
|
+
optional :enable_http2, prefix: true
|
1633
|
+
end
|
1634
|
+
|
1635
|
+
# Multi-level nesting
|
1636
|
+
attribute :monitoring do
|
1637
|
+
required :provider
|
1638
|
+
|
1639
|
+
optional :alerting do
|
1640
|
+
required :threshold_percentage
|
1641
|
+
optional :notification_channel
|
1642
|
+
end
|
1643
|
+
end
|
1644
|
+
|
1645
|
+
def work
|
1646
|
+
network_config #=> { hostname: "api.company.com" ... }
|
1647
|
+
hostname #=> "api.company.com"
|
1648
|
+
load_balancer #=> nil
|
1649
|
+
end
|
1650
|
+
end
|
1651
|
+
|
1652
|
+
ConfigureServer.execute(
|
1653
|
+
server_id: "srv-001",
|
1654
|
+
network_config: {
|
1655
|
+
hostname: "api.company.com",
|
1656
|
+
port: 443,
|
1657
|
+
protocol: "https",
|
1658
|
+
subnet: "10.0.1.0/24",
|
1659
|
+
firewall_rules: "allow_web_traffic"
|
1660
|
+
},
|
1661
|
+
monitoring: {
|
1662
|
+
provider: "datadog",
|
1663
|
+
alerting: {
|
1664
|
+
threshold_percentage: 85.0,
|
1665
|
+
notification_channel: "slack"
|
1666
|
+
}
|
1667
|
+
}
|
1668
|
+
)
|
1669
|
+
```
|
1670
|
+
|
1671
|
+
> [!IMPORTANT]
|
1672
|
+
> Child attributes are only required when their parent attribute is provided, enabling flexible optional structures.
|
1673
|
+
|
1674
|
+
## Error Handling
|
1675
|
+
|
1676
|
+
Attribute validation failures result in structured error information with details about each failed attribute.
|
1677
|
+
|
1678
|
+
> [!NOTE]
|
1679
|
+
> Nested attributes are only ever evaluated when the parent attribute is available and valid.
|
1680
|
+
|
1681
|
+
```ruby
|
1682
|
+
class ConfigureServer < CMDx::Task
|
1683
|
+
required :server_id, :environment
|
1684
|
+
required :network_config do
|
1685
|
+
required :hostname, :port
|
1686
|
+
end
|
1687
|
+
|
1688
|
+
def work
|
1689
|
+
# Your logic here...
|
1690
|
+
end
|
1691
|
+
end
|
1692
|
+
|
1693
|
+
# Missing required top-level attributes
|
1694
|
+
result = ConfigureServer.execute(server_id: "srv-001")
|
1695
|
+
|
1696
|
+
result.state #=> "interrupted"
|
1697
|
+
result.status #=> "failed"
|
1698
|
+
result.reason #=> "environment is required. network_config is required."
|
1699
|
+
result.metadata #=> {
|
1700
|
+
# messages: {
|
1701
|
+
# environment: ["is required"],
|
1702
|
+
# network_config: ["is required"]
|
1703
|
+
# }
|
1704
|
+
# }
|
1705
|
+
|
1706
|
+
# Missing required nested attributes
|
1707
|
+
result = ConfigureServer.execute(
|
1708
|
+
server_id: "srv-001",
|
1709
|
+
environment: "production",
|
1710
|
+
network_config: { hostname: "api.company.com" } # Missing port
|
1711
|
+
)
|
1712
|
+
|
1713
|
+
result.state #=> "interrupted"
|
1714
|
+
result.status #=> "failed"
|
1715
|
+
result.reason #=> "port is required."
|
1716
|
+
result.metadata #=> {
|
1717
|
+
# messages: {
|
1718
|
+
# port: ["is required"]
|
1719
|
+
# }
|
1720
|
+
# }
|
1721
|
+
|
1722
|
+
---
|
1723
|
+
|
1724
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/attributes/naming.md
|
1725
|
+
---
|
1726
|
+
|
1727
|
+
# Attributes - Naming
|
1728
|
+
|
1729
|
+
Attribute naming provides method name customization to prevent conflicts and enable flexible attribute access patterns. When attributes share names with existing methods or when multiple attributes from different sources have the same name, affixing ensures clean method resolution within tasks.
|
1730
|
+
|
1731
|
+
> [!NOTE]
|
1732
|
+
> Affixing modifies only the generated accessor method names within tasks.
|
1733
|
+
|
1734
|
+
## Prefix
|
1735
|
+
|
1736
|
+
Adds a prefix to the generated accessor method name.
|
1737
|
+
|
1738
|
+
```ruby
|
1739
|
+
class GenerateReport < CMDx::Task
|
1740
|
+
# Dynamic from attribute source
|
1741
|
+
attribute :template, prefix: true
|
1742
|
+
|
1743
|
+
# Static
|
1744
|
+
attribute :format, prefix: "report_"
|
1745
|
+
|
1746
|
+
def work
|
1747
|
+
context_template #=> "monthly_sales"
|
1748
|
+
report_format #=> "pdf"
|
1749
|
+
end
|
1750
|
+
end
|
1751
|
+
|
1752
|
+
# Attributes passed as original attribute names
|
1753
|
+
GenerateReport.execute(template: "monthly_sales", format: "pdf")
|
1754
|
+
```
|
1755
|
+
|
1756
|
+
## Suffix
|
1757
|
+
|
1758
|
+
Adds a suffix to the generated accessor method name.
|
1759
|
+
|
1760
|
+
```ruby
|
1761
|
+
class DeployApplication < CMDx::Task
|
1762
|
+
# Dynamic from attribute source
|
1763
|
+
attribute :branch, suffix: true
|
1764
|
+
|
1765
|
+
# Static
|
1766
|
+
attribute :version, suffix: "_tag"
|
1767
|
+
|
1768
|
+
def work
|
1769
|
+
branch_context #=> "main"
|
1770
|
+
version_tag #=> "v1.2.3"
|
1771
|
+
end
|
1772
|
+
end
|
1773
|
+
|
1774
|
+
# Attributes passed as original attribute names
|
1775
|
+
DeployApplication.execute(branch: "main", version: "v1.2.3")
|
1776
|
+
```
|
1777
|
+
|
1778
|
+
## As
|
1779
|
+
|
1780
|
+
Completely renames the generated accessor method.
|
1781
|
+
|
1782
|
+
```ruby
|
1783
|
+
class ScheduleMaintenance < CMDx::Task
|
1784
|
+
attribute :scheduled_at, as: :when
|
1785
|
+
|
1786
|
+
def work
|
1787
|
+
when #=> <DateTime>
|
1788
|
+
end
|
1789
|
+
end
|
1790
|
+
|
1791
|
+
# Attributes passed as original attribute names
|
1792
|
+
ScheduleMaintenance.execute(scheduled_at: DateTime.new(2024, 12, 15, 2, 0, 0))
|
1793
|
+
```
|
1794
|
+
|
1795
|
+
---
|
1796
|
+
|
1797
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/attributes/coercions.md
|
1798
|
+
---
|
1799
|
+
|
1800
|
+
# Attributes - Coercions
|
1801
|
+
|
1802
|
+
Attribute coercions automatically convert task arguments to expected types, ensuring type safety while providing flexible input handling. Coercions transform raw input values into the specified types, supporting simple conversions like string-to-integer and complex operations like JSON parsing.
|
1803
|
+
|
1804
|
+
## Usage
|
1805
|
+
|
1806
|
+
Define attribute types to enable automatic coercion:
|
1807
|
+
|
1808
|
+
```ruby
|
1809
|
+
class ParseMetrics < CMDx::Task
|
1810
|
+
# Coerce into a symbol
|
1811
|
+
attribute :measurement_type, type: :symbol
|
1812
|
+
|
1813
|
+
# Coerce into a rational fallback to big decimal
|
1814
|
+
attribute :value, type: [:rational, :big_decimal]
|
1815
|
+
|
1816
|
+
# Coerce with options
|
1817
|
+
attribute :recorded_at, type: :date, strptime: "%m-%d-%Y"
|
1818
|
+
|
1819
|
+
def work
|
1820
|
+
measurement_type #=> :temperature
|
1821
|
+
recorded_at #=> <Date 2024-01-23>
|
1822
|
+
value #=> 98.6 (Float)
|
1823
|
+
end
|
1824
|
+
end
|
1825
|
+
|
1826
|
+
ParseMetrics.execute(
|
1827
|
+
measurement_type: "temperature",
|
1828
|
+
recorded_at: "01-23-2020",
|
1829
|
+
value: "98.6"
|
1830
|
+
)
|
1831
|
+
```
|
1832
|
+
|
1833
|
+
> [!TIP]
|
1834
|
+
> Specify multiple coercion types for attributes that could be a variety of value formats. CMDx attempts each type in order until one succeeds.
|
1835
|
+
|
1836
|
+
## Built-in Coercions
|
1837
|
+
|
1838
|
+
| Type | Options | Description | Examples |
|
1839
|
+
|------|---------|-------------|----------|
|
1840
|
+
| `:array` | | Array conversion with JSON support | `"val"` → `["val"]`<br>`"[1,2,3]"` → `[1, 2, 3]` |
|
1841
|
+
| `:big_decimal` | `:precision` | High-precision decimal | `"123.456"` → `BigDecimal("123.456")` |
|
1842
|
+
| `:boolean` | | Boolean with text patterns | `"yes"` → `true`, `"no"` → `false` |
|
1843
|
+
| `:complex` | | Complex numbers | `"1+2i"` → `Complex(1, 2)` |
|
1844
|
+
| `:date` | `:strptime` | Date objects | `"2024-01-23"` → `Date.new(2024, 1, 23)` |
|
1845
|
+
| `:datetime` | `:strptime` | DateTime objects | `"2024-01-23 10:30"` → `DateTime.new(2024, 1, 23, 10, 30)` |
|
1846
|
+
| `:float` | | Floating-point numbers | `"123.45"` → `123.45` |
|
1847
|
+
| `:hash` | | Hash conversion with JSON support | `'{"a":1}'` → `{"a" => 1}` |
|
1848
|
+
| `:integer` | | Integer with hex/octal support | `"0xFF"` → `255`, `"077"` → `63` |
|
1849
|
+
| `:rational` | | Rational numbers | `"1/2"` → `Rational(1, 2)` |
|
1850
|
+
| `:string` | | String conversion | `123` → `"123"` |
|
1851
|
+
| `:symbol` | | Symbol conversion | `"abc"` → `:abc` |
|
1852
|
+
| `:time` | `:strptime` | Time objects | `"10:30:00"` → `Time.new(2024, 1, 23, 10, 30)` |
|
1853
|
+
|
1854
|
+
## Declarations
|
1855
|
+
|
1856
|
+
> [!IMPORTANT]
|
1857
|
+
> Coercions must raise a CMDx::CoercionError and its message is used as part of the fault reason and metadata.
|
1858
|
+
|
1859
|
+
### Proc or Lambda
|
1860
|
+
|
1861
|
+
Use anonymous functions for simple coercion logic:
|
1862
|
+
|
1863
|
+
```ruby
|
1864
|
+
class TransformCoordinates < CMDx::Task
|
1865
|
+
# Proc
|
1866
|
+
register :callback, :geolocation, proc do |value, options = {}|
|
1867
|
+
begin
|
1868
|
+
Geolocation(value)
|
1869
|
+
rescue StandardError
|
1870
|
+
raise CMDx::CoercionError, "could not convert into a geolocation"
|
1871
|
+
end
|
1872
|
+
end
|
1873
|
+
|
1874
|
+
# Lambda
|
1875
|
+
register :callback, :geolocation, ->(value, options = {}) {
|
1876
|
+
begin
|
1877
|
+
Geolocation(value)
|
1878
|
+
rescue StandardError
|
1879
|
+
raise CMDx::CoercionError, "could not convert into a geolocation"
|
1880
|
+
end
|
1881
|
+
}
|
1882
|
+
end
|
1883
|
+
```
|
1884
|
+
|
1885
|
+
### Class or Module
|
1886
|
+
|
1887
|
+
Register custom coercion logic for specialized type handling:
|
1888
|
+
|
1889
|
+
```ruby
|
1890
|
+
class GeolocationCoercion
|
1891
|
+
def self.call(value, options = {})
|
1892
|
+
Geolocation(value)
|
1893
|
+
rescue StandardError
|
1894
|
+
raise CMDx::CoercionError, "could not convert into a geolocation"
|
1895
|
+
end
|
1896
|
+
end
|
1897
|
+
|
1898
|
+
class TransformCoordinates < CMDx::Task
|
1899
|
+
register :coercion, :geolocation, GeolocationCoercion
|
1900
|
+
|
1901
|
+
attribute :latitude, type: :geolocation
|
1902
|
+
end
|
1903
|
+
```
|
1904
|
+
|
1905
|
+
## Removals
|
1906
|
+
|
1907
|
+
Remove custom coercions when no longer needed:
|
1908
|
+
|
1909
|
+
> [!WARNING]
|
1910
|
+
> Only one removal operation is allowed per `deregister` call. Multiple removals require separate calls.
|
1911
|
+
|
1912
|
+
```ruby
|
1913
|
+
class TransformCoordinates < CMDx::Task
|
1914
|
+
deregister :coercion, :geolocation
|
1915
|
+
end
|
1916
|
+
```
|
1917
|
+
|
1918
|
+
## Error Handling
|
1919
|
+
|
1920
|
+
Coercion failures provide detailed error information including attribute paths, attempted types, and specific failure reasons:
|
1921
|
+
|
1922
|
+
```ruby
|
1923
|
+
class AnalyzePerformance < CMDx::Task
|
1924
|
+
attribute :iterations, type: :integer
|
1925
|
+
attribute :score, type: [:float, :big_decimal]
|
1926
|
+
|
1927
|
+
def work
|
1928
|
+
# Your logic here...
|
1929
|
+
end
|
1930
|
+
end
|
1931
|
+
|
1932
|
+
result = AnalyzePerformance.execute(
|
1933
|
+
iterations: "not-a-number",
|
1934
|
+
score: "invalid-float"
|
1935
|
+
)
|
1936
|
+
|
1937
|
+
result.state #=> "interrupted"
|
1938
|
+
result.status #=> "failed"
|
1939
|
+
result.reason #=> "iterations could not coerce into an integer. score could not coerce into one of: float, big_decimal."
|
1940
|
+
result.metadata #=> {
|
1941
|
+
# messages: {
|
1942
|
+
# iterations: ["could not coerce into an integer"],
|
1943
|
+
# score: ["could not coerce into one of: float, big_decimal"]
|
1944
|
+
# }
|
1945
|
+
# }
|
1946
|
+
|
1947
|
+
---
|
1948
|
+
|
1949
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/attributes/validations.md
|
1950
|
+
---
|
1951
|
+
|
1952
|
+
# Attributes - Validations
|
1953
|
+
|
1954
|
+
Attribute validations ensure task arguments meet specified requirements before execution begins. Validations run after coercions and provide declarative rules for data integrity, supporting both built-in validators and custom validation logic.
|
1955
|
+
|
1956
|
+
## Usage
|
1957
|
+
|
1958
|
+
Define validation rules on attributes to enforce data requirements:
|
1959
|
+
|
1960
|
+
```ruby
|
1961
|
+
class ProcessSubscription < CMDx::Task
|
1962
|
+
# Required field with presence validation
|
1963
|
+
attribute :user_id, presence: true
|
1964
|
+
|
1965
|
+
# String with length constraints
|
1966
|
+
attribute :preferences, length: { minimum: 10, maximum: 500 }
|
1967
|
+
|
1968
|
+
# Numeric range validation
|
1969
|
+
attribute :tier_level, inclusion: { in: 1..5 }
|
1970
|
+
|
1971
|
+
# Format validation for email
|
1972
|
+
attribute :contact_email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
|
1973
|
+
|
1974
|
+
def work
|
1975
|
+
user_id #=> "98765"
|
1976
|
+
preferences #=> "Send weekly digest emails"
|
1977
|
+
tier_level #=> 3
|
1978
|
+
contact_email #=> "user@company.com"
|
1979
|
+
end
|
1980
|
+
end
|
1981
|
+
|
1982
|
+
ProcessSubscription.execute(
|
1983
|
+
user_id: "98765",
|
1984
|
+
preferences: "Send weekly digest emails",
|
1985
|
+
tier_level: 3,
|
1986
|
+
contact_email: "user@company.com"
|
1987
|
+
)
|
1988
|
+
```
|
1989
|
+
|
1990
|
+
> [!TIP]
|
1991
|
+
> Validations run after coercions, so you can validate the final coerced values rather than raw input.
|
1992
|
+
|
1993
|
+
## Built-in Validators
|
1994
|
+
|
1995
|
+
### Common Options
|
1996
|
+
|
1997
|
+
This list of options is available to all validators:
|
1998
|
+
|
1999
|
+
| Option | Description |
|
2000
|
+
|--------|-------------|
|
2001
|
+
| `:allow_nil` | Skip validation when value is `nil` |
|
2002
|
+
| `:if` | Symbol, proc, lambda, or callable determining when to validate |
|
2003
|
+
| `:unless` | Symbol, proc, lambda, or callable determining when to skip validation |
|
2004
|
+
| `:message` | Custom error message for validation failures |
|
2005
|
+
|
2006
|
+
### Exclusion
|
2007
|
+
|
2008
|
+
```ruby
|
2009
|
+
class ProcessProduct < CMDx::Task
|
2010
|
+
attribute :status, exclusion: { in: %w[recalled archived] }
|
2011
|
+
|
2012
|
+
def work
|
2013
|
+
# Your logic here...
|
2014
|
+
end
|
2015
|
+
end
|
2016
|
+
```
|
2017
|
+
|
2018
|
+
| Options | Description |
|
2019
|
+
|---------|-------------|
|
2020
|
+
| `:in` | The collection of forbidden values or range |
|
2021
|
+
| `:within` | Alias for :in option |
|
2022
|
+
| `:of_message` | Custom message for discrete value exclusions |
|
2023
|
+
| `:in_message` | Custom message for range-based exclusions |
|
2024
|
+
| `:within_message` | Alias for :in_message option |
|
2025
|
+
|
2026
|
+
### Format
|
2027
|
+
|
2028
|
+
```ruby
|
2029
|
+
class ProcessProduct < CMDx::Task
|
2030
|
+
attribute :sku, format: /\A[A-Z]{3}-[0-9]{4}\z/
|
2031
|
+
|
2032
|
+
attribute :sku, format: { with: /\A[A-Z]{3}-[0-9]{4}\z/ }
|
2033
|
+
|
2034
|
+
def work
|
2035
|
+
# Your logic here...
|
2036
|
+
end
|
2037
|
+
end
|
2038
|
+
```
|
2039
|
+
|
2040
|
+
| Options | Description |
|
2041
|
+
|---------|-------------|
|
2042
|
+
| `regexp` | Alias for :with option |
|
2043
|
+
| `:with` | Regex pattern that the value must match |
|
2044
|
+
| `:without` | Regex pattern that the value must not match |
|
2045
|
+
|
2046
|
+
### Inclusion
|
2047
|
+
|
2048
|
+
```ruby
|
2049
|
+
class ProcessProduct < CMDx::Task
|
2050
|
+
attribute :availability, inclusion: { in: %w[available limited] }
|
2051
|
+
|
2052
|
+
def work
|
2053
|
+
# Your logic here...
|
2054
|
+
end
|
2055
|
+
end
|
2056
|
+
```
|
2057
|
+
|
2058
|
+
| Options | Description |
|
2059
|
+
|---------|-------------|
|
2060
|
+
| `:in` | The collection of allowed values or range |
|
2061
|
+
| `:within` | Alias for :in option |
|
2062
|
+
| `:of_message` | Custom message for discrete value inclusions |
|
2063
|
+
| `:in_message` | Custom message for range-based inclusions |
|
2064
|
+
| `:within_message` | Alias for :in_message option |
|
2065
|
+
|
2066
|
+
### Length
|
2067
|
+
|
2068
|
+
```ruby
|
2069
|
+
class CreateBlogPost < CMDx::Task
|
2070
|
+
attribute :title, length: { within: 5..100 }
|
2071
|
+
|
2072
|
+
def work
|
2073
|
+
# Your logic here...
|
2074
|
+
end
|
2075
|
+
end
|
2076
|
+
```
|
2077
|
+
|
2078
|
+
| Options | Description |
|
2079
|
+
|---------|-------------|
|
2080
|
+
| `:within` | Range that the length must fall within (inclusive) |
|
2081
|
+
| `:not_within` | Range that the length must not fall within |
|
2082
|
+
| `:in` | Alias for :within |
|
2083
|
+
| `:not_in` | Range that the length must not fall within |
|
2084
|
+
| `:min` | Minimum allowed length |
|
2085
|
+
| `:max` | Maximum allowed length |
|
2086
|
+
| `:is` | Exact required length |
|
2087
|
+
| `:is_not` | Length that is not allowed |
|
2088
|
+
| `:within_message` | Custom message for within/range validations |
|
2089
|
+
| `:in_message` | Custom message for :in validation |
|
2090
|
+
| `:not_within_message` | Custom message for not_within validation |
|
2091
|
+
| `:not_in_message` | Custom message for not_in validation |
|
2092
|
+
| `:min_message` | Custom message for minimum length validation |
|
2093
|
+
| `:max_message` | Custom message for maximum length validation |
|
2094
|
+
| `:is_message` | Custom message for exact length validation |
|
2095
|
+
| `:is_not_message` | Custom message for is_not validation |
|
2096
|
+
|
2097
|
+
### Numeric
|
2098
|
+
|
2099
|
+
```ruby
|
2100
|
+
class CreateBlogPost < CMDx::Task
|
2101
|
+
attribute :word_count, numeric: { min: 100 }
|
2102
|
+
|
2103
|
+
def work
|
2104
|
+
# Your logic here...
|
2105
|
+
end
|
2106
|
+
end
|
2107
|
+
```
|
2108
|
+
|
2109
|
+
| Options | Description |
|
2110
|
+
|---------|-------------|
|
2111
|
+
| `:within` | Range that the value must fall within (inclusive) |
|
2112
|
+
| `:not_within` | Range that the value must not fall within |
|
2113
|
+
| `:in` | Alias for :within option |
|
2114
|
+
| `:not_in` | Alias for :not_within option |
|
2115
|
+
| `:min` | Minimum allowed value (inclusive, >=) |
|
2116
|
+
| `:max` | Maximum allowed value (inclusive, <=) |
|
2117
|
+
| `:is` | Exact value that must match |
|
2118
|
+
| `:is_not` | Value that must not match |
|
2119
|
+
| `:within_message` | Custom message for range validations |
|
2120
|
+
| `:not_within_message` | Custom message for exclusion validations |
|
2121
|
+
| `:min_message` | Custom message for minimum validation |
|
2122
|
+
| `:max_message` | Custom message for maximum validation |
|
2123
|
+
| `:is_message` | Custom message for exact match validation |
|
2124
|
+
| `:is_not_message` | Custom message for exclusion validation |
|
2125
|
+
|
2126
|
+
### Presence
|
2127
|
+
|
2128
|
+
```ruby
|
2129
|
+
class CreateBlogPost < CMDx::Task
|
2130
|
+
attribute :content, presence: true
|
2131
|
+
|
2132
|
+
attribute :content, presence: { message: "cannot be blank" }
|
2133
|
+
|
2134
|
+
def work
|
2135
|
+
# Your logic here...
|
2136
|
+
end
|
2137
|
+
end
|
2138
|
+
```
|
2139
|
+
|
2140
|
+
| Options | Description |
|
2141
|
+
|---------|-------------|
|
2142
|
+
| `true` | Ensures value is not nil, empty string, or whitespace |
|
2143
|
+
|
2144
|
+
## Declarations
|
2145
|
+
|
2146
|
+
> [!IMPORTANT]
|
2147
|
+
> Custom validators must raise a `CMDx::ValidationError` and its message is used as part of the fault reason and metadata.
|
2148
|
+
|
2149
|
+
### Proc or Lambda
|
2150
|
+
|
2151
|
+
Use anonymous functions for simple validation logic:
|
2152
|
+
|
2153
|
+
```ruby
|
2154
|
+
class SetupApplication < CMDx::Task
|
2155
|
+
# Proc
|
2156
|
+
register :validator, :api_key, proc do |value, options = {}|
|
2157
|
+
unless value.match?(/\A[a-zA-Z0-9]{32}\z/)
|
2158
|
+
raise CMDx::ValidationError, "invalid API key format"
|
2159
|
+
end
|
2160
|
+
end
|
2161
|
+
|
2162
|
+
# Lambda
|
2163
|
+
register :validator, :api_key, ->(value, options = {}) {
|
2164
|
+
unless value.match?(/\A[a-zA-Z0-9]{32}\z/)
|
2165
|
+
raise CMDx::ValidationError, "invalid API key format"
|
2166
|
+
end
|
2167
|
+
}
|
2168
|
+
end
|
2169
|
+
```
|
2170
|
+
|
2171
|
+
### Class or Module
|
2172
|
+
|
2173
|
+
Register custom validation logic for specialized requirements:
|
2174
|
+
|
2175
|
+
```ruby
|
2176
|
+
class ApiKeyValidator
|
2177
|
+
def self.call(value, options = {})
|
2178
|
+
unless value.match?(/\A[a-zA-Z0-9]{32}\z/)
|
2179
|
+
raise CMDx::ValidationError, "invalid API key format"
|
2180
|
+
end
|
2181
|
+
end
|
2182
|
+
end
|
2183
|
+
|
2184
|
+
class SetupApplication < CMDx::Task
|
2185
|
+
register :validator, :api_key, ApiKeyValidator
|
2186
|
+
|
2187
|
+
attribute :access_key, api_key: true
|
2188
|
+
end
|
2189
|
+
```
|
2190
|
+
|
2191
|
+
## Removals
|
2192
|
+
|
2193
|
+
Remove custom validators when no longer needed:
|
2194
|
+
|
2195
|
+
> [!WARNING]
|
2196
|
+
> Only one removal operation is allowed per `deregister` call. Multiple removals require separate calls.
|
2197
|
+
|
2198
|
+
```ruby
|
2199
|
+
class SetupApplication < CMDx::Task
|
2200
|
+
deregister :validator, :api_key
|
2201
|
+
end
|
2202
|
+
```
|
2203
|
+
|
2204
|
+
## Error Handling
|
2205
|
+
|
2206
|
+
Validation failures provide detailed error information including attribute paths, validation rules, and specific failure reasons:
|
2207
|
+
|
2208
|
+
```ruby
|
2209
|
+
class CreateProject < CMDx::Task
|
2210
|
+
attribute :project_name, presence: true, length: { minimum: 3, maximum: 50 }
|
2211
|
+
attribute :budget, numeric: { greater_than: 1000, less_than: 1000000 }
|
2212
|
+
attribute :priority, inclusion: { in: [:low, :medium, :high] }
|
2213
|
+
attribute :contact_email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
|
2214
|
+
|
2215
|
+
def work
|
2216
|
+
# Your logic here...
|
2217
|
+
end
|
2218
|
+
end
|
2219
|
+
|
2220
|
+
result = CreateProject.execute(
|
2221
|
+
project_name: "AB", # Too short
|
2222
|
+
budget: 500, # Too low
|
2223
|
+
priority: :urgent, # Not in allowed list
|
2224
|
+
contact_email: "invalid-email" # Invalid format
|
2225
|
+
)
|
2226
|
+
|
2227
|
+
result.state #=> "interrupted"
|
2228
|
+
result.status #=> "failed"
|
2229
|
+
result.reason #=> "project_name is too short (minimum is 3 characters). budget must be greater than 1000. priority is not included in the list. contact_email is invalid."
|
2230
|
+
result.metadata #=> {
|
2231
|
+
# messages: {
|
2232
|
+
# project_name: ["is too short (minimum is 3 characters)"],
|
2233
|
+
# budget: ["must be greater than 1000"],
|
2234
|
+
# priority: ["is not included in the list"],
|
2235
|
+
# contact_email: ["is invalid"]
|
2236
|
+
# }
|
2237
|
+
# }
|
2238
|
+
```
|
2239
|
+
|
2240
|
+
---
|
2241
|
+
|
2242
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/attributes/defaults.md
|
2243
|
+
---
|
2244
|
+
|
2245
|
+
# Attributes - Defaults
|
2246
|
+
|
2247
|
+
Attribute defaults provide fallback values when arguments are not provided or resolve to `nil`. Defaults ensure tasks have sensible values for optional attributes while maintaining flexibility for callers to override when needed.
|
2248
|
+
|
2249
|
+
## Declarations
|
2250
|
+
|
2251
|
+
Defaults apply when attributes are not provided or resolve to `nil`. They work seamlessly with coercion, validation, and nested attributes.
|
2252
|
+
|
2253
|
+
### Static Values
|
2254
|
+
|
2255
|
+
```ruby
|
2256
|
+
class OptimizeDatabase < CMDx::Task
|
2257
|
+
attribute :strategy, default: :incremental
|
2258
|
+
attribute :level, default: "basic"
|
2259
|
+
attribute :notify_admin, default: true
|
2260
|
+
attribute :timeout_minutes, default: 30
|
2261
|
+
attribute :indexes, default: []
|
2262
|
+
attribute :options, default: {}
|
2263
|
+
|
2264
|
+
def work
|
2265
|
+
strategy #=> :incremental
|
2266
|
+
level #=> "basic"
|
2267
|
+
notify_admin #=> true
|
2268
|
+
timeout_minutes #=> 30
|
2269
|
+
indexes #=> []
|
2270
|
+
options #=> {}
|
2271
|
+
end
|
2272
|
+
end
|
2273
|
+
```
|
2274
|
+
|
2275
|
+
### Symbol References
|
2276
|
+
|
2277
|
+
Reference instance methods by symbol for dynamic default values:
|
2278
|
+
|
2279
|
+
```ruby
|
2280
|
+
class ProcessAnalytics < CMDx::Task
|
2281
|
+
attribute :granularity, default: :default_granularity
|
2282
|
+
|
2283
|
+
def work
|
2284
|
+
# Your logic here...
|
2285
|
+
end
|
2286
|
+
|
2287
|
+
private
|
2288
|
+
|
2289
|
+
def default_granularity
|
2290
|
+
Current.user.premium? ? "hourly" : "daily"
|
2291
|
+
end
|
2292
|
+
end
|
2293
|
+
```
|
2294
|
+
|
2295
|
+
### Proc or Lambda
|
2296
|
+
|
2297
|
+
Use anonymous functions for dynamic default values:
|
2298
|
+
|
2299
|
+
```ruby
|
2300
|
+
class CacheContent < CMDx::Task
|
2301
|
+
# Proc
|
2302
|
+
attribute :expire_hours, default: proc { Current.tenant.cache_duration || 24 }
|
2303
|
+
|
2304
|
+
# Lambda
|
2305
|
+
attribute :compression, default: -> { Current.tenant.premium? ? "gzip" : "none" }
|
2306
|
+
end
|
2307
|
+
```
|
2308
|
+
|
2309
|
+
## Coercions and Validations
|
2310
|
+
|
2311
|
+
Defaults are subject to the same coercion and validation rules as provided values, ensuring consistency and catching configuration errors early.
|
2312
|
+
|
2313
|
+
```ruby
|
2314
|
+
class ScheduleBackup < CMDx::Task
|
2315
|
+
# Coercions
|
2316
|
+
attribute :retention_days, default: "7", type: :integer
|
2317
|
+
|
2318
|
+
# Validations
|
2319
|
+
optional :frequency, default: "daily", inclusion: { in: %w[hourly daily weekly monthly] }
|
2320
|
+
end
|
2321
|
+
```
|
2322
|
+
|
2323
|
+
---
|
2324
|
+
|
2325
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/callbacks.md
|
2326
|
+
---
|
2327
|
+
|
2328
|
+
# Callbacks
|
2329
|
+
|
2330
|
+
Callbacks provide precise control over task execution lifecycle, running custom logic at specific transition points. Callback callables have access to the same context and result information as the `execute` method, enabling rich integration patterns.
|
2331
|
+
|
2332
|
+
> [!IMPORTANT]
|
2333
|
+
> Callbacks execute in the order they are declared within each hook type. Multiple callbacks of the same type execute in declaration order (FIFO: first in, first out).
|
2334
|
+
|
2335
|
+
## Available Callbacks
|
2336
|
+
|
2337
|
+
Callbacks execute in precise lifecycle order. Here is the complete execution sequence:
|
2338
|
+
|
2339
|
+
```ruby
|
2340
|
+
1. before_validation # Pre-validation setup
|
2341
|
+
2. before_execution # Setup and preparation
|
2342
|
+
|
2343
|
+
# --- Task#work executed ---
|
2344
|
+
|
2345
|
+
3. on_[complete|interrupted] # Based on execution state
|
2346
|
+
4. on_executed # Task finished (any outcome)
|
2347
|
+
5. on_[success|skipped|failed] # Based on execution status
|
2348
|
+
6. on_[good|bad] # Based on outcome classification
|
2349
|
+
```
|
2350
|
+
|
2351
|
+
## Declarations
|
2352
|
+
|
2353
|
+
### Symbol References
|
2354
|
+
|
2355
|
+
Reference instance methods by symbol for simple callback logic:
|
2356
|
+
|
2357
|
+
```ruby
|
2358
|
+
class ProcessBooking < CMDx::Task
|
2359
|
+
before_execution :find_reservation
|
2360
|
+
|
2361
|
+
# Batch declarations (works for any type)
|
2362
|
+
on_complete :notify_guest, :update_availability
|
2363
|
+
|
2364
|
+
def work
|
2365
|
+
# Your logic here...
|
2366
|
+
end
|
2367
|
+
|
2368
|
+
private
|
2369
|
+
|
2370
|
+
def find_reservation
|
2371
|
+
@reservation ||= Reservation.find(context.reservation_id)
|
2372
|
+
end
|
2373
|
+
|
2374
|
+
def notify_guest
|
2375
|
+
GuestNotifier.call(context.guest, result)
|
2376
|
+
end
|
2377
|
+
|
2378
|
+
def update_availability
|
2379
|
+
AvailabilityService.update(context.room_ids, result)
|
2380
|
+
end
|
2381
|
+
end
|
2382
|
+
```
|
2383
|
+
|
2384
|
+
### Proc or Lambda
|
2385
|
+
|
2386
|
+
Use anonymous functions for inline callback logic:
|
2387
|
+
|
2388
|
+
```ruby
|
2389
|
+
class ProcessBooking < CMDx::Task
|
2390
|
+
# Proc
|
2391
|
+
on_interrupted proc { |task| ReservationSystem.pause! }
|
2392
|
+
|
2393
|
+
# Lambda
|
2394
|
+
on_complete -> { ReservationSystem.resume! }
|
2395
|
+
end
|
2396
|
+
```
|
2397
|
+
|
2398
|
+
### Class or Module
|
2399
|
+
|
2400
|
+
Implement reusable callback logic in dedicated classes:
|
2401
|
+
|
2402
|
+
```ruby
|
2403
|
+
class BookingConfirmationCallback
|
2404
|
+
def call(task)
|
2405
|
+
if task.result.success?
|
2406
|
+
MessagingApi.send_confirmation(task.context.guest)
|
2407
|
+
else
|
2408
|
+
MessagingApi.send_issue_alert(task.context.manager)
|
2409
|
+
end
|
2410
|
+
end
|
2411
|
+
end
|
2412
|
+
|
2413
|
+
class ProcessBooking < CMDx::Task
|
2414
|
+
# Class or Module
|
2415
|
+
on_success BookingConfirmationCallback
|
2416
|
+
|
2417
|
+
# Instance
|
2418
|
+
on_interrupted BookingConfirmationCallback.new
|
2419
|
+
end
|
2420
|
+
```
|
2421
|
+
|
2422
|
+
### Conditional Execution
|
2423
|
+
|
2424
|
+
Control callback execution with conditional logic:
|
2425
|
+
|
2426
|
+
```ruby
|
2427
|
+
class MessagingPermissionCheck
|
2428
|
+
def call(task)
|
2429
|
+
task.context.guest.can?(:receive_messages)
|
2430
|
+
end
|
2431
|
+
end
|
2432
|
+
|
2433
|
+
class ProcessBooking < CMDx::Task
|
2434
|
+
# If and/or Unless
|
2435
|
+
before_execution :notify_guest, if: :messaging_enabled?, unless: :messaging_blocked?
|
2436
|
+
|
2437
|
+
# Proc
|
2438
|
+
on_failure :increment_failure, if: ->(task) { Rails.env.production? && task.class.name.include?("Legacy") }
|
2439
|
+
|
2440
|
+
# Lambda
|
2441
|
+
on_success :ping_housekeeping, if: proc { |task| task.context.rooms_need_cleaning? }
|
2442
|
+
|
2443
|
+
# Class or Module
|
2444
|
+
on_complete :send_confirmation, unless: MessagingPermissionCheck
|
2445
|
+
|
2446
|
+
# Instance
|
2447
|
+
on_complete :send_confirmation, if: MessagingPermissionCheck.new
|
2448
|
+
|
2449
|
+
def work
|
2450
|
+
# Your logic here...
|
2451
|
+
end
|
2452
|
+
|
2453
|
+
private
|
2454
|
+
|
2455
|
+
def messaging_enabled?
|
2456
|
+
context.guest.messaging_preference.present?
|
2457
|
+
end
|
2458
|
+
|
2459
|
+
def messaging_blocked?
|
2460
|
+
context.guest.communication_status == :blocked
|
2461
|
+
end
|
2462
|
+
end
|
2463
|
+
```
|
2464
|
+
|
2465
|
+
## Callback Removal
|
2466
|
+
|
2467
|
+
Remove callbacks at runtime for dynamic behavior control:
|
2468
|
+
|
2469
|
+
> [!IMPORTANT]
|
2470
|
+
> Only one removal operation is allowed per `deregister` call. Multiple removals require separate calls.
|
2471
|
+
|
2472
|
+
```ruby
|
2473
|
+
class ProcessBooking < CMDx::Task
|
2474
|
+
# Symbol
|
2475
|
+
deregister :callback, :before_execution, :notify_guest
|
2476
|
+
|
2477
|
+
# Class or Module (no instances)
|
2478
|
+
deregister :callback, :on_complete, BookingConfirmationCallback
|
2479
|
+
end
|
2480
|
+
```
|
2481
|
+
|
2482
|
+
---
|
2483
|
+
|
2484
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/middlewares.md
|
2485
|
+
---
|
2486
|
+
|
2487
|
+
# Middlewares
|
2488
|
+
|
2489
|
+
Middleware provides Rack-style wrappers around task execution for cross-cutting concerns like authentication, logging, caching, and error handling.
|
2490
|
+
|
2491
|
+
## Order
|
2492
|
+
|
2493
|
+
Middleware executes in a nested fashion, creating an onion-like execution pattern:
|
2494
|
+
|
2495
|
+
> [!NOTE]
|
2496
|
+
> Middleware executes in the order they are registered, with the first registered middleware being the outermost wrapper.
|
2497
|
+
|
2498
|
+
```ruby
|
2499
|
+
class ProcessCampaign < CMDx::Task
|
2500
|
+
register :middleware, AuditMiddleware # 1st: outermost wrapper
|
2501
|
+
register :middleware, AuthorizationMiddleware # 2nd: middle wrapper
|
2502
|
+
register :middleware, CacheMiddleware # 3rd: innermost wrapper
|
2503
|
+
|
2504
|
+
def work
|
2505
|
+
# Your logic here...
|
2506
|
+
end
|
2507
|
+
end
|
2508
|
+
|
2509
|
+
# Execution flow:
|
2510
|
+
# 1. AuditMiddleware (before)
|
2511
|
+
# 2. AuthorizationMiddleware (before)
|
2512
|
+
# 3. CacheMiddleware (before)
|
2513
|
+
# 4. [task execution]
|
2514
|
+
# 5. CacheMiddleware (after)
|
2515
|
+
# 6. AuthorizationMiddleware (after)
|
2516
|
+
# 7. AuditMiddleware (after)
|
2517
|
+
```
|
2518
|
+
|
2519
|
+
## Declarations
|
2520
|
+
|
2521
|
+
### Proc or Lambda
|
2522
|
+
|
2523
|
+
Use anonymous functions for simple middleware logic:
|
2524
|
+
|
2525
|
+
```ruby
|
2526
|
+
class ProcessCampaign < CMDx::Task
|
2527
|
+
# Proc
|
2528
|
+
register :middleware, proc do |task, options, &block|
|
2529
|
+
result = block.call
|
2530
|
+
Analytics.track(result.status)
|
2531
|
+
result
|
2532
|
+
end
|
2533
|
+
|
2534
|
+
# Lambda
|
2535
|
+
register :middleware, ->(task, options, &block) {
|
2536
|
+
result = block.call
|
2537
|
+
Analytics.track(result.status)
|
2538
|
+
result
|
2539
|
+
}
|
2540
|
+
end
|
2541
|
+
```
|
2542
|
+
|
2543
|
+
### Class or Module
|
2544
|
+
|
2545
|
+
For complex middleware logic, use classes or modules:
|
2546
|
+
|
2547
|
+
```ruby
|
2548
|
+
class TelemetryMiddleware
|
2549
|
+
def call(task, options)
|
2550
|
+
result = yield
|
2551
|
+
Telemetry.record(result.status)
|
2552
|
+
ensure
|
2553
|
+
result # Always return result
|
2554
|
+
end
|
2555
|
+
end
|
2556
|
+
|
2557
|
+
class ProcessCampaign < CMDx::Task
|
2558
|
+
# Class or Module
|
2559
|
+
register :middleware, TelemetryMiddleware
|
2560
|
+
|
2561
|
+
# Instance
|
2562
|
+
register :middleware, TelemetryMiddleware.new
|
2563
|
+
|
2564
|
+
# With options
|
2565
|
+
register :middleware, MonitoringMiddleware, service_key: ENV["MONITORING_KEY"]
|
2566
|
+
register :middleware, MonitoringMiddleware.new(ENV["MONITORING_KEY"])
|
2567
|
+
end
|
2568
|
+
```
|
2569
|
+
|
2570
|
+
## Removals
|
2571
|
+
|
2572
|
+
Class and Module based declarations can be removed at a global and task level.
|
2573
|
+
|
2574
|
+
> [!WARNING]
|
2575
|
+
> Only one removal operation is allowed per `deregister` call. Multiple removals require separate calls.
|
2576
|
+
|
2577
|
+
```ruby
|
2578
|
+
class ProcessCampaign < CMDx::Task
|
2579
|
+
# Class or Module (no instances)
|
2580
|
+
deregister :middleware, TelemetryMiddleware
|
2581
|
+
end
|
2582
|
+
```
|
2583
|
+
|
2584
|
+
## Built-in
|
2585
|
+
|
2586
|
+
### Timeout
|
2587
|
+
|
2588
|
+
Ensures task execution doesn't exceed a specified time limit:
|
2589
|
+
|
2590
|
+
```ruby
|
2591
|
+
class ProcessReport < CMDx::Task
|
2592
|
+
# Default timeout: 3 seconds
|
2593
|
+
register :middleware, CMDx::Middlewares::Timeout
|
2594
|
+
|
2595
|
+
# Seconds (takes Numeric, Symbol, Proc, Lambda, Class, Module)
|
2596
|
+
register :middleware, CMDx::Middlewares::Timeout, seconds: :max_processing_time
|
2597
|
+
|
2598
|
+
# If or Unless (takes Symbol, Proc, Lambda, Class, Module)
|
2599
|
+
register :middleware, CMDx::Middlewares::Timeout, unless: -> { self.class.name.include?("Quick") }
|
2600
|
+
|
2601
|
+
def work
|
2602
|
+
# Your logic here...
|
2603
|
+
end
|
2604
|
+
|
2605
|
+
private
|
2606
|
+
|
2607
|
+
def max_processing_time
|
2608
|
+
Rails.env.production? ? 2 : 10
|
2609
|
+
end
|
2610
|
+
end
|
2611
|
+
|
2612
|
+
# Slow task
|
2613
|
+
result = ProcessReport.execute
|
2614
|
+
|
2615
|
+
result.state #=> "interrupted"
|
2616
|
+
result.status #=> "failure"
|
2617
|
+
result.reason #=> "[CMDx::TimeoutError] execution exceeded 3 seconds"
|
2618
|
+
result.cause #=> <CMDx::TimeoutError>
|
2619
|
+
result.metadata #=> { limit: 3 }
|
2620
|
+
```
|
2621
|
+
|
2622
|
+
### Correlate
|
2623
|
+
|
2624
|
+
Tags tasks with a global correlation ID for distributed tracing:
|
2625
|
+
|
2626
|
+
```ruby
|
2627
|
+
class ProcessExport < CMDx::Task
|
2628
|
+
# Default correlation ID generation
|
2629
|
+
register :middleware, CMDx::Middlewares::Correlate
|
2630
|
+
|
2631
|
+
# Seconds (takes Object, Symbol, Proc, Lambda, Class, Module)
|
2632
|
+
register :middleware, CMDx::Middlewares::Correlate, id: proc { |task| task.context.session_id }
|
2633
|
+
|
2634
|
+
# If or Unless (takes Symbol, Proc, Lambda, Class, Module)
|
2635
|
+
register :middleware, CMDx::Middlewares::Correlate, if: :correlation_enabled?
|
2636
|
+
|
2637
|
+
def work
|
2638
|
+
# Your logic here...
|
2639
|
+
end
|
2640
|
+
|
2641
|
+
private
|
2642
|
+
|
2643
|
+
def correlation_enabled?
|
2644
|
+
ENV["CORRELATION_ENABLED"] == "true"
|
2645
|
+
end
|
2646
|
+
end
|
2647
|
+
|
2648
|
+
result = ProcessExport.execute
|
2649
|
+
result.metadata #=> { correlation_id: "550e8400-e29b-41d4-a716-446655440000" }
|
2650
|
+
```
|
2651
|
+
|
2652
|
+
### Runtime
|
2653
|
+
|
2654
|
+
The runtime middleware tags tasks with how long it took to execute the task.
|
2655
|
+
The calculation uses a monotonic clock and the time is returned in milliseconds.
|
2656
|
+
|
2657
|
+
```ruby
|
2658
|
+
class PerformanceMonitoringCheck
|
2659
|
+
def call(task)
|
2660
|
+
task.context.tenant.monitoring_enabled?
|
2661
|
+
end
|
2662
|
+
end
|
2663
|
+
|
2664
|
+
class ProcessExport < CMDx::Task
|
2665
|
+
# Default timeout is 3 seconds
|
2666
|
+
register :middleware, CMDx::Middlewares::Runtime
|
2667
|
+
|
2668
|
+
# If or Unless (takes Symbol, Proc, Lambda, Class, Module)
|
2669
|
+
register :middleware, CMDx::Middlewares::Runtime, if: PerformanceMonitoringCheck
|
2670
|
+
end
|
2671
|
+
|
2672
|
+
result = ProcessExport.execute
|
2673
|
+
result.metadata #=> { runtime: 1247 } (ms)
|
2674
|
+
```
|
2675
|
+
|
2676
|
+
---
|
2677
|
+
|
2678
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/logging.md
|
2679
|
+
---
|
2680
|
+
|
2681
|
+
# Logging
|
2682
|
+
|
2683
|
+
CMDx provides comprehensive automatic logging for task execution with structured data, customizable formatters, and intelligent severity mapping. All task results are logged after completion with rich metadata for debugging and monitoring.
|
2684
|
+
|
2685
|
+
## Formatters
|
2686
|
+
|
2687
|
+
CMDx supports multiple log formatters to integrate with various logging systems:
|
2688
|
+
|
2689
|
+
| Formatter | Use Case | Output Style |
|
2690
|
+
|-----------|----------|--------------|
|
2691
|
+
| `Line` | Traditional logging | Single-line format |
|
2692
|
+
| `Json` | Structured systems | Compact JSON |
|
2693
|
+
| `KeyValue` | Log parsing | `key=value` pairs |
|
2694
|
+
| `Logstash` | ELK stack | JSON with @version/@timestamp |
|
2695
|
+
| `Raw` | Minimal output | Message content only |
|
2696
|
+
|
2697
|
+
Sample output:
|
2698
|
+
|
2699
|
+
```log
|
2700
|
+
<!-- Success (INFO level) -->
|
2701
|
+
I, [2022-07-17T18:43:15.000000 #3784] INFO -- GenerateInvoice:
|
2702
|
+
index=0 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task"
|
2703
|
+
class="GenerateInvoice" state="complete" status="success" metadata={runtime: 187}
|
2704
|
+
|
2705
|
+
<!-- Skipped (WARN level) -->
|
2706
|
+
W, [2022-07-17T18:43:15.000000 #3784] WARN -- ValidateCustomer:
|
2707
|
+
index=1 state="interrupted" status="skipped" reason="Customer already validated"
|
2708
|
+
|
2709
|
+
<!-- Failed (ERROR level) -->
|
2710
|
+
E, [2022-07-17T18:43:15.000000 #3784] ERROR -- CalculateTax:
|
2711
|
+
index=2 state="interrupted" status="failed" metadata={error_code: "TAX_SERVICE_UNAVAILABLE"}
|
2712
|
+
|
2713
|
+
<!-- Failed Chain -->
|
2714
|
+
E, [2022-07-17T18:43:15.000000 #3784] ERROR -- BillingWorkflow:
|
2715
|
+
caused_failure={index: 2, class: "CalculateTax", status: "failed"}
|
2716
|
+
threw_failure={index: 1, class: "ValidateCustomer", status: "failed"}
|
2717
|
+
```
|
2718
|
+
|
2719
|
+
> [!TIP]
|
2720
|
+
> Logging can be used as low-level eventing system, ingesting all tasks performed within a small action or long running request. This ie where correlation is especially handy.
|
2721
|
+
|
2722
|
+
## Structure
|
2723
|
+
|
2724
|
+
All log entries include comprehensive execution metadata. Field availability depends on execution context and outcome.
|
2725
|
+
|
2726
|
+
### Core Fields
|
2727
|
+
|
2728
|
+
| Field | Description | Example |
|
2729
|
+
|-------|-------------|---------|
|
2730
|
+
| `severity` | Log level | `INFO`, `WARN`, `ERROR` |
|
2731
|
+
| `timestamp` | ISO 8601 execution time | `2022-07-17T18:43:15.000000` |
|
2732
|
+
| `pid` | Process ID | `3784` |
|
2733
|
+
|
2734
|
+
### Task Information
|
2735
|
+
|
2736
|
+
| Field | Description | Example |
|
2737
|
+
|-------|-------------|---------|
|
2738
|
+
| `index` | Execution sequence position | `0`, `1`, `2` |
|
2739
|
+
| `chain_id` | Unique execution chain ID | `018c2b95-b764-7615...` |
|
2740
|
+
| `type` | Execution unit type | `Task`, `Workflow` |
|
2741
|
+
| `class` | Task class name | `GenerateInvoiceTask` |
|
2742
|
+
| `id` | Unique task instance ID | `018c2b95-b764-7615...` |
|
2743
|
+
| `tags` | Custom categorization | `["billing", "financial"]` |
|
2744
|
+
|
2745
|
+
### Execution Data
|
2746
|
+
|
2747
|
+
| Field | Description | Example |
|
2748
|
+
|-------|-------------|---------|
|
2749
|
+
| `state` | Lifecycle state | `complete`, `interrupted` |
|
2750
|
+
| `status` | Business outcome | `success`, `skipped`, `failed` |
|
2751
|
+
| `outcome` | Final classification | `success`, `interrupted` |
|
2752
|
+
| `metadata` | Custom task data | `{order_id: 123, amount: 99.99}` |
|
2753
|
+
|
2754
|
+
### Failure Chain
|
2755
|
+
|
2756
|
+
| Field | Description |
|
2757
|
+
|-------|-------------|
|
2758
|
+
| `reason` | Reason given for the stoppage |
|
2759
|
+
| `caused` | Cause exception details |
|
2760
|
+
| `caused_failure` | Original failing task details |
|
2761
|
+
| `threw_failure` | Task that propagated the failure |
|
2762
|
+
|
2763
|
+
## Usage
|
2764
|
+
|
2765
|
+
Tasks have access to the frameworks logger.
|
2766
|
+
|
2767
|
+
```ruby
|
2768
|
+
class ProcessSubscription < CMDx::Task
|
2769
|
+
def work
|
2770
|
+
logger.debug { "Activated feature flags: #{Features.active_flags}" }
|
2771
|
+
# Your logic here...
|
2772
|
+
logger.info("Subscription processed")
|
2773
|
+
end
|
2774
|
+
end
|
2775
|
+
```
|
2776
|
+
|
2777
|
+
---
|
2778
|
+
|
2779
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/internationalization.md
|
2780
|
+
---
|
2781
|
+
|
2782
|
+
# Internationalization (i18n)
|
2783
|
+
|
2784
|
+
CMDx provides comprehensive internationalization support for all error messages, attribute validation failures, coercion errors, and fault messages. All user-facing text is automatically localized based on the current `I18n.locale`, ensuring your applications can serve global audiences with native-language error reporting.
|
2785
|
+
|
2786
|
+
## Localization
|
2787
|
+
|
2788
|
+
> [!NOTE]
|
2789
|
+
> CMDx automatically localizes all error messages based on the `I18n.locale` setting.
|
2790
|
+
|
2791
|
+
```ruby
|
2792
|
+
class ProcessQuote < CMDx::Task
|
2793
|
+
attribute :price, type: :float
|
2794
|
+
|
2795
|
+
def work
|
2796
|
+
# Your logic here...
|
2797
|
+
end
|
2798
|
+
end
|
2799
|
+
|
2800
|
+
I18n.with_locale(:fr) do
|
2801
|
+
result = ProcessQuote.execute(price: "invalid")
|
2802
|
+
result.metadata[:messages][:price] #=> ["impossible de contraindre en float"]
|
2803
|
+
end
|
2804
|
+
```
|
2805
|
+
|
2806
|
+
---
|
2807
|
+
|
2808
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/deprecation.md
|
2809
|
+
---
|
2810
|
+
|
2811
|
+
# Task Deprecation
|
2812
|
+
|
2813
|
+
Task deprecation provides a systematic approach to managing legacy tasks in CMDx applications. The deprecation system enables controlled migration paths by issuing warnings, logging messages, or preventing execution of deprecated tasks entirely, helping teams maintain code quality while providing clear upgrade paths.
|
2814
|
+
|
2815
|
+
## Modes
|
2816
|
+
|
2817
|
+
### Raise
|
2818
|
+
|
2819
|
+
`:raise` mode prevents task execution entirely. Use this for tasks that should no longer be used under any circumstances.
|
2820
|
+
|
2821
|
+
> [!WARNING]
|
2822
|
+
> Use `:raise` mode carefully in production environments as it will break existing workflows immediately.
|
2823
|
+
|
2824
|
+
```ruby
|
2825
|
+
class ProcessObsoleteAPI < CMDx::Task
|
2826
|
+
settings(deprecated: :raise)
|
2827
|
+
|
2828
|
+
def work
|
2829
|
+
# Will never execute...
|
2830
|
+
end
|
2831
|
+
end
|
2832
|
+
|
2833
|
+
result = ProcessObsoleteAPI.execute
|
2834
|
+
#=> raises CMDx::DeprecationError: "ProcessObsoleteAPI usage prohibited"
|
2835
|
+
```
|
2836
|
+
|
2837
|
+
### Log
|
2838
|
+
|
2839
|
+
`:log` mode allows continued usage while tracking deprecation warnings. Perfect for gradual migration scenarios where immediate replacement isn't feasible.
|
2840
|
+
|
2841
|
+
```ruby
|
2842
|
+
class ProcessLegacyFormat < CMDx::Task
|
2843
|
+
settings(deprecated: :log)
|
2844
|
+
|
2845
|
+
# Same
|
2846
|
+
settings(deprecated: true)
|
2847
|
+
|
2848
|
+
def work
|
2849
|
+
# Executes but logs deprecation warning...
|
2850
|
+
end
|
2851
|
+
end
|
2852
|
+
|
2853
|
+
result = ProcessLegacyFormat.execute
|
2854
|
+
result.successful? #=> true
|
2855
|
+
|
2856
|
+
# Deprecation warning appears in logs:
|
2857
|
+
# WARN -- : DEPRECATED: ProcessLegacyFormat - migrate to replacement or discontinue use
|
2858
|
+
```
|
2859
|
+
|
2860
|
+
### Warn
|
2861
|
+
|
2862
|
+
`:warn` mode issues Ruby warnings visible in development and testing environments. Useful for alerting developers without affecting production logging.
|
2863
|
+
|
2864
|
+
```ruby
|
2865
|
+
class ProcessOldData < CMDx::Task
|
2866
|
+
settings(deprecated: :warn)
|
2867
|
+
|
2868
|
+
def work
|
2869
|
+
# Executes but emits Ruby warning...
|
2870
|
+
end
|
2871
|
+
end
|
2872
|
+
|
2873
|
+
result = ProcessOldData.execute
|
2874
|
+
result.successful? #=> true
|
2875
|
+
|
2876
|
+
# Ruby warning appears in stderr:
|
2877
|
+
# [ProcessOldData] DEPRECATED: migrate to replacement or discontinue use
|
2878
|
+
```
|
2879
|
+
|
2880
|
+
## Declarations
|
2881
|
+
|
2882
|
+
### Symbol or String
|
2883
|
+
|
2884
|
+
```ruby
|
2885
|
+
class OutdatedConnector < CMDx::Task
|
2886
|
+
# Symbol
|
2887
|
+
settings(deprecated: :raise)
|
2888
|
+
|
2889
|
+
# String
|
2890
|
+
settings(deprecated: "warn")
|
2891
|
+
end
|
2892
|
+
```
|
2893
|
+
|
2894
|
+
### Boolean or Nil
|
2895
|
+
|
2896
|
+
```ruby
|
2897
|
+
class OutdatedConnector < CMDx::Task
|
2898
|
+
# Deprecates with default :log mode
|
2899
|
+
settings(deprecated: true)
|
2900
|
+
|
2901
|
+
# Skips deprecation
|
2902
|
+
settings(deprecated: false)
|
2903
|
+
settings(deprecated: nil)
|
2904
|
+
end
|
2905
|
+
```
|
2906
|
+
|
2907
|
+
### Method
|
2908
|
+
|
2909
|
+
```ruby
|
2910
|
+
class OutdatedConnector < CMDx::Task
|
2911
|
+
# Symbol
|
2912
|
+
settings(deprecated: :deprecated?)
|
2913
|
+
|
2914
|
+
def work
|
2915
|
+
# Your logic here...
|
2916
|
+
end
|
2917
|
+
|
2918
|
+
private
|
2919
|
+
|
2920
|
+
def deprecated?
|
2921
|
+
Time.now.year > 2024 ? :raise : false
|
2922
|
+
end
|
2923
|
+
end
|
2924
|
+
```
|
2925
|
+
|
2926
|
+
### Proc or Lambda
|
2927
|
+
|
2928
|
+
```ruby
|
2929
|
+
class OutdatedConnector < CMDx::Task
|
2930
|
+
# Proc
|
2931
|
+
settings(deprecated: proc { Rails.env.development? ? :raise : :log })
|
2932
|
+
|
2933
|
+
# Lambda
|
2934
|
+
settings(deprecated: -> { Current.tenant.legacy_mode? ? :warn : :raise })
|
2935
|
+
end
|
2936
|
+
```
|
2937
|
+
|
2938
|
+
### Class or Module
|
2939
|
+
|
2940
|
+
```ruby
|
2941
|
+
class OutdatedTaskDeprecator
|
2942
|
+
def call(task)
|
2943
|
+
task.class.name.include?("Outdated")
|
2944
|
+
end
|
2945
|
+
end
|
2946
|
+
|
2947
|
+
class OutdatedConnector < CMDx::Task
|
2948
|
+
# Class or Module
|
2949
|
+
settings(deprecated: OutdatedTaskDeprecator)
|
2950
|
+
|
2951
|
+
# Instance
|
2952
|
+
settings(deprecated: OutdatedTaskDeprecator.new)
|
2953
|
+
end
|
2954
|
+
```
|
2955
|
+
|
2956
|
+
---
|
2957
|
+
|
2958
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/workflows.md
|
2959
|
+
---
|
2960
|
+
|
2961
|
+
# Workflows
|
2962
|
+
|
2963
|
+
Workflow orchestrates sequential execution of multiple tasks in a linear pipeline. Workflows provide a declarative DSL for composing complex business logic from individual task components, with support for conditional execution, context propagation, and configurable halt behavior.
|
2964
|
+
|
2965
|
+
## Declarations
|
2966
|
+
|
2967
|
+
Tasks execute in declaration order (FIFO). The workflow context propagates to each task, allowing access to data from previous executions.
|
2968
|
+
|
2969
|
+
> [!IMPORTANT]
|
2970
|
+
> Do **NOT** define a `work` method in workflow tasks. The included module automatically provides the execution logic.
|
2971
|
+
|
2972
|
+
### Task
|
2973
|
+
|
2974
|
+
```ruby
|
2975
|
+
class OnboardingWorkflow < CMDx::Task
|
2976
|
+
include CMDx::Workflow
|
2977
|
+
|
2978
|
+
task CreateUserProfile
|
2979
|
+
task SetupAccountPreferences
|
2980
|
+
|
2981
|
+
tasks SendWelcomeEmail, SendWelcomeSms, CreateDashboard
|
2982
|
+
end
|
2983
|
+
```
|
2984
|
+
|
2985
|
+
### Group
|
2986
|
+
|
2987
|
+
Group related tasks for better organization and shared configuration:
|
2988
|
+
|
2989
|
+
> [!IMPORTANT]
|
2990
|
+
> Settings and conditionals for a group apply to all tasks within that group.
|
2991
|
+
|
2992
|
+
```ruby
|
2993
|
+
class ContentModerationWorkflow < CMDx::Task
|
2994
|
+
include CMDx::Workflow
|
2995
|
+
|
2996
|
+
# Screening phase
|
2997
|
+
tasks ScanForProfanity, CheckForSpam, ValidateImages, breakpoints: ["skipped"]
|
2998
|
+
|
2999
|
+
# Review phase
|
3000
|
+
tasks ApplyFilters, ScoreContent, FlagSuspicious
|
3001
|
+
|
3002
|
+
# Decision phase
|
3003
|
+
tasks PublishContent, QueueForReview, NotifyModerators
|
3004
|
+
end
|
3005
|
+
```
|
3006
|
+
|
3007
|
+
### Conditionals
|
3008
|
+
|
3009
|
+
Conditionals support multiple syntaxes for flexible execution control:
|
3010
|
+
|
3011
|
+
```ruby
|
3012
|
+
class ContentAccessCheck
|
3013
|
+
def call(task)
|
3014
|
+
task.context.user.can?(:publish_content)
|
3015
|
+
end
|
3016
|
+
end
|
3017
|
+
|
3018
|
+
class OnboardingWorkflow < CMDx::Task
|
3019
|
+
include CMDx::Workflow
|
3020
|
+
|
3021
|
+
# If and/or Unless
|
3022
|
+
task SendWelcomeEmail, if: :email_configured?, unless: :email_disabled?
|
3023
|
+
|
3024
|
+
# Proc
|
3025
|
+
task SendWelcomeEmail, if: ->(workflow) { Rails.env.production? && workflow.class.name.include?("Premium") }
|
3026
|
+
|
3027
|
+
# Lambda
|
3028
|
+
task SendWelcomeEmail, if: proc { |workflow| workflow.context.features_enabled? }
|
3029
|
+
|
3030
|
+
# Class or Module
|
3031
|
+
task SendWelcomeEmail, unless: ContentAccessCheck
|
3032
|
+
|
3033
|
+
# Instance
|
3034
|
+
task SendWelcomeEmail, if: ContentAccessCheck.new
|
3035
|
+
|
3036
|
+
# Conditional applies to all tasks of this declaration group
|
3037
|
+
tasks SendWelcomeEmail, CreateDashboard, SetupTutorial, if: :email_configured?
|
3038
|
+
|
3039
|
+
private
|
3040
|
+
|
3041
|
+
def email_configured?
|
3042
|
+
context.user.email_address.present?
|
3043
|
+
end
|
3044
|
+
|
3045
|
+
def email_disabled?
|
3046
|
+
context.user.communication_preference == :disabled
|
3047
|
+
end
|
3048
|
+
end
|
3049
|
+
```
|
3050
|
+
|
3051
|
+
## Halt Behavior
|
3052
|
+
|
3053
|
+
By default skipped tasks are considered no-op executions and does not stop workflow execution.
|
3054
|
+
This is configurable via global and task level breakpoint settings. Task and group configurations
|
3055
|
+
can be used together within a workflow.
|
3056
|
+
|
3057
|
+
```ruby
|
3058
|
+
class AnalyticsWorkflow < CMDx::Task
|
3059
|
+
include CMDx::Workflow
|
3060
|
+
|
3061
|
+
task CollectMetrics # If fails → workflow stops
|
3062
|
+
task FilterOutliers # If skipped → workflow continues
|
3063
|
+
task GenerateDashboard # Only runs if no failures occurred
|
3064
|
+
end
|
3065
|
+
```
|
3066
|
+
|
3067
|
+
### Task Configuration
|
3068
|
+
|
3069
|
+
Configure halt behavior for the entire workflow:
|
3070
|
+
|
3071
|
+
```ruby
|
3072
|
+
class SecurityWorkflow < CMDx::Task
|
3073
|
+
include CMDx::Workflow
|
3074
|
+
|
3075
|
+
# Halt on both failed and skipped results
|
3076
|
+
settings(workflow_breakpoints: ["skipped", "failed"])
|
3077
|
+
|
3078
|
+
task PerformSecurityScan
|
3079
|
+
task ValidateSecurityRules
|
3080
|
+
end
|
3081
|
+
|
3082
|
+
class OptionalTasksWorkflow < CMDx::Task
|
3083
|
+
include CMDx::Workflow
|
3084
|
+
|
3085
|
+
# Never halt, always continue
|
3086
|
+
settings(breakpoints: [])
|
3087
|
+
|
3088
|
+
task TryBackupData
|
3089
|
+
task TryCleanupLogs
|
3090
|
+
task TryOptimizeCache
|
3091
|
+
end
|
3092
|
+
```
|
3093
|
+
|
3094
|
+
### Group Configuration
|
3095
|
+
|
3096
|
+
Different task groups can have different halt behavior:
|
3097
|
+
|
3098
|
+
```ruby
|
3099
|
+
class SubscriptionWorkflow < CMDx::Task
|
3100
|
+
include CMDx::Workflow
|
3101
|
+
|
3102
|
+
task CreateSubscription, ValidatePayment, workflow_breakpoints: ["skipped", "failed"]
|
3103
|
+
|
3104
|
+
# Never halt, always continue
|
3105
|
+
task SendConfirmationEmail, UpdateBilling, breakpoints: []
|
3106
|
+
end
|
3107
|
+
```
|
3108
|
+
|
3109
|
+
## Nested Workflows
|
3110
|
+
|
3111
|
+
Workflows can task other workflows for hierarchical composition:
|
3112
|
+
|
3113
|
+
```ruby
|
3114
|
+
class EmailPreparationWorkflow < CMDx::Task
|
3115
|
+
include CMDx::Workflow
|
3116
|
+
|
3117
|
+
task ValidateRecipients
|
3118
|
+
task CompileTemplate
|
3119
|
+
end
|
3120
|
+
|
3121
|
+
class EmailDeliveryWorkflow < CMDx::Task
|
3122
|
+
include CMDx::Workflow
|
3123
|
+
|
3124
|
+
tasks SendEmails, TrackDeliveries
|
3125
|
+
end
|
3126
|
+
|
3127
|
+
class CompleteEmailWorkflow < CMDx::Task
|
3128
|
+
include CMDx::Workflow
|
3129
|
+
|
3130
|
+
task EmailPreparationWorkflow
|
3131
|
+
task EmailDeliveryWorkflow, if: proc { context.preparation_successful? }
|
3132
|
+
task GenerateDeliveryReport
|
3133
|
+
end
|
3134
|
+
```
|
3135
|
+
|
3136
|
+
---
|
3137
|
+
|
3138
|
+
url: https://github.com/drexed/cmdx/blob/main/docs/tips_and_tricks.md
|
3139
|
+
---
|
3140
|
+
|
3141
|
+
# Tips and Tricks
|
3142
|
+
|
3143
|
+
This guide covers advanced patterns and optimization techniques for getting the most out of CMDx in production applications.
|
3144
|
+
|
3145
|
+
## Project Organization
|
3146
|
+
|
3147
|
+
### Directory Structure
|
3148
|
+
|
3149
|
+
Create a well-organized command structure for maintainable applications:
|
3150
|
+
|
3151
|
+
```text
|
3152
|
+
/app/
|
3153
|
+
└── /tasks/
|
3154
|
+
├── /invoices/
|
3155
|
+
│ ├── calculate_tax.rb
|
3156
|
+
│ ├── validate_invoice.rb
|
3157
|
+
│ ├── send_invoice.rb
|
3158
|
+
│ └── process_invoice.rb # workflow
|
3159
|
+
├── /reports/
|
3160
|
+
│ ├── generate_pdf.rb
|
3161
|
+
│ ├── compile_data.rb
|
3162
|
+
│ ├── export_csv.rb
|
3163
|
+
│ └── create_reports.rb # workflow
|
3164
|
+
├── application_task.rb # base class
|
3165
|
+
├── authenticate_session.rb
|
3166
|
+
└── activate_account.rb
|
3167
|
+
```
|
3168
|
+
|
3169
|
+
### Naming Conventions
|
3170
|
+
|
3171
|
+
Follow consistent naming patterns for clarity and maintainability:
|
3172
|
+
|
3173
|
+
```ruby
|
3174
|
+
# Verb + Noun
|
3175
|
+
class ExportData < CMDx::Task; end
|
3176
|
+
class CompressFile < CMDx::Task; end
|
3177
|
+
class ValidateSchema < CMDx::Task; end
|
3178
|
+
|
3179
|
+
# Use present tense verbs for actions
|
3180
|
+
class GenerateToken < CMDx::Task; end # ✓ Good
|
3181
|
+
class GeneratingToken < CMDx::Task; end # ❌ Avoid
|
3182
|
+
class TokenGeneration < CMDx::Task; end # ❌ Avoid
|
3183
|
+
```
|
3184
|
+
|
3185
|
+
### Story Telling
|
3186
|
+
|
3187
|
+
Consider using descriptive methods to express the task’s flow, rather than concentrating all logic inside the `work` method.
|
3188
|
+
|
3189
|
+
```ruby
|
3190
|
+
class ProcessOrder < CMDx::Task
|
3191
|
+
def work
|
3192
|
+
charge_payment_method
|
3193
|
+
assign_to_warehouse
|
3194
|
+
send_notification
|
3195
|
+
end
|
3196
|
+
|
3197
|
+
private
|
3198
|
+
|
3199
|
+
def charge_payment_method
|
3200
|
+
order.primary_payment_method.charge!
|
3201
|
+
end
|
3202
|
+
|
3203
|
+
def assign_to_warehouse
|
3204
|
+
order.ready_for_shipping!
|
3205
|
+
end
|
3206
|
+
|
3207
|
+
def send_notification
|
3208
|
+
if order.products_out_of_stock?
|
3209
|
+
OrderMailer.pending(order).deliver
|
3210
|
+
else
|
3211
|
+
OrderMailer.preparing(order).deliver
|
3212
|
+
end
|
3213
|
+
end
|
3214
|
+
end
|
3215
|
+
```
|
3216
|
+
|
3217
|
+
### Style Guide
|
3218
|
+
|
3219
|
+
Follow a style pattern for consistent task design:
|
3220
|
+
|
3221
|
+
```ruby
|
3222
|
+
class ExportReport < CMDx::Task
|
3223
|
+
|
3224
|
+
# 1. Register functions
|
3225
|
+
register :middleware, CMDx::Middlewares::Correlate
|
3226
|
+
register :validator, :format, FormatValidator
|
3227
|
+
|
3228
|
+
# 2. Define callbacks
|
3229
|
+
before_execution :find_report
|
3230
|
+
on_complete :track_export_metrics, if: ->(task) { Current.tenant.analytics? }
|
3231
|
+
|
3232
|
+
# 3. Declare attributes
|
3233
|
+
attributes :user_id
|
3234
|
+
required :report_id
|
3235
|
+
optional :format_type
|
3236
|
+
|
3237
|
+
# 4. Define work method
|
3238
|
+
def work
|
3239
|
+
report.compile!
|
3240
|
+
report.export!
|
3241
|
+
|
3242
|
+
context.exported_at = Time.now
|
3243
|
+
end
|
3244
|
+
|
3245
|
+
# TIP: Favor private business logic to reduce the surface of the public API.
|
3246
|
+
private
|
3247
|
+
|
3248
|
+
# 5. Build helper functions
|
3249
|
+
def find_report
|
3250
|
+
@report ||= Report.find(report_id)
|
3251
|
+
end
|
3252
|
+
|
3253
|
+
def track_export_metrics
|
3254
|
+
Analytics.increment(:report_exported)
|
3255
|
+
end
|
3256
|
+
|
3257
|
+
end
|
3258
|
+
```
|
3259
|
+
|
3260
|
+
## Attribute Options
|
3261
|
+
|
3262
|
+
Use Rails `with_options` to reduce duplication and improve readability:
|
3263
|
+
|
3264
|
+
```ruby
|
3265
|
+
class ConfigureCompany < CMDx::Task
|
3266
|
+
# Apply common options to multiple attributes
|
3267
|
+
with_options(type: :string, presence: true) do
|
3268
|
+
attributes :website, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
|
3269
|
+
required :company_name, :industry
|
3270
|
+
optional :description, format: { with: /\A[\w\s\-\.,!?]+\z/ }
|
3271
|
+
end
|
3272
|
+
|
3273
|
+
# Nested attributes with shared prefix
|
3274
|
+
required :headquarters do
|
3275
|
+
with_options(prefix: :hq_) do
|
3276
|
+
attributes :street, :city, :zip_code, type: :string
|
3277
|
+
required :country, type: :string, inclusion: { in: VALID_COUNTRIES }
|
3278
|
+
optional :region, type: :string
|
3279
|
+
end
|
3280
|
+
end
|
3281
|
+
|
3282
|
+
def work
|
3283
|
+
# Your logic here...
|
3284
|
+
end
|
3285
|
+
end
|
3286
|
+
```
|
3287
|
+
|
3288
|
+
## ActiveRecord Query Tagging
|
3289
|
+
|
3290
|
+
Automatically tag SQL queries for better debugging:
|
3291
|
+
|
3292
|
+
```ruby
|
3293
|
+
# config/application.rb
|
3294
|
+
config.active_record.query_log_tags_enabled = true
|
3295
|
+
config.active_record.query_log_tags << :cmdx_task_class
|
3296
|
+
config.active_record.query_log_tags << :cmdx_chain_id
|
3297
|
+
|
3298
|
+
# app/tasks/application_task.rb
|
3299
|
+
class ApplicationTask < CMDx::Task
|
3300
|
+
before_execution :set_execution_context
|
3301
|
+
|
3302
|
+
private
|
3303
|
+
|
3304
|
+
def set_execution_context
|
3305
|
+
# NOTE: This could easily be made into a middleware
|
3306
|
+
ActiveSupport::ExecutionContext.set(
|
3307
|
+
cmdx_task_class: self.class.name,
|
3308
|
+
cmdx_chain_id: chain.id
|
3309
|
+
)
|
3310
|
+
end
|
3311
|
+
end
|
3312
|
+
|
3313
|
+
# SQL queries will now include comments like:
|
3314
|
+
# /*cmdx_task_class:ExportReportTask,cmdx_chain_id:018c2b95-b764-7615*/ SELECT * FROM reports WHERE id = 1
|
3315
|
+
```
|
3316
|
+
|
3317
|
+
---
|