cmdx 1.19.0 → 1.20.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +69 -16
  4. data/README.md +1 -1
  5. data/lib/cmdx/attribute.rb +82 -19
  6. data/lib/cmdx/attribute_registry.rb +79 -8
  7. data/lib/cmdx/attribute_value.rb +2 -2
  8. data/lib/cmdx/callback_registry.rb +60 -26
  9. data/lib/cmdx/chain.rb +33 -4
  10. data/lib/cmdx/coercion_registry.rb +42 -20
  11. data/lib/cmdx/coercions/array.rb +2 -2
  12. data/lib/cmdx/coercions/big_decimal.rb +1 -1
  13. data/lib/cmdx/coercions/boolean.rb +2 -2
  14. data/lib/cmdx/coercions/complex.rb +1 -1
  15. data/lib/cmdx/coercions/date.rb +1 -1
  16. data/lib/cmdx/coercions/date_time.rb +1 -1
  17. data/lib/cmdx/coercions/float.rb +1 -1
  18. data/lib/cmdx/coercions/hash.rb +1 -1
  19. data/lib/cmdx/coercions/integer.rb +1 -1
  20. data/lib/cmdx/coercions/rational.rb +1 -1
  21. data/lib/cmdx/coercions/string.rb +1 -1
  22. data/lib/cmdx/coercions/symbol.rb +1 -1
  23. data/lib/cmdx/coercions/time.rb +1 -1
  24. data/lib/cmdx/configuration.rb +26 -0
  25. data/lib/cmdx/context.rb +9 -6
  26. data/lib/cmdx/deprecator.rb +27 -14
  27. data/lib/cmdx/errors.rb +3 -4
  28. data/lib/cmdx/exception.rb +7 -0
  29. data/lib/cmdx/executor.rb +75 -52
  30. data/lib/cmdx/identifier.rb +4 -6
  31. data/lib/cmdx/locale.rb +32 -9
  32. data/lib/cmdx/middleware_registry.rb +43 -23
  33. data/lib/cmdx/middlewares/correlate.rb +4 -2
  34. data/lib/cmdx/middlewares/timeout.rb +11 -10
  35. data/lib/cmdx/parallelizer.rb +100 -0
  36. data/lib/cmdx/pipeline.rb +42 -23
  37. data/lib/cmdx/railtie.rb +1 -1
  38. data/lib/cmdx/result.rb +27 -11
  39. data/lib/cmdx/retry.rb +166 -0
  40. data/lib/cmdx/settings.rb +222 -0
  41. data/lib/cmdx/task.rb +53 -63
  42. data/lib/cmdx/utils/format.rb +17 -1
  43. data/lib/cmdx/utils/normalize.rb +52 -0
  44. data/lib/cmdx/utils/wrap.rb +38 -0
  45. data/lib/cmdx/validator_registry.rb +44 -19
  46. data/lib/cmdx/validators/absence.rb +1 -1
  47. data/lib/cmdx/validators/exclusion.rb +2 -2
  48. data/lib/cmdx/validators/format.rb +1 -1
  49. data/lib/cmdx/validators/inclusion.rb +2 -2
  50. data/lib/cmdx/validators/length.rb +1 -1
  51. data/lib/cmdx/validators/numeric.rb +1 -1
  52. data/lib/cmdx/validators/presence.rb +1 -1
  53. data/lib/cmdx/version.rb +1 -1
  54. data/lib/cmdx.rb +12 -0
  55. data/lib/generators/cmdx/templates/install.rb +11 -0
  56. data/mkdocs.yml +5 -1
  57. metadata +6 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ead1d181ed1eac565ea3287eeaaca3216e5d24a40f4071000539584435fc1327
4
- data.tar.gz: ba0273c00286e10621f06fc5b0ba7be760afa99dd8d3fbfc6fc7da1c3c11891f
3
+ metadata.gz: 34ebd45d6a27296d5960995fc1b313b808f1d3835df82ce688ae1a6dc4faa313
4
+ data.tar.gz: 01ccc099ffd40950e4085c850ba3959db59bddbb5df39e8b540a6310607f7a59
5
5
  SHA512:
6
- metadata.gz: 30e8c09228e765f2ac8e9b4f8db854da21a13e3a2d7e67b9ad61ecad66d09dd7f8ca9327ac9a8ef8777e78d56d5844a06f52e55d282cc1d78d7ddb9141b9acc2
7
- data.tar.gz: 922de5ad8342195dc94bf8137e7023612db4df09b3979581c0e77aecf599cae971004e64b9ab71261ea6795feb8ca8b7fe3fe4fc3f37a570ed50157c404aff33
6
+ metadata.gz: 9cadc2e6c98e819512a62eb484c7ffb5ed2ea29bf135851441cb78fa4bb64421b844337d489976c4148c0e471144159640257dc20d574a802e90619d833c2316
7
+ data.tar.gz: 3edd420426ccc2ed48b1bc3f972984111eb10ec1f41bd835de478318cb5dba169cb440c5ff24b202533a942040a62aaf9897d88f03229cc97285ea69af11a377
data/.DS_Store CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -6,25 +6,76 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [UNRELEASED]
8
8
 
9
+ ## [1.20.0] - 2026-03-12
10
+
11
+ ### Added
12
+ - Add `CallbackRegistry#empty?` for fast callback presence checking
13
+ - Add `Parallelizer` class for bounded thread pool execution
14
+ - Add `Configuration#default_locale` setting (defaults to `"en"`)
15
+ - Add `Task.type` to return task mechanics
16
+ - Add `Utils::Normalize` module for exception and status normalization
17
+ - Add `Utils::Wrap` module for array value normalization
18
+ - Add `Retry` class for retry logic, state tracking, and jitter computation
19
+ - Add `Settings` object with method-based access
20
+ - Add `freeze_results` configuration option to replace `SKIP_CMDX_FREEZING` env var
21
+ - Add `any?`, `clear`, and `size` delegators to `Errors`
22
+ - Add `Context#respond_to_missing?` for setter methods
23
+ - Add `Attribute#clear_task_tree!` to prevent stale task instance retention
24
+ - Add thread-safe `Chain#push` and `Chain#index` via `Mutex`
25
+ - Add identity-aware `Executor#clear_chain!` for parallel execution safety
26
+ - Add `Executor#verify_middleware_yield!` to detect non-yielding middlewares
27
+ - Add copy-on-write semantics to `MiddlewareRegistry`, `CallbackRegistry`, `CoercionRegistry`, and `ValidatorRegistry`
28
+ - Add `Attribute#allocation_name` for task-free reader name resolution
29
+ - Add `AttributeRegistry#define_readers_on!` and `#undefine_readers_on!` for eager reader definition
30
+
9
31
  ### Changed
10
- - Add attribute `source` fallback to `:context` when no task is given
11
- - Improve falsy attribute derived Hash value lookup
32
+ - Short-circuit `Executor#post_execution!` when callback registry is empty
33
+ - Optimize `Context#method_missing` to avoid `String` allocation on getter path
34
+ - Replace `parallel` gem with native `Parallelizer` thread pool
35
+ - Rename `in_threads` option to `pool_size`
36
+ - Move `TimeoutError` to `exception.rb` for Zeitwerk autoloading
37
+ - Update Rails initializer install script
38
+ - Dup attributes in `AttributeRegistry#define_and_verify` for thread-safe concurrent execution
39
+ - Default `retry_on` to `[StandardError, CMDx::TimeoutError]`
40
+ - Replace hash-based `settings[:]` with method-based `settings.` access
41
+ - Lazy-load locale translations instead of eager-loading
42
+ - Use compile-time method definition for `Identifier#generate` and `Chain`/`Correlate` `thread_or_fiber`
43
+ - Use `define_method` on task class for attribute readers
44
+ - Tighten `Deprecator` regex to exact word boundaries
45
+ - Use `public_send` instead of `send` in `Result` for state/status checks
46
+
47
+ ### Fixed
48
+ - Fix `Attribute#source` and `#method_name` memoization without a task
49
+ - Fix `execute!` to call `executed!` before `post_execution!` on non-halt path
50
+ - Clear `task.errors` before each retry attempt
51
+ - Fix `Pipeline#execute_tasks_in_parallel` to snapshot context per thread
52
+ - Reject `in_processes` and `in_reactors` options in parallel tasks
53
+
54
+ ### Removed
55
+ - Remove `SKIP_CMDX_FREEZING` env var in favor of `CMDx.configuration.freeze_results`
56
+
57
+ ## [1.19.0] - 2026-03-09
58
+
59
+ ### Changed
60
+ - Fall back attribute `source` to `:context` when no task is given
61
+ - Improve falsy attribute derived hash value lookup
12
62
  - Freeze chain results
13
- - Fix missing fault cause no method error issue
14
- - Add context respond_to? with setter methods
63
+ - Use `to_date`, `to_time`, `to_datetime` for date/time coercion checks
64
+
65
+ ### Fixed
66
+ - Fix missing fault cause `NoMethodError`
15
67
  - Fix validator `allow_nil` inverted logic
16
- - Array coercion JSON parse error no returns CoercionError
17
- - Boolean coercions now return `false` for `nil` and `""`
18
- - Coerce anaglous date, datetime, and time class checks to rely on `to_date`, `to_time`, `to_datetime` methods
68
+ - Fix array coercion JSON parse error to return `CoercionError`
69
+ - Fix boolean coercions to return `false` for `nil` and `""`
19
70
 
20
- ## [1.18.0] - 2025-03-09
71
+ ## [1.18.0] - 2026-03-09
21
72
 
22
73
  ### Changed
23
74
  - Use `Fiber.storage` instead of `Thread.current` for `Chain` and `Correlate` storage, with fallback to `Thread.current` for Ruby < 3.2, making them thread and fiber safe
24
75
  - Clone shared logger in `Task#logger` when `log_level` or `log_formatter` is customized to prevent mutation of the shared instance
25
76
  - Derive attribute values from source objects that respond to the attribute name (via `send`) as fallback when the source is not callable
26
77
 
27
- ## [1.17.0] - 2025-02-23
78
+ ## [1.17.0] - 2026-02-23
28
79
 
29
80
  ### Added
30
81
  - Add `returns` macro for context output validation after task execution
@@ -33,26 +84,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
33
84
  - Add hash coercion for JSON `"null"` string as empty hash
34
85
  - Add attribute sourcing to support both string and symbol keys when sourcing/deriving from Hash
35
86
 
36
- ## Changed
37
- - Do not fail coercion if nil on optional attributes
87
+ ### Changed
38
88
  - Include the source method in the required attribute error message
39
89
 
40
- ## [1.16.0] - 2025-02-06
90
+ ### Fixed
91
+ - Fix coercion to not fail on `nil` for optional attributes
92
+
93
+ ## [1.16.0] - 2026-02-06
41
94
 
42
95
  ### Added
43
96
  - Add `CMDx::Exception` alias for `CMDx::Error`
44
97
 
45
98
  ### Changed
46
- - Renamed `exceptions.rb` file to `exception.rb` (zeitwerk issue)
47
- - Renamed `faults.rb` file to `fault.rb` (zeitwerk issue)
99
+ - Rename `exceptions.rb` file to `exception.rb` (zeitwerk compatibility)
100
+ - Rename `faults.rb` file to `fault.rb` (zeitwerk compatibility)
48
101
 
49
- ## [1.15.0] - 2025-01-21
102
+ ## [1.15.0] - 2026-01-21
50
103
 
51
104
  ### Added
52
105
  - Add attribute `Absence` validator
53
106
  - Add attribute `:description` option
54
107
 
55
- ## [1.14.0] - 2025-01-09
108
+ ## [1.14.0] - 2026-01-09
56
109
 
57
110
  ### Added
58
111
  - Add Ruby 4.0 compatibility
data/README.md CHANGED
@@ -12,9 +12,9 @@
12
12
  [Changelog](./CHANGELOG.md) ·
13
13
  [Report Bug](https://github.com/drexed/cmdx/issues) ·
14
14
  [Request Feature](https://github.com/drexed/cmdx/issues) ·
15
+ [AI Skills](https://github.com/drexed/cmdx/blob/main/skills) ·
15
16
  [llms.txt](https://drexed.github.io/cmdx/llms.txt) ·
16
17
  [llms-full.txt](https://drexed.github.io/cmdx/llms-full.txt) ·
17
- [SKILL.md](https://github.com/drexed/cmdx/blob/main/docs/SKILL.md)
18
18
 
19
19
  <img alt="Version" src="https://img.shields.io/gem/v/cmdx">
20
20
  <img alt="Build" src="https://github.com/drexed/cmdx/actions/workflows/ci.yml/badge.svg">
@@ -108,7 +108,7 @@ module CMDx
108
108
  def initialize(name, options = {}, &)
109
109
  @parent = options.delete(:parent)
110
110
  @required = options.delete(:required) || false
111
- @types = Array(options.delete(:types) || options.delete(:type))
111
+ @types = Utils::Wrap.array(options.delete(:types) || options.delete(:type))
112
112
  @description = options.delete(:description) || options.delete(:desc)
113
113
 
114
114
  @name = name.to_sym
@@ -118,6 +118,21 @@ module CMDx
118
118
  instance_eval(&) if block_given?
119
119
  end
120
120
 
121
+ # Deep-copies children and clears task-dependent memoization so that
122
+ # duped attributes can be safely bound to a task without mutating the
123
+ # class-level originals. This makes concurrent execution thread-safe.
124
+ #
125
+ # @param source [Attribute] The attribute being duplicated
126
+ #
127
+ # @rbs (Attribute source) -> void
128
+ def initialize_dup(source)
129
+ super
130
+ @children = source.children.map(&:dup)
131
+ remove_instance_variable(:@source) if defined?(@source)
132
+ remove_instance_variable(:@method_name) if defined?(@method_name)
133
+ @task = nil
134
+ end
135
+
121
136
  class << self
122
137
 
123
138
  # Builds multiple attributes with the same configuration.
@@ -220,15 +235,45 @@ module CMDx
220
235
  def source
221
236
  return @source if defined?(@source)
222
237
 
223
- @source = parent&.method_name || begin
238
+ parent&.method_name || begin
224
239
  value = options[:source]
225
240
 
226
241
  if value.is_a?(Proc)
227
- task ? task.instance_eval(&value) : :context
242
+ task ? @source = task.instance_eval(&value) : :context
228
243
  elsif value.respond_to?(:call)
229
- task ? value.call(task) : :context
244
+ task ? @source = value.call(task) : :context
230
245
  else
231
- value || :context
246
+ @source = value || :context
247
+ end
248
+ end
249
+ end
250
+
251
+ # Returns the method name for this attribute when it can be resolved
252
+ # statically (without a task instance). Returns nil for Proc/callable
253
+ # sources whose method name depends on runtime evaluation.
254
+ #
255
+ # @return [Symbol, nil] The static method name, or nil if dynamic
256
+ #
257
+ # @example
258
+ # attribute.allocation_name # => :user_name
259
+ #
260
+ # @rbs () -> Symbol?
261
+ def allocation_name
262
+ return @allocation_name if defined?(@allocation_name)
263
+
264
+ @allocation_name = options[:as] || begin
265
+ src = options[:source]
266
+ source_name =
267
+ if parent
268
+ parent.allocation_name
269
+ elsif !src.is_a?(Proc) && !src.respond_to?(:call)
270
+ src || :context
271
+ end
272
+
273
+ if source_name.is_a?(Symbol)
274
+ prefix = AFFIX.call(options[:prefix]) { "#{source_name}_" }
275
+ suffix = AFFIX.call(options[:suffix]) { "_#{source_name}" }
276
+ :"#{prefix}#{name}#{suffix}"
232
277
  end
233
278
  end
234
279
  end
@@ -244,12 +289,17 @@ module CMDx
244
289
  def method_name
245
290
  return @method_name if defined?(@method_name)
246
291
 
247
- @method_name = options[:as] || begin
292
+ result = options[:as] || begin
248
293
  prefix = AFFIX.call(options[:prefix]) { "#{source}_" }
249
294
  suffix = AFFIX.call(options[:suffix]) { "_#{source}" }
250
-
251
295
  :"#{prefix}#{name}#{suffix}"
252
296
  end
297
+
298
+ # Only memoize if @source is defined to avoid memoizing method
299
+ # name when no task is present.
300
+ return result unless defined?(@source)
301
+
302
+ @method_name = result
253
303
  end
254
304
 
255
305
  # Defines and verifies the entire attribute tree including nested children.
@@ -264,6 +314,15 @@ module CMDx
264
314
  end
265
315
  end
266
316
 
317
+ # Recursively clears the task reference from this attribute and all children.
318
+ # Prevents the class-level attribute from retaining the last-executed task instance.
319
+ #
320
+ # @rbs () -> void
321
+ def clear_task_tree!
322
+ @task = nil
323
+ children.each(&:clear_task_tree!)
324
+ end
325
+
267
326
  # @return [Hash] A hash representation of the attribute
268
327
  #
269
328
  # @example
@@ -348,29 +407,33 @@ module CMDx
348
407
  attributes(*names, **options.merge(required: true), &)
349
408
  end
350
409
 
351
- # Defines the attribute method on the task and validates the configuration.
410
+ # Defines the attribute reader on the task class (once) and
411
+ # generates/validates the per-instance value (every execution).
352
412
  #
353
413
  # @raise [RuntimeError] When the method name is already defined on the task
354
414
  #
355
415
  # @rbs () -> void
356
416
  def define_and_verify
357
- if task.respond_to?(method_name, true)
358
- raise <<~MESSAGE
359
- The method #{method_name.inspect} is already defined on the #{task.class.name} task.
360
- This may be due conflicts with one of the task's user defined or internal methods/attributes.
417
+ name_of_method = method_name
418
+
419
+ unless task.class.method_defined?(name_of_method)
420
+ if task.respond_to?(name_of_method, true)
421
+ raise <<~MESSAGE
422
+ The method #{name_of_method.inspect} is already defined on the #{task.class.name} task.
423
+ This may be due conflicts with one of the task's user defined or internal methods/attributes.
361
424
 
362
- Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods.
363
- MESSAGE
425
+ Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods.
426
+ MESSAGE
427
+ end
428
+
429
+ task.class.define_method(name_of_method) do
430
+ attributes[name_of_method]
431
+ end
364
432
  end
365
433
 
366
434
  attribute_value = AttributeValue.new(self)
367
435
  attribute_value.generate
368
436
  attribute_value.validate
369
-
370
- name_of_method = method_name
371
- task.define_singleton_method(name_of_method) do
372
- attributes[name_of_method]
373
- end
374
437
  end
375
438
 
376
439
  end
@@ -56,7 +56,7 @@ module CMDx
56
56
  #
57
57
  # @rbs (Attribute | Array[Attribute] attributes) -> self
58
58
  def register(attributes)
59
- @registry.concat(Array(attributes))
59
+ @registry.concat(Utils::Wrap.array(attributes))
60
60
  self
61
61
  end
62
62
 
@@ -73,29 +73,100 @@ module CMDx
73
73
  #
74
74
  # @rbs ((Symbol | String | Array[Symbol | String]) names) -> self
75
75
  def deregister(names)
76
- Array(names).each do |name|
76
+ Utils::Wrap.array(names).each do |name|
77
77
  @registry.reject! { |attribute| matches_attribute_tree?(attribute, name.to_sym) }
78
78
  end
79
79
 
80
80
  self
81
81
  end
82
82
 
83
- # Associates all registered attributes with a task and verifies their definitions.
84
- # This method is called during task setup to establish attribute-task relationships
85
- # and validate the attribute hierarchy.
83
+ # Eagerly defines attribute reader methods on the task class for all
84
+ # attributes whose method names can be statically resolved. Called at
85
+ # class definition time so readers are defined once, not per-execution.
86
86
  #
87
- # @param task [Task] The task to associate with all attributes
87
+ # @param task_class [Class] The task class to define readers on
88
+ # @param attrs [Array<Attribute>] Attributes to process (defaults to all)
89
+ #
90
+ # @rbs (Class task_class, ?Array[Attribute] attrs) -> void
91
+ def define_readers_on!(task_class, attrs = registry)
92
+ attrs.each { |attr| define_reader_tree!(task_class, attr) }
93
+ end
94
+
95
+ # Removes eagerly defined reader methods from the task class for
96
+ # attributes about to be deregistered. Must be called before {#deregister}.
97
+ #
98
+ # @param task_class [Class] The task class to undefine readers on
99
+ # @param names [Array<Symbol, String>] Attribute method names being removed
100
+ #
101
+ # @rbs (Class task_class, (Symbol | String | Array[Symbol | String]) names) -> void
102
+ def undefine_readers_on!(task_class, names)
103
+ Utils::Wrap.array(names).each do |name|
104
+ sym = name.to_sym
105
+
106
+ registry.each do |attribute|
107
+ next unless matches_attribute_tree?(attribute, sym)
108
+
109
+ undefine_reader_tree!(task_class, attribute)
110
+ end
111
+ end
112
+ end
113
+
114
+ # Verifies attribute definitions against a task instance.
115
+ # Each attribute is duped before binding so the class-level originals
116
+ # are never mutated — this eliminates the concurrency hazard of
117
+ # shared mutable @task, @source, and @method_name state.
118
+ #
119
+ # @param task [Task] The task to verify attributes against
88
120
  #
89
121
  # @rbs (Task task) -> void
90
122
  def define_and_verify(task)
91
123
  registry.each do |attribute|
92
- attribute.task = task
93
- attribute.define_and_verify_tree
124
+ duplicate = attribute.dup
125
+ duplicate.task = task
126
+ duplicate.define_and_verify_tree
94
127
  end
95
128
  end
96
129
 
97
130
  private
98
131
 
132
+ # Recursively defines reader methods for an attribute and its children
133
+ # when the method name is statically resolvable.
134
+ #
135
+ # @param task_class [Class] The task class to define readers on
136
+ # @param attribute [Attribute] The attribute to define a reader for
137
+ #
138
+ # @rbs (Class task_class, Attribute attribute) -> void
139
+ def define_reader_tree!(task_class, attribute)
140
+ name = attribute.allocation_name
141
+
142
+ if name && !task_class.method_defined?(name)
143
+ if task_class.private_method_defined?(name)
144
+ raise <<~MESSAGE
145
+ The method #{name.inspect} is already defined on the #{task_class.name} task.
146
+ This may be due conflicts with one of the task's user defined or internal methods/attributes.
147
+
148
+ Use :as, :prefix, and/or :suffix attribute options to avoid conflicts with existing methods.
149
+ MESSAGE
150
+ end
151
+
152
+ task_class.define_method(name) { attributes[name] }
153
+ end
154
+
155
+ attribute.children.each { |child| define_reader_tree!(task_class, child) }
156
+ end
157
+
158
+ # Recursively removes reader methods for an attribute and its children.
159
+ #
160
+ # @param task_class [Class] The task class to undefine readers on
161
+ # @param attribute [Attribute] The attribute whose readers to remove
162
+ #
163
+ # @rbs (Class task_class, Attribute attribute) -> void
164
+ def undefine_reader_tree!(task_class, attribute)
165
+ name = attribute.allocation_name
166
+ task_class.remove_method(name) if name && task_class.method_defined?(name, false)
167
+ attribute.children.each { |child| undefine_reader_tree!(task_class, child) }
168
+ end
169
+
99
170
  # Recursively checks if an attribute or any of its children match the given name.
100
171
  #
101
172
  # @param attribute [Attribute] The attribute to check
@@ -81,7 +81,7 @@ module CMDx
81
81
  #
82
82
  # @rbs () -> void
83
83
  def validate
84
- registry = task.class.settings[:validators]
84
+ registry = task.class.settings.validators
85
85
 
86
86
  options.slice(*registry.keys).each do |type, opts|
87
87
  registry.validate(type, task, value, opts)
@@ -226,7 +226,7 @@ module CMDx
226
226
  def coerce_value(transformed_value)
227
227
  return transformed_value if types.empty?
228
228
 
229
- registry = task.class.settings[:coercions]
229
+ registry = task.class.settings.coercions
230
230
  last_idx = types.size - 1
231
231
 
232
232
  types.find.with_index do |type, i|
@@ -5,8 +5,13 @@ module CMDx
5
5
  #
6
6
  # Callbacks are organized by type and can be registered with optional conditions and options.
7
7
  # Each callback type represents a specific execution phase or outcome.
8
+ #
9
+ # Supports copy-on-write semantics: a duped registry shares the parent's
10
+ # data until a write operation triggers materialization.
8
11
  class CallbackRegistry
9
12
 
13
+ extend Forwardable
14
+
10
15
  # @rbs TYPES: Array[Symbol]
11
16
  TYPES = %i[
12
17
  before_validation
@@ -21,32 +26,43 @@ module CMDx
21
26
  on_bad
22
27
  ].freeze
23
28
 
24
- # Returns the internal registry of callbacks organized by type.
25
- #
26
- # @return [Hash{Symbol => Set<Array>}] Hash mapping callback types to their registered callables
27
- #
28
- # @example
29
- # registry.registry # => { before_execution: #<Set: [[[:validate], {}]]> }
29
+ # @rbs TYPES_SET: Set[Symbol]
30
+ TYPES_SET = TYPES.to_set.freeze
31
+ private_constant :TYPES_SET
32
+
33
+ def_delegators :registry, :empty?
34
+
35
+ # @param registry [Hash, nil] Initial registry hash, defaults to empty
30
36
  #
31
- # @rbs @registry: Hash[Symbol, Set[Array[untyped]]]
32
- attr_reader :registry
33
- alias to_h registry
37
+ # @rbs (?Hash[Symbol, Set[Array[untyped]]]? registry) -> void
38
+ def initialize(registry = nil)
39
+ @registry = registry || {}
40
+ end
34
41
 
35
- # @param registry [Hash] Initial registry hash, defaults to empty
42
+ # Sets up copy-on-write state when duplicated via dup.
43
+ #
44
+ # @param source [CallbackRegistry] The registry being duplicated
36
45
  #
37
- # @rbs (?Hash[Symbol, Set[Array[untyped]]] registry) -> void
38
- def initialize(registry = {})
39
- @registry = registry
46
+ # @rbs (CallbackRegistry source) -> void
47
+ def initialize_dup(source)
48
+ @parent = source
49
+ @registry = nil
50
+ super
40
51
  end
41
52
 
42
- # Creates a deep copy of the registry with duplicated callable sets
53
+ # Returns the internal registry of callbacks organized by type.
54
+ # Delegates to the parent registry when not yet materialized.
43
55
  #
44
- # @return [CallbackRegistry] A new instance with duplicated registry contents
56
+ # @return [Hash{Symbol => Set<Array>}] Hash mapping callback types to their registered callables
45
57
  #
46
- # @rbs () -> CallbackRegistry
47
- def dup
48
- self.class.new(registry.transform_values(&:dup))
58
+ # @example
59
+ # registry.registry # => { before_execution: #<Set: [[[:validate], {}]]> }
60
+ #
61
+ # @rbs () -> Hash[Symbol, Set[Array[untyped]]]
62
+ def registry
63
+ @registry || @parent.registry
49
64
  end
65
+ alias to_h registry
50
66
 
51
67
  # Registers one or more callables for a specific callback type
52
68
  #
@@ -70,10 +86,12 @@ module CMDx
70
86
  #
71
87
  # @rbs (Symbol type, *untyped callables, **untyped options) ?{ (Task) -> void } -> self
72
88
  def register(type, *callables, **options, &block)
89
+ materialize!
90
+
73
91
  callables << block if block_given?
74
92
 
75
- registry[type] ||= Set.new
76
- registry[type] << [callables, options]
93
+ @registry[type] ||= Set.new
94
+ @registry[type] << [callables, options]
77
95
  self
78
96
  end
79
97
 
@@ -92,11 +110,13 @@ module CMDx
92
110
  #
93
111
  # @rbs (Symbol type, *untyped callables, **untyped options) ?{ (Task) -> void } -> self
94
112
  def deregister(type, *callables, **options, &block)
113
+ materialize!
114
+
95
115
  callables << block if block_given?
96
- return self unless registry[type]
116
+ return self unless @registry[type]
97
117
 
98
- registry[type].delete([callables, options])
99
- registry.delete(type) if registry[type].empty?
118
+ @registry[type].delete([callables, options])
119
+ @registry.delete(type) if @registry[type].empty?
100
120
  self
101
121
  end
102
122
 
@@ -112,12 +132,13 @@ module CMDx
112
132
  #
113
133
  # @rbs (Symbol type, Task task) -> void
114
134
  def invoke(type, task)
115
- raise TypeError, "unknown callback type #{type.inspect}" unless TYPES.include?(type)
135
+ raise TypeError, "unknown callback type #{type.inspect}" unless TYPES_SET.include?(type)
136
+ return unless registry[type]
116
137
 
117
- Array(registry[type]).each do |callables, options|
138
+ registry[type].each do |callables, options|
118
139
  next unless Utils::Condition.evaluate(task, options)
119
140
 
120
- Array(callables).each do |callable|
141
+ Utils::Wrap.array(callables).each do |callable|
121
142
  if callable.is_a?(Symbol)
122
143
  task.send(callable)
123
144
  elsif callable.is_a?(Proc)
@@ -131,5 +152,18 @@ module CMDx
131
152
  end
132
153
  end
133
154
 
155
+ private
156
+
157
+ # Copies the parent's registry data into this instance,
158
+ # severing the copy-on-write link.
159
+ #
160
+ # @rbs () -> void
161
+ def materialize!
162
+ return if @registry
163
+
164
+ @registry = @parent.registry.transform_values(&:dup)
165
+ @parent = nil
166
+ end
167
+
134
168
  end
135
169
  end
data/lib/cmdx/chain.rb CHANGED
@@ -31,7 +31,7 @@ module CMDx
31
31
  # @rbs @results: Array[Result]
32
32
  attr_reader :results
33
33
 
34
- def_delegators :results, :index, :first, :last, :size
34
+ def_delegators :results, :first, :last, :size
35
35
  def_delegators :first, :state, :status, :outcome, :runtime
36
36
 
37
37
  # Creates a new chain with a unique identifier and empty results collection.
@@ -40,6 +40,7 @@ module CMDx
40
40
  #
41
41
  # @rbs () -> void
42
42
  def initialize(dry_run: false)
43
+ @mutex = Mutex.new
43
44
  @id = Identifier.generate
44
45
  @results = []
45
46
  @dry_run = !!dry_run
@@ -107,7 +108,7 @@ module CMDx
107
108
  raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result)
108
109
 
109
110
  self.current ||= new(dry_run:)
110
- current.results << result
111
+ current.push(result)
111
112
  current
112
113
  end
113
114
 
@@ -118,12 +119,40 @@ module CMDx
118
119
  # @return [Hash] The thread or fiber storage
119
120
  #
120
121
  # @rbs () -> Hash
121
- def thread_or_fiber
122
- Fiber.respond_to?(:storage) ? Fiber.storage : Thread.current
122
+ if Fiber.respond_to?(:storage)
123
+ def thread_or_fiber = Fiber.storage
124
+ else
125
+ def thread_or_fiber = Thread.current
123
126
  end
124
127
 
125
128
  end
126
129
 
130
+ # Thread-safe append of a result to the chain.
131
+ # Caches the result's index to avoid repeated O(n) lookups.
132
+ #
133
+ # @param result [Result] The result to append
134
+ #
135
+ # @return [Array<Result>] The updated results array
136
+ #
137
+ # @rbs (Result result) -> Array[Result]
138
+ def push(result)
139
+ @mutex.synchronize do
140
+ result.instance_variable_set(:@chain_index, @results.size)
141
+ @results << result
142
+ end
143
+ end
144
+
145
+ # Thread-safe lookup of a result's position in the chain.
146
+ #
147
+ # @param result [Result] The result to find
148
+ #
149
+ # @return [Integer, nil] The zero-based index or nil if not found
150
+ #
151
+ # @rbs (Result result) -> Integer?
152
+ def index(result)
153
+ @mutex.synchronize { @results.index(result) }
154
+ end
155
+
127
156
  # Returns whether the chain is running in dry-run mode.
128
157
  #
129
158
  # @return [Boolean] Whether the chain is running in dry-run mode