cmdx 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -8
  3. data/lib/cmdx/callbacks.rb +31 -11
  4. data/lib/cmdx/chain.rb +29 -10
  5. data/lib/cmdx/coercions/big_decimal.rb +1 -1
  6. data/lib/cmdx/coercions/boolean.rb +3 -9
  7. data/lib/cmdx/coercions/coerce.rb +4 -1
  8. data/lib/cmdx/coercions/date_time.rb +1 -1
  9. data/lib/cmdx/coercions/integer.rb +11 -2
  10. data/lib/cmdx/coercions/symbol.rb +23 -4
  11. data/lib/cmdx/coercions.rb +25 -10
  12. data/lib/cmdx/configuration.rb +31 -16
  13. data/lib/cmdx/context.rb +40 -56
  14. data/lib/cmdx/deprecation.rb +4 -7
  15. data/lib/cmdx/deprecators/error.rb +4 -1
  16. data/lib/cmdx/deprecators.rb +17 -8
  17. data/lib/cmdx/errors.rb +15 -11
  18. data/lib/cmdx/executors/fiber.rb +16 -4
  19. data/lib/cmdx/executors/thread.rb +18 -4
  20. data/lib/cmdx/executors.rb +22 -7
  21. data/lib/cmdx/fault.rb +15 -3
  22. data/lib/cmdx/i18n_proxy.rb +10 -6
  23. data/lib/cmdx/input.rb +23 -21
  24. data/lib/cmdx/inputs.rb +14 -26
  25. data/lib/cmdx/log_formatters/json.rb +8 -1
  26. data/lib/cmdx/log_formatters/logstash.rb +7 -1
  27. data/lib/cmdx/mergers.rb +22 -7
  28. data/lib/cmdx/middlewares.rb +40 -24
  29. data/lib/cmdx/output.rb +5 -2
  30. data/lib/cmdx/pipeline.rb +28 -11
  31. data/lib/cmdx/railtie.rb +1 -0
  32. data/lib/cmdx/result.rb +22 -6
  33. data/lib/cmdx/retriers/decorrelated_jitter.rb +10 -5
  34. data/lib/cmdx/retriers/exponential.rb +10 -2
  35. data/lib/cmdx/retriers/fibonacci.rb +29 -12
  36. data/lib/cmdx/retriers.rb +17 -8
  37. data/lib/cmdx/retry.rb +20 -13
  38. data/lib/cmdx/runtime.rb +22 -40
  39. data/lib/cmdx/settings.rb +9 -9
  40. data/lib/cmdx/signal.rb +1 -1
  41. data/lib/cmdx/task.rb +90 -45
  42. data/lib/cmdx/telemetry.rb +52 -11
  43. data/lib/cmdx/util.rb +50 -4
  44. data/lib/cmdx/validators/absence.rb +1 -1
  45. data/lib/cmdx/validators/exclusion.rb +15 -15
  46. data/lib/cmdx/validators/format.rb +12 -4
  47. data/lib/cmdx/validators/inclusion.rb +15 -15
  48. data/lib/cmdx/validators/length.rb +5 -49
  49. data/lib/cmdx/validators/numeric.rb +5 -49
  50. data/lib/cmdx/validators/presence.rb +1 -1
  51. data/lib/cmdx/validators/validate.rb +7 -1
  52. data/lib/cmdx/validators.rb +21 -9
  53. data/lib/cmdx/version.rb +1 -1
  54. data/lib/cmdx/workflow.rb +28 -14
  55. data/lib/cmdx.rb +24 -0
  56. data/lib/generators/cmdx/templates/install.rb +96 -33
  57. data/mkdocs.yml +2 -0
  58. metadata +1 -1
data/lib/cmdx/workflow.rb CHANGED
@@ -24,10 +24,11 @@ module CMDx
24
24
  @pipeline ||= []
25
25
  end
26
26
 
27
- # Declares a task group. With no arguments, returns the pipeline.
28
- # Tasks must be `Task` subclasses.
27
+ # Declares a task group. At least one `Task` subclass is required —
28
+ # invoking with no arguments raises `DefinitionError`. Use {.pipeline}
29
+ # to read the existing group list.
29
30
  #
30
- # @param tasks [Array<Class<Task>>]
31
+ # @param tasks [Array<Class<Task>>] one or more `Task` subclasses
31
32
  # @param options [Hash{Symbol => Object}]
32
33
  # @option options [:sequential, :parallel] :strategy (:sequential)
33
34
  # @option options [Integer] :pool_size parallel worker/fiber count
@@ -55,18 +56,26 @@ module CMDx
55
56
  # `:parallel` cancels pending tasks (in-flight tasks still finish).
56
57
  # @option options [Symbol, Proc, #call] :if
57
58
  # @option options [Symbol, Proc, #call] :unless
58
- # @return [Array<ExecutionGroup>] the full pipeline
59
- # @raise [DefinitionError] when called with options but no tasks
59
+ # @return [Array<ExecutionGroup>] the full pipeline (with the new group appended)
60
+ # @raise [DefinitionError] when called with no tasks
60
61
  # @raise [TypeError] when any element isn't a `Task` subclass
61
62
  def tasks(*tasks, **options)
62
- raise DefinitionError, "#{name}: cannot declare an empty task group" if tasks.empty?
63
+ if tasks.empty?
64
+ raise DefinitionError, <<~MSG.chomp
65
+ #{name}: cannot declare an empty task group; pass at least one Task subclass.
66
+ See https://drexed.github.io/cmdx/workflows/#declarations
67
+ MSG
68
+ end
63
69
 
64
70
  pipeline << ExecutionGroup.new(
65
71
  tasks:
66
72
  tasks.map do |task|
67
73
  next task if task.is_a?(Class) && (task <= Task)
68
74
 
69
- raise TypeError, "#{task.inspect} is not a Task"
75
+ raise TypeError, <<~MSG.chomp
76
+ #{task.inspect} is not a Task subclass.
77
+ See https://drexed.github.io/cmdx/workflows/#declarations
78
+ MSG
70
79
  end,
71
80
  options:
72
81
  )
@@ -75,16 +84,13 @@ module CMDx
75
84
 
76
85
  private
77
86
 
78
- # Forbids user-defined `work` on workflows; `Workflow#work` delegates
79
- # to {Pipeline}.
80
- #
81
- # @param method_name [Symbol] hook name reported by Ruby
82
- # @return [void]
83
- # @raise [ImplementationError] when a workflow defines `work`
84
87
  def method_added(method_name)
85
88
  return super unless method_name == :work
86
89
 
87
- raise ImplementationError, "cannot define #{name}##{method_name} in a workflow"
90
+ raise ImplementationError, <<~MSG.chomp
91
+ cannot define #{name}##{method_name} in a workflow; #work is auto-generated to delegate to Pipeline.
92
+ See https://drexed.github.io/cmdx/workflows/#declarations
93
+ MSG
88
94
  end
89
95
 
90
96
  end
@@ -95,7 +101,15 @@ module CMDx
95
101
  # @api private
96
102
  # @param base [Class] task class including this mixin
97
103
  # @return [void]
104
+ # @raise [ImplementationError] when `base` is not a {Task} subclass
98
105
  def self.included(base)
106
+ unless base.is_a?(Class) && base <= Task
107
+ raise ImplementationError, <<~MSG.chomp
108
+ CMDx::Workflow can only be included in a CMDx::Task subclass (got #{base.inspect}).
109
+ See https://drexed.github.io/cmdx/workflows/#declarations
110
+ MSG
111
+ end
112
+
99
113
  base.extend(ClassMethods)
100
114
  end
101
115
 
data/lib/cmdx.rb CHANGED
@@ -24,6 +24,13 @@ module CMDx
24
24
  EMPTY_HASH = {}.freeze
25
25
  private_constant :EMPTY_HASH
26
26
 
27
+ # Sentinel object used as a placeholder return value to avoid per-call
28
+ # allocations on hot paths.
29
+ #
30
+ # @api private
31
+ EMPTY_SENTINEL = Object.new.freeze
32
+ private_constant :EMPTY_SENTINEL
33
+
27
34
  # Shared empty string constant used as a sentinel default. Intentionally
28
35
  # not frozen so callers may treat it as a mutable seed when needed.
29
36
  #
@@ -51,6 +58,11 @@ module CMDx
51
58
  # before continuing.
52
59
  DeprecationError = Class.new(Error)
53
60
 
61
+ # Raised when a control-flow signal (e.g. `skip!`, `fail!`) is thrown against
62
+ # a task that has already completed and been frozen, making further state
63
+ # transitions impossible.
64
+ FrozenTaskError = Class.new(Error)
65
+
54
66
  # Raised when a subclass fails to fulfill an abstract contract — most
55
67
  # commonly when {Task} is invoked without overriding `#work`, or when a
56
68
  # {Workflow} attempts to define `#work` itself.
@@ -60,6 +72,18 @@ module CMDx
60
72
  # yield to `next_link`, which would otherwise silently skip the task body.
61
73
  MiddlewareError = Class.new(Error)
62
74
 
75
+ # Raised by {Context} in strict mode when accessing a key that was never
76
+ # assigned, preventing silent `nil` propagation across task boundaries.
77
+ UnknownAccessorError = Class.new(Error)
78
+
79
+ # Raised when a registry lookup (coercion, validator, middleware, etc.) is
80
+ # performed against a name that has not been registered.
81
+ UnknownEntryError = Class.new(Error)
82
+
83
+ # Raised when the configured locale cannot be resolved to a translation
84
+ # file on the i18n load path.
85
+ UnknownLocaleError = Class.new(Error)
86
+
63
87
  end
64
88
 
65
89
  require_relative "cmdx/version"
@@ -1,40 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  CMDx.configure do |config|
4
+ # Full configuration reference:
5
+ # https://drexed.github.io/cmdx/configuration
6
+
4
7
  # ===========================================================================
5
- # Locale
8
+ # Default locale
9
+ # https://drexed.github.io/cmdx/configuration/#default-locale
6
10
  # ===========================================================================
7
- # Fallback locale for built-in messages (validation, coercion, etc.) when
8
- # the I18n gem is not present. With I18n loaded, CMDx follows `I18n.locale`.
11
+ # The language CMDx uses for its built-in messages (validation errors,
12
+ # coercion errors, etc.) when the `I18n` gem isn't around. If `I18n` IS
13
+ # loaded, CMDx follows `I18n.locale` and ignores this setting.
9
14
  #
10
15
  # config.default_locale = "en"
11
16
 
12
17
  # ===========================================================================
13
18
  # Strict context
19
+ # https://drexed.github.io/cmdx/configuration/#strict-context
14
20
  # ===========================================================================
15
- # When true, dynamic reads on `context` raise `NoMethodError` for unknown
16
- # keys instead of returning `nil` (`[]`, `fetch`, `dig`, and `?` predicates
17
- # stay lenient). Override per-task via `settings(strict_context: true)`.
21
+ # Catches typos early. With strict mode on, `context.usr_id` (instead of
22
+ # `context.user_id`) raises `CMDx::UnknownAccessorError` instead of silently
23
+ # returning `nil`. Hash-style reads (`[]`, `fetch`, `dig`, `?` predicates)
24
+ # stay forgiving. Flip it per task with `settings(strict_context: true)`.
18
25
  #
19
26
  # config.strict_context = true
20
27
 
21
28
  # ===========================================================================
22
29
  # Correlation ID (xid)
30
+ # https://drexed.github.io/cmdx/configuration/#correlation-id-xid
23
31
  # ===========================================================================
24
- # Resolves an external correlation id (e.g. Rails `request_id`) once per
25
- # root execution. The value is stored on the Chain and surfaces on every
26
- # Result (`result.xid`, `result.to_h[:xid]`) and Telemetry::Event (`event.xid`),
27
- # so all tasks within the same request can be filtered together in logs.
32
+ # Stamps every task in a run with the same id so you can grep your logs by
33
+ # request. The callable runs ONCE per root execution; every nested task
34
+ # inherits the value. Surfaces as `result.xid`, `result.to_h[:xid]`, and
35
+ # `event.xid` on telemetry events.
28
36
  #
29
37
  # config.correlation_id = -> { Current.request_id }
30
38
 
31
39
  # ===========================================================================
32
40
  # Logging
41
+ # https://drexed.github.io/cmdx/configuration/#logging
33
42
  # ===========================================================================
34
- # In Rails, the Railtie already wires `config.logger = Rails.logger` and a
35
- # backtrace cleaner override here only if you need something different.
43
+ # Pick where logs go, how they look, and what to hide. In Rails, the Railtie
44
+ # already points `logger` at `Rails.logger` and wires a backtrace cleaner
45
+ # only override here if you want something different.
46
+ #
47
+ # Built-in formatters (under `CMDx::LogFormatters`):
48
+ # Line (default), JSON, KeyValue, Logstash, Raw
36
49
  #
37
- # Formatters: Line (default), Json, KeyValue, Logstash, Raw
50
+ # `log_exclusions` only strips keys from the LOG LINE — the in-memory
51
+ # `Result` and telemetry payloads stay complete.
38
52
  #
39
53
  # config.backtrace_cleaner = ->(bt) { Rails.backtrace_cleaner.clean(bt) }
40
54
  # config.log_exclusions = [:context]
@@ -44,8 +58,12 @@ CMDx.configure do |config|
44
58
 
45
59
  # ===========================================================================
46
60
  # Middlewares
61
+ # https://drexed.github.io/cmdx/configuration/#middlewares
47
62
  # ===========================================================================
48
- # Wrap every task's execution. Must respond to `call(task) { ... }`.
63
+ # Wrap every task with shared behavior (auth, locale, timing, you name it).
64
+ # A middleware is anything that responds to `call(task) { ... }` and MUST
65
+ # yield (or call `next_link.call` from a proc) — forgetting to do so raises
66
+ # `CMDx::MiddlewareError` so tasks never silently disappear.
49
67
  #
50
68
  # Example — run each task under the current user's locale:
51
69
  #
@@ -59,9 +77,13 @@ CMDx.configure do |config|
59
77
 
60
78
  # ===========================================================================
61
79
  # Callbacks
80
+ # https://drexed.github.io/cmdx/configuration/#callbacks
62
81
  # ===========================================================================
63
- # Events:
64
- # :before_validation, :before_execution,
82
+ # Hook into a task's lifecycle. Each callback receives the task instance.
83
+ #
84
+ # Available events:
85
+ # :before_execution, :before_validation,
86
+ # :around_execution, :after_execution,
65
87
  # :on_complete, :on_interrupted,
66
88
  # :on_success, :on_skipped, :on_failed,
67
89
  # :on_ok, :on_ko
@@ -72,16 +94,20 @@ CMDx.configure do |config|
72
94
 
73
95
  # ===========================================================================
74
96
  # Telemetry
97
+ # https://drexed.github.io/cmdx/configuration/#telemetry
75
98
  # ===========================================================================
76
- # Events and payloads:
77
- # :task_started payload: {}
78
- # :task_deprecated payload: {}
79
- # :task_retried payload: { attempt: Integer }
80
- # :task_rolled_back payload: {}
81
- # :task_executed payload: { result: CMDx::Result }
99
+ # A tiny pub/sub bus for runtime events. Subscribe with a callable; nothing
100
+ # fires if nobody's listening, so unused events cost nothing.
101
+ #
102
+ # Events and their extra payload keys:
103
+ # :task_started {}
104
+ # :task_deprecated {}
105
+ # :task_retried { attempt: Integer }
106
+ # :task_rolled_back {}
107
+ # :task_executed { result: CMDx::Result }
82
108
  #
83
109
  # Every event also carries: event.cid, event.xid, event.tid, event.task,
84
- # event.type, event.root, event.timestamp.
110
+ # event.type, event.name, event.root, event.payload, event.timestamp.
85
111
  #
86
112
  # config.telemetry.subscribe(:task_executed, proc do |event|
87
113
  # StatsD.timing("cmdx.task", event.payload[:result].duration)
@@ -89,8 +115,11 @@ CMDx.configure do |config|
89
115
 
90
116
  # ===========================================================================
91
117
  # Coercions
118
+ # https://drexed.github.io/cmdx/configuration/#coercions
92
119
  # ===========================================================================
93
- # Register custom type coercions. Callable receives `(value, **options)`.
120
+ # Teach CMDx how to convert raw input into a custom type. The callable gets
121
+ # `(value, **options)` and returns the coerced value (or
122
+ # `CMDx::Coercions::Failure.new("message")` to signal a bad value).
94
123
  #
95
124
  # config.coercions.register(:currency, proc do |value, **|
96
125
  # BigDecimal(value.to_s.gsub(/[^\d.-]/, ""))
@@ -98,9 +127,11 @@ CMDx.configure do |config|
98
127
 
99
128
  # ===========================================================================
100
129
  # Validators
130
+ # https://drexed.github.io/cmdx/configuration/#validators
101
131
  # ===========================================================================
102
- # Register custom validators. Callable receives `(value, options)` and
103
- # returns a `CMDx::Validators::Failure.new(message)` on failure.
132
+ # Custom input validators. The callable gets `(value, options)` (options is
133
+ # a positional Hash). Return `CMDx::Validators::Failure.new(message)` to
134
+ # fail — anything else (even `nil`) means the value passed.
104
135
  #
105
136
  # config.validators.register(:uuid, proc do |value, _options|
106
137
  # unless value.to_s.match?(/\A[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/i)
@@ -108,13 +139,43 @@ CMDx.configure do |config|
108
139
  # end
109
140
  # end)
110
141
 
142
+ # ===========================================================================
143
+ # Retriers
144
+ # https://drexed.github.io/cmdx/retries/
145
+ # ===========================================================================
146
+ # Retriers decide how long to wait between `retry_on` attempts. A callable
147
+ # gets `(attempt, delay, prev_delay)` and returns the next sleep in seconds.
148
+ #
149
+ # Built-ins: :exponential (default), :linear, :fibonacci, :half_random,
150
+ # :full_random, :bounded_random, :decorrelated_jitter.
151
+ #
152
+ # config.retriers.register(:capped_exponential, proc do |attempt, delay, _prev|
153
+ # [delay * (2**(attempt - 1)), 30.0].min
154
+ # end)
155
+
156
+ # ===========================================================================
157
+ # Deprecators
158
+ # https://drexed.github.io/cmdx/deprecation/
159
+ # ===========================================================================
160
+ # Decide what happens when a task with a `deprecation` declaration runs —
161
+ # log a warning, raise, ping your error tracker, whatever you like. The
162
+ # callable receives the task instance.
163
+ #
164
+ # Built-ins: :log, :warn, :error.
165
+ #
166
+ # config.deprecators.register(:notify, proc do |task|
167
+ # Bugsnag.notify("Deprecated task invoked: #{task.class.name}")
168
+ # end)
169
+
111
170
  # ===========================================================================
112
171
  # Executors
172
+ # https://drexed.github.io/cmdx/workflows/#parallel-execution
113
173
  # ===========================================================================
114
- # Registered executors drive `:parallel` workflow groups. Built-ins:
115
- # `:threads` (default), `:fibers`. A callable receives
116
- # `call(jobs:, concurrency:, on_job:)` and must invoke `on_job.call(job)`
117
- # for each job, blocking until every job is done.
174
+ # Executors power `:parallel` workflow groups. The callable gets
175
+ # `(jobs:, concurrency:, on_job:)`, runs `on_job.call(job)` for every job,
176
+ # and blocks until every job is done.
177
+ #
178
+ # Built-ins: :threads (default), :fibers.
118
179
  #
119
180
  # config.executors.register(:ractors, proc do |jobs:, concurrency:, on_job:|
120
181
  # jobs.each_slice(concurrency) do |slice|
@@ -124,10 +185,12 @@ CMDx.configure do |config|
124
185
 
125
186
  # ===========================================================================
126
187
  # Mergers
188
+ # https://drexed.github.io/cmdx/workflows/#parallel-execution
127
189
  # ===========================================================================
128
- # Merge strategies fold successful parallel task contexts back into the
129
- # workflow context. Built-ins: `:last_write_wins` (default), `:deep_merge`,
130
- # `:no_merge`. A callable receives `call(workflow_context, result)`.
190
+ # After parallel branches succeed, a merger folds each branch's context back
191
+ # into the workflow context. The callable gets `(workflow_context, result)`.
192
+ #
193
+ # Built-ins: :last_write_wins (default), :deep_merge, :no_merge.
131
194
  #
132
195
  # config.mergers.register(:whitelist, proc do |workflow_context, result|
133
196
  # result.context.to_h.slice(:order_id, :total).each do |key, value|
data/mkdocs.yml CHANGED
@@ -189,12 +189,14 @@ nav:
189
189
  - Tips and Tricks: tips_and_tricks.md
190
190
  - Comparison: comparison.md
191
191
  - v1 → v2 Migration: v2-migration.md
192
+ - Examples: https://github.com/drexed/cmdx/blob/main/examples/index.md
192
193
  - References:
193
194
  - API Documentation: https://drexed.github.io/cmdx/api/index.html
194
195
  - llms.txt: https://drexed.github.io/cmdx/llms.txt
195
196
  - llms-full.txt: https://drexed.github.io/cmdx/llms-full.txt
196
197
  - AI Skills: https://github.com/drexed/cmdx/blob/main/skills
197
198
  - Ecosystem:
199
+ - cmdx-i18n: https://github.com/drexed/cmdx-i18n
198
200
  - cmdx-rspec: https://github.com/drexed/cmdx-rspec
199
201
  - Blog: blog/index.md
200
202
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cmdx
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Juan Gomez