cmdx 2.0.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -8
- data/lib/cmdx/callbacks.rb +31 -11
- data/lib/cmdx/chain.rb +29 -10
- data/lib/cmdx/coercions/big_decimal.rb +1 -1
- data/lib/cmdx/coercions/boolean.rb +3 -9
- data/lib/cmdx/coercions/coerce.rb +4 -1
- data/lib/cmdx/coercions/date_time.rb +1 -1
- data/lib/cmdx/coercions/integer.rb +11 -2
- data/lib/cmdx/coercions/symbol.rb +23 -4
- data/lib/cmdx/coercions.rb +25 -10
- data/lib/cmdx/configuration.rb +31 -16
- data/lib/cmdx/context.rb +36 -52
- data/lib/cmdx/deprecation.rb +4 -7
- data/lib/cmdx/deprecators/error.rb +4 -1
- data/lib/cmdx/deprecators.rb +17 -8
- data/lib/cmdx/errors.rb +11 -10
- data/lib/cmdx/executors/fiber.rb +16 -4
- data/lib/cmdx/executors/thread.rb +18 -4
- data/lib/cmdx/executors.rb +22 -7
- data/lib/cmdx/fault.rb +15 -3
- data/lib/cmdx/i18n_proxy.rb +9 -5
- data/lib/cmdx/input.rb +23 -21
- data/lib/cmdx/inputs.rb +14 -26
- data/lib/cmdx/log_formatters/json.rb +8 -1
- data/lib/cmdx/log_formatters/logstash.rb +7 -1
- data/lib/cmdx/mergers.rb +22 -7
- data/lib/cmdx/middlewares.rb +40 -24
- data/lib/cmdx/output.rb +5 -2
- data/lib/cmdx/pipeline.rb +18 -3
- data/lib/cmdx/railtie.rb +1 -0
- data/lib/cmdx/result.rb +22 -6
- data/lib/cmdx/retriers/decorrelated_jitter.rb +10 -5
- data/lib/cmdx/retriers/exponential.rb +10 -2
- data/lib/cmdx/retriers/fibonacci.rb +29 -12
- data/lib/cmdx/retriers.rb +17 -8
- data/lib/cmdx/retry.rb +20 -13
- data/lib/cmdx/runtime.rb +18 -17
- data/lib/cmdx/settings.rb +9 -9
- data/lib/cmdx/signal.rb +1 -1
- data/lib/cmdx/task.rb +90 -45
- data/lib/cmdx/telemetry.rb +37 -10
- data/lib/cmdx/util.rb +50 -4
- data/lib/cmdx/validators/absence.rb +1 -1
- data/lib/cmdx/validators/exclusion.rb +15 -15
- data/lib/cmdx/validators/format.rb +12 -4
- data/lib/cmdx/validators/inclusion.rb +15 -15
- data/lib/cmdx/validators/length.rb +5 -49
- data/lib/cmdx/validators/numeric.rb +5 -49
- data/lib/cmdx/validators/presence.rb +1 -1
- data/lib/cmdx/validators/validate.rb +7 -1
- data/lib/cmdx/validators.rb +21 -9
- data/lib/cmdx/version.rb +1 -1
- data/lib/cmdx/workflow.rb +28 -14
- data/lib/cmdx.rb +24 -0
- data/lib/generators/cmdx/templates/install.rb +80 -39
- data/mkdocs.yml +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d3bfaab740912c354bf1fb028b0ba0271760974f23490a9d6b8a67a82bd1675
|
|
4
|
+
data.tar.gz: 3c8dca3e0708d701b36dc65cbf7eefa0c14a932b783226c40f9f951886c1edb0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c6d28f1702c1034b55b9ffdad6e902f3d99b741bed3b12131d0fc5f15e7538143ac5a4e15f04e82c1f922d872415256290390b93a926f7a3bc830c046603f25c
|
|
7
|
+
data.tar.gz: 63bd3228b4b1fc75acb295d0d10f734e9f7452447568e650252b43a308c1ed9e1552c16a92f7a308e3c0acc87eebe962730ab758c3139a1b0f7c3f46c3664bfd
|
data/CHANGELOG.md
CHANGED
|
@@ -6,12 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [2.x.x] - UNRELEASED
|
|
8
8
|
|
|
9
|
+
### Added
|
|
10
|
+
- `Result#error` — convenience returning `cause` (rescued `Exception`) when a failure was raised, otherwise the `reason` `String`; `nil` for non-failed results. Lets telemetry adapters branch on type without repeating the `cause || reason` dance
|
|
11
|
+
- Middlewares and lifecycle callbacks may halt the task with `success!` / `skip!` / `fail!` / `throw!` — `Runtime#execute` now wraps the middleware chain in `catch(Signal::TAG)` so a signal thrown before yielding to `next_link` becomes the task's outcome instead of an `UncaughtThrowError`
|
|
12
|
+
- `CMDx::Util.deep_merge` — recursive `Hash` merge with scalar last-write-wins; used by `Context#deep_merge` and `I18nProxy` locale YAML folding
|
|
13
|
+
- `CMDx::Util.deep_dup` — recursive `Hash` / `Array` copy with scalar sharing and `#dup` fallback; used by `Context#deep_dup`
|
|
14
|
+
- Add `Telemetry#lookup` (subscriber list, or `UnknownEntryError` when `event` is unknown)
|
|
15
|
+
- Add `CMDx.config` as an alias for `CMDx.configuration`
|
|
16
|
+
- Add `key?` to the name-keyed registries `Callbacks`, `Coercions`, `Validators`, `Executors`, `Mergers`, `Retriers`, and `Deprecators`
|
|
17
|
+
- `Coercions::Symbol` accepts `:max_length` (default `256`); longer strings fail with the usual coercion message (reduces symbol-table pressure from oversized untrusted strings)
|
|
18
|
+
- `Coercions::Integer` honors `:base` for non-decimal string input (e.g. `"0x10"` with `base: 16`); non-`String` values ignore `:base`
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- `Executors::Thread` and `Executors::Fiber` require `concurrency >= 1` (`ArgumentError` on zero/negative, avoiding a silent hang); worker `on_job` errors are collected and the first is re-raised after workers finish
|
|
22
|
+
- `Chain` synchronizes `index`, `each`, `last`, `root`, `size`, `empty?`, `freeze`, and `results` on the existing mutex; `#results` dups while the chain stays mutable; `#root` is updated on `push` / `unshift` when the new result is the root
|
|
23
|
+
- `Context#initialize_copy` copies the backing table and preserves `@strict`, so `dup` / `clone` no longer share `@table`; `Context#deep_dup` preserves `@strict`; `Context#to_h` returns `table.dup` unless the context is frozen
|
|
24
|
+
- `Telemetry#emit` returns immediately when the telemetry registry is empty; `Runtime` and `Pipeline` still gate lifecycle emits with `Telemetry#subscribed?` first (calling `emit` for an unknown `event` while other events exist still goes through `lookup` and raises `UnknownEntryError`)
|
|
25
|
+
- `Task.optional` / `Task.required` and the matching `Inputs::ChildBuilder` helpers merge `**options` then pin `required:` (`optional` forces `false`, `required` forces `true`) so `required:` inside `**options` cannot flip polarity
|
|
26
|
+
- `Retry#wait` bounds jitter: non-`Numeric` / non-finite results fall back to base `delay`; the sleep is clamped to `[0, max_delay]` when set; registry strategies, task Symbol methods, `Proc` / `instance_exec` blocks, and `#call`-ables receive `(attempt, delay, prev_delay)`
|
|
27
|
+
- `Retry#build` returns `self` only when new exceptions, new options, and a replacement block are all empty — option-only subclass updates such as `retry_on(limit: 5)` apply
|
|
28
|
+
- `Retriers::Exponential` caps the doubling exponent at `30` (`2 ** attempt` saturates); `Retriers::Fibonacci` memoizes the sequence (lock-free reads, mutex-guarded growth) and caps `attempt + 1` at `78`; `Retriers::DecorrelatedJitter` draws in `[delay, max(prev_delay * 3, delay)]` so the delay never sits below the configured base
|
|
29
|
+
- `Pipeline#rollback_executed!` marks successful results `rolled_back` before each compensator, logs each `#rollback` failure at `error`, continues through the remaining tasks, then re-raises the **first** compensator exception so strict runs surface rollback faults
|
|
30
|
+
- `Validators::Inclusion` / `Validators::Exclusion` raise `ArgumentError` when `:in` / `:within` is a `Hash` (instead of flattening into key/value pairs)
|
|
31
|
+
- `Validators::Format` sends values without `#match?` through `#to_s` before matching so non-strings do not raise `NoMethodError`
|
|
32
|
+
- `Coercions::DateTime` rescues `RangeError` in addition to `ArgumentError` / `TypeError` / `Date::Error` and returns `Failure`
|
|
33
|
+
- `Coercions::Boolean` coerces `nil` to `false` (unknown strings still fail)
|
|
34
|
+
- `Input` resolution treats `Symbol` sources whose resolved object is `false` as absent; parent reads prefer `#fetch` with a sentinel so explicit `nil` stays distinct from a missing key
|
|
35
|
+
- `LogFormatters::JSON` and `LogFormatters::Logstash` rescue `JSON.dump` failures, substitute `message.inspect`, and record `logerr` so a bad payload cannot crash the logger process
|
|
36
|
+
- `Inputs#deregister` ignores unknown names (same as `Outputs#deregister`; avoids `NoMethodError` on a missing entry)
|
|
37
|
+
- `Railtie` uses a `{*}.yml` locale segment when `app.config.i18n.available_locales` is empty (the old `{}` segment matched no files)
|
|
38
|
+
- `Errors` coerces string keys to symbols on `add`, `[]`, `added?`, `key?` / `for?`, and `delete` to match `Context`
|
|
39
|
+
- `I18nProxy.register` deep-merges locale YAML so later files patch nested keys without replacing whole branches
|
|
40
|
+
- `Middlewares#process` builds the wrap chain with the same iterative reverse-reduce pattern as `Callbacks#around` (no recursive lambda trampoline)
|
|
41
|
+
- `Context#to_s` uses a pre-sized `String` buffer instead of `map` / `join`
|
|
42
|
+
- `CMDx.reset_configuration!` walks `ObjectSpace.each_object(Class)` and clears cached registry ivars on every `Task` subclass, not only `Task`
|
|
43
|
+
- Replace selected stdlib exceptions with `CMDx::Error` subclasses so `rescue CMDx::Error` covers more framework cases: `FrozenTaskError` (was `FrozenError`) when signaling after freeze; `UnknownAccessorError` (was `NoMethodError`) on strict context reader misses; `UnknownEntryError` (was `ArgumentError`) on registry misses and on unknown `Telemetry` events in `lookup` / `unsubscribe`; `UnknownLocaleError` (was `LoadError`) when the default locale file cannot be loaded
|
|
44
|
+
- Broader error copy refresh in `lib/cmdx/`: registry misses list the bad key plus registered keys; validator / option errors contrast passed vs accepted keys; type mismatches name the unexpected class; short messages name the entry point (e.g. `CMDx.configure`); `MiddlewareError` names the middleware that failed to yield; nuanced cases append https://drexed.github.io/cmdx/ anchors; multi-line text prefers squiggly heredocs (`<<~MSG`) so permalinks stay on their own line in logs
|
|
45
|
+
- Document the Symbol dispatch contract for callbacks, coercions, validators, and input sources (`send` / instance hooks reach private helpers); never derive those symbols from untrusted input
|
|
46
|
+
- `Workflow.included` raises `ImplementationError` immediately when the host is not a `Task` subclass (instead of failing later at execution)
|
|
47
|
+
- Clearer `DefinitionError` when sibling nested inputs collide on the same accessor, pointing to `:as` / `:prefix` / `:suffix` or separate `inputs` blocks
|
|
48
|
+
- `Fault.for?` / `Fault.reason?` / `Fault.matches?` YARD notes explain per-call anonymous subclass allocation; hoist matchers at module scope on hot paths
|
|
49
|
+
- `Settings#correlation_id`, `Result#xid`, and `Chain#initialize` YARD: `correlation_id` is a callable evaluated when the root chain is created, not a literal `String` / xid
|
|
50
|
+
- Install generator comments refreshed for strict context and current registration APIs
|
|
51
|
+
|
|
52
|
+
## [2.0.1] - 2026-05-09
|
|
53
|
+
|
|
9
54
|
### Changed
|
|
10
55
|
- Link cause of a raised Fault exception
|
|
11
56
|
- Simplify context `method_missing` lookup order (small perf boost)
|
|
12
57
|
- Attempt translation of errors `full_messages`
|
|
13
58
|
- Emit `:task_rolled_back` telemetry event on workflow rollback
|
|
14
|
-
- `
|
|
59
|
+
- Run `before_validation` before the `around_execution` stack (it used to run inside the around callbacks); the around hook still wraps input resolution, retried `work`, output verification, and post-failure `#rollback` when defined — `after_execution` remains after the stack
|
|
15
60
|
|
|
16
61
|
## [2.0.0] - 2026-05-05
|
|
17
62
|
|
|
@@ -28,14 +73,14 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace
|
|
|
28
73
|
- Add `CMDx::Signal` halt token thrown via `catch(Signal::TAG)` (`:cmdx_signal`)
|
|
29
74
|
- Add `Signal#ok?` / `Signal#ko?` predicates
|
|
30
75
|
- Add `CMDx::Runtime` orchestrating the full task lifecycle and building the final `Result`
|
|
31
|
-
- Add `CMDx::Telemetry` pub/sub for `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`; emits `Telemetry::Event` data objects with `cid`, `root`, `type`, `task`, `tid`, `name`, `payload`, `timestamp`
|
|
76
|
+
- Add `CMDx::Telemetry` pub/sub for `:task_started`, `:task_deprecated`, `:task_retried`, `:task_rolled_back`, `:task_executed`; emits `Telemetry::Event` data objects with `xid`, `cid`, `root`, `type`, `task`, `tid`, `name`, `payload`, `timestamp`
|
|
32
77
|
- Add `CMDx::Deprecation` for declarative class-level deprecation (`:log`, `:warn`, `:error`, Symbol, Proc, callable) with `:if` / `:unless` gating
|
|
33
78
|
- Add `CMDx::Input` / `CMDx::Inputs` (replaces `Attribute` / `AttributeRegistry` / `AttributeValue`) supporting `:source`, `:default`, `:transform`, `:as`, `:prefix` / `:suffix`, and nested children via DSL block
|
|
34
79
|
- Add `CMDx::Output` / `CMDx::Outputs` for first-class declared outputs verified against `task.context` after `work`. Every declared output is implicitly required — there is no `:required` option. Surface is intentionally minimal: `:default` (literal/Symbol/Proc/`#call(task)`-able fallback applied when the value is `nil`, satisfies the implicit required check), `:if` / `:unless` guards, and `:description`. Coercion, transformation, validation, and nested children are intentionally not supported on outputs — use inputs (or compute in `work`) when you need any of those
|
|
35
80
|
- Add `CMDx::Util` single conditional-evaluation module (`evaluate`, `if?`, `unless?`, `satisfied?`) consolidating the v1 `Utils::*` modules
|
|
36
81
|
- Add `CMDx::I18nProxy` translation façade that delegates to `I18n` when available, otherwise loads the bundled YAML and percent-interpolates with memoization
|
|
37
82
|
- Add `CMDx::LoggerProxy` returning a per-task logger, `dup`-ing the base only when the task overrides `log_level` or `log_formatter`
|
|
38
|
-
- Add `around_execution` callback
|
|
83
|
+
- Add `around_execution` callback nesting `before_validation`, input resolution, retried `Task#work`, output verification, `after_execution`, and post-failure `#rollback` (when defined). Symbol callbacks receive the continuation as their block (use `yield`); Procs/blocks/callables receive `(task, continuation)` and must invoke `continuation.call`. Hooks nest in declaration order; skipping the continuation raises `CMDx::CallbackError`. Lives inside middlewares but outside the `on_*` state/status callbacks — suited to transactions, instrumentation, and logging scope without extra `middlewares.register` entries (ordering of `before_validation` / `after_execution` vs this hook was refined in 2.0.1)
|
|
39
84
|
- Add new exception classes: `DefinitionError`, `DeprecationError`, `ImplementationError`, `MiddlewareError`, `CallbackError`
|
|
40
85
|
- Add `Task#work` abstract method (raises `ImplementationError` when not defined)
|
|
41
86
|
- Add `Task#rollback` lifecycle hook, auto-invoked by Runtime on failed results when defined; surfaced via `Result#rolled_back?` and the `:task_rolled_back` event
|
|
@@ -81,11 +126,11 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace
|
|
|
81
126
|
- Generated input accessors are now plain instance methods backed by `@_input_<name>` ivars set during input resolution; outputs have no accessors and are read/written directly on `task.context`
|
|
82
127
|
- `Workflow` declares groups via `task` / `tasks` (still aliased) and supports `:strategy => :parallel`, `:pool_size`, `:continue_on_failure`, `:if` / `:unless` per group; defining `#work` on a workflow raises `ImplementationError`
|
|
83
128
|
- `Pipeline` gains a `:parallel` strategy with `:pool_size` (replacing the removed `Parallelizer`); parallel workers share the parent fiber's chain, each get a `deep_dup`-ed context, successful child contexts are merged back into the workflow's context, and the first failed result is echoed via `throw!` to halt the pipeline. By default pending tasks are cancelled on the first failure (in-flight tasks still finish and successful contexts still merge); opt into batch semantics with `:continue_on_failure => true` to run every task to completion and aggregate failures into the workflow's `errors` (keys namespaced as `"TaskClass.input"` for input/validation errors and `"TaskClass.<status>"` for bare `fail!` reasons). `:continue_on_failure` works for both `:sequential` and `:parallel` groups.
|
|
84
|
-
- `Task.callbacks`, `Task.middlewares`, `Task.coercions`, `Task.validators`, `Task.executors`, `Task.mergers`, `Task.telemetry`, `Task.inputs`, `Task.outputs` lazy-clone from the superclass (or global `Configuration`) on first access — subclasses extend rather than replace
|
|
85
|
-
- `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `log_exclusions`, `backtrace_cleaner`, `tags`, `strict_context`; every getter falls back to `CMDx.configuration`
|
|
129
|
+
- `Task.callbacks`, `Task.middlewares`, `Task.coercions`, `Task.validators`, `Task.executors`, `Task.mergers`, `Task.retriers`, `Task.deprecators`, `Task.telemetry`, `Task.inputs`, `Task.outputs` lazy-clone from the superclass (or global `Configuration`) on first access — subclasses extend rather than replace
|
|
130
|
+
- `Settings` is now a frozen value object holding only `logger`, `log_formatter`, `log_level`, `log_exclusions`, `backtrace_cleaner`, `tags`, `strict_context`, `correlation_id`; every getter falls back to `CMDx.configuration`
|
|
86
131
|
- `Context.build` accepts anything that responds to `#context` (e.g. another `Task`), unwraps repeatedly, and only re-wraps frozen contexts; symbolizes hash keys via `#to_hash` / `#to_h`
|
|
87
132
|
- `Retry` becomes a value object; `Task.retry_on` accumulates exceptions and options across the inheritance chain via `Retry#build`; supports built-in jitter strategies (`:exponential`, `:half_random`, `:full_random`, `:bounded_random`) plus Symbol / Proc / callable; retry wraps `work` only (input resolution and output verification run once, outside the retry loop)
|
|
88
|
-
- All registries (`Callbacks`, `Middlewares`, `Coercions`, `Validators`, `Telemetry`, `Inputs`, `Outputs`) implement `initialize_copy` for cheap copy-on-write inheritance; `register` / `deregister` validate types up-front and raise `ArgumentError` on misuse
|
|
133
|
+
- All registries (`Callbacks`, `Middlewares`, `Coercions`, `Validators`, `Executors`, `Mergers`, `Retriers`, `Deprecators`, `Telemetry`, `Inputs`, `Outputs`) implement `initialize_copy` for cheap copy-on-write inheritance; `register` / `deregister` validate types up-front and raise `ArgumentError` on misuse
|
|
89
134
|
- `Coercions#coerce` returns a `Coercions::Failure` sentinel with an i18n message recorded on `task.errors`; when multiple declared coercion rules match none (and none were inline), an aggregated `cmdx.coercions.into_any` message is reported instead of the per-rule messages
|
|
90
135
|
- `Validators#validate` records a message on `task.errors` for each failed rule (the individual built-in validators return `Validators::Failure`)
|
|
91
136
|
- Extend `Validators::Numeric` and `Validators::Length` with `:gt` / `:lt` (strict comparison, with `:gt_message` / `:lt_message` overrides and `cmdx.validators.{numeric,length}.{gt,lt}` i18n keys), plus `:gte` / `:lte` / `:eq` / `:not_eq` aliases that normalize to `:min` / `:max` / `:is` / `:is_not`
|
|
@@ -94,10 +139,10 @@ Full runtime rewrite: the v1 state-machine plus Zeitwerk architecture is replace
|
|
|
94
139
|
- `Result#to_h` / `to_s` / `deconstruct_keys` now include `:origin` (compact `{ task:, tid: }` hash, or `nil` for locally originated failures)
|
|
95
140
|
- **BREAKING**: `Result#deconstruct` now returns `#to_h.to_a` (array of `[key, value]` pairs) instead of the fixed `[type, task, state, status, reason, metadata, cause, origin]` tuple — update any array-pattern matches to use find-patterns (`in [*, [:status, "failed"], *]`)
|
|
96
141
|
- `Result#deconstruct_keys` now honors its `keys` argument — `nil` returns the full `#to_h`, a key list slices it; previously it always returned the full hash
|
|
97
|
-
- `Middlewares` registry entries are now `[callable, options.freeze]` tuples — callers that read `Task.middlewares.registry` directly
|
|
142
|
+
- `Middlewares` registry entries are now `[callable, options.freeze]` tuples — callers that read `Task.middlewares.registry` directly should take `.first` for the callable (per-entry options live in `.last`)
|
|
98
143
|
- Slim the locale file: remove `attributes.undefined`, `coercions.unknown`, `faults.invalid`, `faults.unspecified`, `returns.*`; rename `returns.missing` → `outputs.missing`; add `nil_value` to `length` / `numeric` validator messages
|
|
99
144
|
- Generators emit the new `def work` template; the install template documents the new middleware / callback / telemetry / coercion / validator registration shapes
|
|
100
|
-
- Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `telemetry`, `default_locale`, `strict_context`, `backtrace_cleaner`, `logger`, `log_level`, `log_formatter`
|
|
145
|
+
- Slim `Configuration` to: `middlewares`, `callbacks`, `coercions`, `validators`, `executors`, `mergers`, `retriers`, `deprecators`, `telemetry`, `correlation_id`, `default_locale`, `strict_context`, `backtrace_cleaner`, `log_exclusions`, `logger`, `log_level`, `log_formatter`
|
|
101
146
|
- `Configuration#log_level` and `Configuration#log_formatter` now default to `nil` — treat them as optional overrides on top of `config.logger` (the default `Logger` still carries `Logger::INFO` + `LogFormatters::Line.new`). `LoggerProxy` only `dup`s the logger when a non-nil override differs from the logger's own level/formatter, so swapping `config.logger` no longer requires also clearing these fields
|
|
102
147
|
|
|
103
148
|
### Removed
|
data/lib/cmdx/callbacks.rb
CHANGED
|
@@ -53,11 +53,17 @@ module CMDx
|
|
|
53
53
|
callback = callable || block
|
|
54
54
|
|
|
55
55
|
if callable && block
|
|
56
|
-
raise ArgumentError, "provide either a callable or a block, not both"
|
|
56
|
+
raise ArgumentError, "callback: provide either a callable or a block, not both"
|
|
57
57
|
elsif !callback.is_a?(Symbol) && !callback.respond_to?(:call)
|
|
58
|
-
raise ArgumentError,
|
|
58
|
+
raise ArgumentError, <<~MSG.chomp
|
|
59
|
+
callback must be a Symbol or respond to #call (got #{callback.class}).
|
|
60
|
+
See https://drexed.github.io/cmdx/callbacks/#how-do-i-register-one
|
|
61
|
+
MSG
|
|
59
62
|
elsif !EVENTS.include?(event)
|
|
60
|
-
raise ArgumentError,
|
|
63
|
+
raise ArgumentError, <<~MSG.chomp
|
|
64
|
+
unknown callback event #{event.inspect}, must be one of #{EVENTS.to_a.inspect}.
|
|
65
|
+
See https://drexed.github.io/cmdx/callbacks/#what-callbacks-exist
|
|
66
|
+
MSG
|
|
61
67
|
end
|
|
62
68
|
|
|
63
69
|
(registry[event] ||= []) << [callback, options.freeze]
|
|
@@ -75,7 +81,12 @@ module CMDx
|
|
|
75
81
|
# @return [Callbacks] self for chaining
|
|
76
82
|
# @raise [ArgumentError] when `event` is unknown
|
|
77
83
|
def deregister(event, callable = nil)
|
|
78
|
-
|
|
84
|
+
unless EVENTS.include?(event)
|
|
85
|
+
raise ArgumentError, <<~MSG.chomp
|
|
86
|
+
unknown callback event #{event.inspect}, must be one of #{EVENTS.to_a.inspect}.
|
|
87
|
+
See https://drexed.github.io/cmdx/callbacks/#what-callbacks-exist
|
|
88
|
+
MSG
|
|
89
|
+
end
|
|
79
90
|
|
|
80
91
|
if callable.nil?
|
|
81
92
|
registry.delete(event)
|
|
@@ -87,6 +98,12 @@ module CMDx
|
|
|
87
98
|
self
|
|
88
99
|
end
|
|
89
100
|
|
|
101
|
+
# @param name [Symbol]
|
|
102
|
+
# @return [Boolean] whether a callback is registered under `name`
|
|
103
|
+
def key?(event)
|
|
104
|
+
registry.key?(event)
|
|
105
|
+
end
|
|
106
|
+
|
|
90
107
|
# @return [Boolean]
|
|
91
108
|
def empty?
|
|
92
109
|
registry.empty?
|
|
@@ -110,6 +127,8 @@ module CMDx
|
|
|
110
127
|
# @return [void]
|
|
111
128
|
# @raise [ArgumentError] when a callback is neither a Symbol nor responds to `#call`
|
|
112
129
|
def process(event, task)
|
|
130
|
+
return if empty?
|
|
131
|
+
|
|
113
132
|
callbacks = registry[event]
|
|
114
133
|
return if callbacks.nil? || callbacks.empty?
|
|
115
134
|
|
|
@@ -150,18 +169,16 @@ module CMDx
|
|
|
150
169
|
|
|
151
170
|
invoke(callable, task, cont, &cont)
|
|
152
171
|
|
|
153
|
-
called || raise(CallbackError,
|
|
172
|
+
called || raise(CallbackError, <<~MSG.chomp)
|
|
173
|
+
#{event} callback did not invoke its continuation.
|
|
174
|
+
See https://drexed.github.io/cmdx/callbacks/#around_execution-the-wrap-the-whole-thing-hook
|
|
175
|
+
MSG
|
|
154
176
|
end
|
|
155
177
|
end.call
|
|
156
178
|
end
|
|
157
179
|
|
|
158
180
|
private
|
|
159
181
|
|
|
160
|
-
# @param callable [Symbol, Proc, #call]
|
|
161
|
-
# @param task [Task]
|
|
162
|
-
# @param extras [Array<Object>] extra args after `task` for continuation-style callbacks
|
|
163
|
-
# @return [Object] the callback's return value
|
|
164
|
-
# @raise [ArgumentError] when `callable` is invalid
|
|
165
182
|
def invoke(callable, task, *extras, &)
|
|
166
183
|
case callable
|
|
167
184
|
when Symbol
|
|
@@ -171,7 +188,10 @@ module CMDx
|
|
|
171
188
|
else
|
|
172
189
|
return callable.call(task, *extras) if callable.respond_to?(:call)
|
|
173
190
|
|
|
174
|
-
raise ArgumentError,
|
|
191
|
+
raise ArgumentError, <<~MSG.chomp
|
|
192
|
+
callback must be a Symbol, Proc, or respond to #call (got #{callable.class}).
|
|
193
|
+
See https://drexed.github.io/cmdx/callbacks/#how-do-i-register-one
|
|
194
|
+
MSG
|
|
175
195
|
end
|
|
176
196
|
end
|
|
177
197
|
|
data/lib/cmdx/chain.rb
CHANGED
|
@@ -34,16 +34,27 @@ module CMDx
|
|
|
34
34
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
attr_reader :xid, :id
|
|
37
|
+
attr_reader :xid, :id
|
|
38
38
|
|
|
39
39
|
# @param xid [String, nil] external correlation id (e.g. Rails `request_id`)
|
|
40
40
|
# shared across every {Result} in this chain. Resolved once by Runtime
|
|
41
|
-
# from {
|
|
41
|
+
# from {Settings#correlation_id} (a callable) when the root chain is
|
|
42
|
+
# created.
|
|
42
43
|
def initialize(xid = nil)
|
|
43
44
|
@xid = xid
|
|
44
45
|
@id = SecureRandom.uuid_v7
|
|
45
46
|
@mutex = Mutex.new
|
|
46
47
|
@results = []
|
|
48
|
+
@root = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Array<Result>] snapshot of the results stored in this chain.
|
|
52
|
+
# While the chain is mutable a dup is returned so callers cannot mutate
|
|
53
|
+
# internal state and see consistent ordering despite parallel pushes;
|
|
54
|
+
# after {#freeze} the actual frozen array is returned to preserve
|
|
55
|
+
# `Array#frozen?` semantics.
|
|
56
|
+
def results
|
|
57
|
+
@mutex.synchronize { @results.frozen? ? @results : @results.dup }
|
|
47
58
|
end
|
|
48
59
|
|
|
49
60
|
# Appends `result` to the chain. Thread-safe to support parallel pipelines.
|
|
@@ -51,7 +62,10 @@ module CMDx
|
|
|
51
62
|
# @param result [Result]
|
|
52
63
|
# @return [Chain] self for chaining
|
|
53
64
|
def push(result)
|
|
54
|
-
@mutex.synchronize
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@results << result
|
|
67
|
+
@root = result if @root.nil? && result.respond_to?(:root?) && result.root?
|
|
68
|
+
end
|
|
55
69
|
self
|
|
56
70
|
end
|
|
57
71
|
alias << push
|
|
@@ -61,24 +75,29 @@ module CMDx
|
|
|
61
75
|
# @param result [Result]
|
|
62
76
|
# @return [Chain] self for chaining
|
|
63
77
|
def unshift(result)
|
|
64
|
-
@mutex.synchronize
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
@results.unshift(result)
|
|
80
|
+
@root = result if result.respond_to?(:root?) && result.root?
|
|
81
|
+
end
|
|
65
82
|
self
|
|
66
83
|
end
|
|
67
84
|
|
|
68
85
|
# @param result [Result]
|
|
69
86
|
# @return [Integer, nil] zero-based position of `result`, or nil when absent
|
|
70
87
|
def index(result)
|
|
71
|
-
@results.index(result)
|
|
88
|
+
@mutex.synchronize { @results.index(result) }
|
|
72
89
|
end
|
|
73
90
|
|
|
74
91
|
# @return [Result, nil] the most recently appended result
|
|
75
92
|
def last
|
|
76
|
-
@results.last
|
|
93
|
+
@mutex.synchronize { @results.last }
|
|
77
94
|
end
|
|
78
95
|
|
|
79
96
|
# @return [Result, nil] the root result, or nil when absent
|
|
80
97
|
def root
|
|
81
|
-
@
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@root || @results.find { |r| r.respond_to?(:root?) && r.root? }
|
|
100
|
+
end
|
|
82
101
|
end
|
|
83
102
|
|
|
84
103
|
# @return [String, nil] the state of the root result, or nil when absent
|
|
@@ -93,18 +112,18 @@ module CMDx
|
|
|
93
112
|
|
|
94
113
|
# @return [Boolean]
|
|
95
114
|
def empty?
|
|
96
|
-
@results.empty?
|
|
115
|
+
@mutex.synchronize { @results.empty? }
|
|
97
116
|
end
|
|
98
117
|
|
|
99
118
|
# @return [Integer]
|
|
100
119
|
def size
|
|
101
|
-
@results.size
|
|
120
|
+
@mutex.synchronize { @results.size }
|
|
102
121
|
end
|
|
103
122
|
|
|
104
123
|
# @yield [Result] each result in insertion order
|
|
105
124
|
# @return [Enumerator, Chain]
|
|
106
125
|
def each(&)
|
|
107
|
-
|
|
126
|
+
results.each(&)
|
|
108
127
|
end
|
|
109
128
|
|
|
110
129
|
# Freezes the chain and its results. Called by Runtime teardown.
|
|
@@ -13,7 +13,7 @@ module CMDx
|
|
|
13
13
|
# @option options [Integer] :precision (14)
|
|
14
14
|
# @return [BigDecimal, Coercions::Failure]
|
|
15
15
|
def call(value, options = EMPTY_HASH)
|
|
16
|
-
return value if value.is_a?(BigDecimal)
|
|
16
|
+
return value if value.is_a?(::BigDecimal)
|
|
17
17
|
|
|
18
18
|
BigDecimal(value, options[:precision] || 14)
|
|
19
19
|
rescue ArgumentError, TypeError
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module CMDx
|
|
4
4
|
class Coercions
|
|
5
5
|
# Coerces to Boolean by matching the string form against the {TRUTHY}
|
|
6
|
-
# and {FALSEY} sets (case- and whitespace-insensitive).
|
|
7
|
-
#
|
|
6
|
+
# and {FALSEY} sets (case- and whitespace-insensitive). `nil` becomes
|
|
7
|
+
# `false`; anything else unrecognized fails.
|
|
8
8
|
module Boolean
|
|
9
9
|
|
|
10
10
|
extend self
|
|
@@ -17,18 +17,12 @@ module CMDx
|
|
|
17
17
|
# @option options [Object] reserved for future per-coercion configuration (currently ignored)
|
|
18
18
|
# @return [Boolean, Coercions::Failure]
|
|
19
19
|
def call(value, options = EMPTY_HASH)
|
|
20
|
-
return
|
|
20
|
+
return false if value.nil?
|
|
21
21
|
|
|
22
22
|
str = value.to_s.strip.downcase
|
|
23
23
|
return true if TRUTHY.include?(str)
|
|
24
24
|
return false if FALSEY.include?(str)
|
|
25
25
|
|
|
26
|
-
coercion_failure
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
def coercion_failure
|
|
32
26
|
type = I18nProxy.t("cmdx.types.boolean")
|
|
33
27
|
Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:))
|
|
34
28
|
end
|
|
@@ -23,7 +23,10 @@ module CMDx
|
|
|
23
23
|
else
|
|
24
24
|
return handler.call(value, task) if handler.respond_to?(:call)
|
|
25
25
|
|
|
26
|
-
raise ArgumentError,
|
|
26
|
+
raise ArgumentError, <<~MSG.chomp
|
|
27
|
+
coerce handler must be a Symbol, Proc, or respond to #call (got #{handler.class}).
|
|
28
|
+
See https://drexed.github.io/cmdx/inputs/coercions/#inline-coerce-callable
|
|
29
|
+
MSG
|
|
27
30
|
end
|
|
28
31
|
end
|
|
29
32
|
|
|
@@ -3,16 +3,25 @@
|
|
|
3
3
|
module CMDx
|
|
4
4
|
class Coercions
|
|
5
5
|
# Coerces to Integer via `Kernel#Integer` (strict; rejects floats-as-strings).
|
|
6
|
+
# Pass `base:` to parse strings written in non-decimal radix
|
|
7
|
+
# (e.g. `"0x10"` with `base: 16`). The `:base` option is applied only
|
|
8
|
+
# when `value` is a String — for numeric inputs `Kernel#Integer` is
|
|
9
|
+
# called without a base, matching its native contract.
|
|
6
10
|
module Integer
|
|
7
11
|
|
|
8
12
|
extend self
|
|
9
13
|
|
|
10
14
|
# @param value [Object]
|
|
11
15
|
# @param options [Hash{Symbol => Object}]
|
|
12
|
-
# @option options [
|
|
16
|
+
# @option options [Integer] :base radix for string parsing (default 10)
|
|
13
17
|
# @return [Integer, Coercions::Failure]
|
|
14
18
|
def call(value, options = EMPTY_HASH)
|
|
15
|
-
|
|
19
|
+
base = options[:base]
|
|
20
|
+
if base && value.is_a?(::String)
|
|
21
|
+
Integer(value, base)
|
|
22
|
+
else
|
|
23
|
+
Integer(value)
|
|
24
|
+
end
|
|
16
25
|
rescue ArgumentError, FloatDomainError, RangeError, TypeError
|
|
17
26
|
type = I18nProxy.t("cmdx.types.integer")
|
|
18
27
|
Failure.new(I18nProxy.t("cmdx.coercions.into_an", type:))
|
|
@@ -2,21 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
module CMDx
|
|
4
4
|
class Coercions
|
|
5
|
-
# Coerces to Symbol via `#to_s.to_sym`. Fails
|
|
6
|
-
# `#to_s` (i.e. `BasicObject` instances)
|
|
5
|
+
# Coerces to Symbol via `#to_s.to_sym`. Fails when `value` has no
|
|
6
|
+
# `#to_s` (i.e. `BasicObject` instances) or when the resulting string
|
|
7
|
+
# exceeds {MAX_LENGTH} characters.
|
|
8
|
+
#
|
|
9
|
+
# Symbols are never garbage-collected when interned from arbitrary
|
|
10
|
+
# strings, so unbounded coercion of attacker-controlled input would
|
|
11
|
+
# grow the symbol table unbounded (memory DoS). The default cap is
|
|
12
|
+
# generous for legitimate identifiers; pass `max_length:` to tighten
|
|
13
|
+
# it for hot paths or untrusted boundaries.
|
|
7
14
|
module Symbol
|
|
8
15
|
|
|
9
16
|
extend self
|
|
10
17
|
|
|
18
|
+
MAX_LENGTH = 256
|
|
19
|
+
|
|
11
20
|
# @param value [Object]
|
|
12
21
|
# @param options [Hash{Symbol => Object}]
|
|
13
|
-
# @option options [
|
|
22
|
+
# @option options [Integer] :max_length (256) reject strings longer than this
|
|
14
23
|
# @return [Symbol, Coercions::Failure]
|
|
15
24
|
def call(value, options = EMPTY_HASH)
|
|
16
25
|
return value if value.is_a?(::Symbol)
|
|
17
26
|
|
|
18
|
-
value.to_s
|
|
27
|
+
str = value.to_s
|
|
28
|
+
limit = options[:max_length] || MAX_LENGTH
|
|
29
|
+
return coercion_failure if str.length > limit
|
|
30
|
+
|
|
31
|
+
str.to_sym
|
|
19
32
|
rescue NoMethodError
|
|
33
|
+
coercion_failure
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def coercion_failure
|
|
20
39
|
type = I18nProxy.t("cmdx.types.symbol")
|
|
21
40
|
Failure.new(I18nProxy.t("cmdx.coercions.into_a", type:))
|
|
22
41
|
end
|
data/lib/cmdx/coercions.rb
CHANGED
|
@@ -52,9 +52,12 @@ module CMDx
|
|
|
52
52
|
coercion = callable || block
|
|
53
53
|
|
|
54
54
|
if callable && block
|
|
55
|
-
raise ArgumentError, "provide either a callable or a block, not both"
|
|
55
|
+
raise ArgumentError, "coercion: provide either a callable or a block, not both"
|
|
56
56
|
elsif !coercion.respond_to?(:call)
|
|
57
|
-
raise ArgumentError,
|
|
57
|
+
raise ArgumentError, <<~MSG.chomp
|
|
58
|
+
coercion must respond to #call (got #{coercion.class}).
|
|
59
|
+
See https://drexed.github.io/cmdx/inputs/coercions/#declarations
|
|
60
|
+
MSG
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
registry[name.to_sym] = coercion
|
|
@@ -64,16 +67,25 @@ module CMDx
|
|
|
64
67
|
# @param name [Symbol]
|
|
65
68
|
# @return [Coercions] self for chaining
|
|
66
69
|
def deregister(name)
|
|
67
|
-
registry.delete(name
|
|
70
|
+
registry.delete(name)
|
|
68
71
|
self
|
|
69
72
|
end
|
|
70
73
|
|
|
74
|
+
# @param name [Symbol]
|
|
75
|
+
# @return [Boolean] whether a coercion is registered under `name`
|
|
76
|
+
def key?(name)
|
|
77
|
+
registry.key?(name)
|
|
78
|
+
end
|
|
79
|
+
|
|
71
80
|
# @param name [Symbol]
|
|
72
81
|
# @return [#call] the registered coercion
|
|
73
|
-
# @raise [
|
|
82
|
+
# @raise [UnknownEntryError] when `name` isn't registered
|
|
74
83
|
def lookup(name)
|
|
75
84
|
registry[name] || begin
|
|
76
|
-
raise
|
|
85
|
+
raise UnknownEntryError, <<~MSG.chomp
|
|
86
|
+
unknown coercion #{name.inspect}; registered: #{registry.keys.inspect}.
|
|
87
|
+
See https://drexed.github.io/cmdx/inputs/coercions/#built-in-coercions
|
|
88
|
+
MSG
|
|
77
89
|
end
|
|
78
90
|
end
|
|
79
91
|
|
|
@@ -101,7 +113,10 @@ module CMDx
|
|
|
101
113
|
else
|
|
102
114
|
return [[raw, EMPTY_HASH]] if raw.respond_to?(:call)
|
|
103
115
|
|
|
104
|
-
raise ArgumentError,
|
|
116
|
+
raise ArgumentError, <<~MSG.chomp
|
|
117
|
+
unsupported :coerce format #{raw.inspect}; expected Symbol, Array, Hash, or a callable.
|
|
118
|
+
See https://drexed.github.io/cmdx/inputs/coercions/#declarations
|
|
119
|
+
MSG
|
|
105
120
|
end
|
|
106
121
|
end
|
|
107
122
|
|
|
@@ -156,9 +171,6 @@ module CMDx
|
|
|
156
171
|
|
|
157
172
|
private
|
|
158
173
|
|
|
159
|
-
# @param entry [Object] Array entry from a `:coerce` list
|
|
160
|
-
# @return [Array(Object, Hash)] handler + options pair
|
|
161
|
-
# @raise [ArgumentError] when `entry` is unsupported
|
|
162
174
|
def normalize_entry(entry)
|
|
163
175
|
case entry
|
|
164
176
|
when ::Symbol, ::Proc
|
|
@@ -166,7 +178,10 @@ module CMDx
|
|
|
166
178
|
else
|
|
167
179
|
return [entry, EMPTY_HASH] if entry.respond_to?(:call)
|
|
168
180
|
|
|
169
|
-
raise ArgumentError,
|
|
181
|
+
raise ArgumentError, <<~MSG.chomp
|
|
182
|
+
unsupported coerce entry #{entry.inspect}; expected Symbol, Proc, or a callable.
|
|
183
|
+
See https://drexed.github.io/cmdx/inputs/coercions/#inline-coerce-callable
|
|
184
|
+
MSG
|
|
170
185
|
end
|
|
171
186
|
end
|
|
172
187
|
|
data/lib/cmdx/configuration.rb
CHANGED
|
@@ -51,38 +51,53 @@ module CMDx
|
|
|
51
51
|
|
|
52
52
|
@configuration ||= Configuration.new
|
|
53
53
|
end
|
|
54
|
+
alias config configuration
|
|
54
55
|
|
|
55
56
|
# Yields the global configuration for mutation.
|
|
56
57
|
#
|
|
57
58
|
# @yield [Configuration]
|
|
58
59
|
# @return [Configuration]
|
|
59
60
|
# @raise [ArgumentError] when no block is given
|
|
60
|
-
def configure
|
|
61
|
-
raise ArgumentError, "block
|
|
61
|
+
def configure(&)
|
|
62
|
+
raise ArgumentError, "CMDx.configure requires a block" unless block_given?
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
yield(config)
|
|
65
|
-
config
|
|
64
|
+
configuration.tap(&)
|
|
66
65
|
end
|
|
67
66
|
|
|
68
67
|
# Replaces the global configuration with a fresh instance and invalidates
|
|
69
|
-
# the cached registries on
|
|
70
|
-
#
|
|
68
|
+
# the cached registries on {Task} and every Task subclass currently in the
|
|
69
|
+
# object space, so new lookups rebuild from the new config. Intended for
|
|
70
|
+
# test setup/teardown.
|
|
71
|
+
#
|
|
72
|
+
# Walks `ObjectSpace.each_object(Class)` once — acceptable for tests but
|
|
73
|
+
# avoid calling it on a hot path.
|
|
71
74
|
#
|
|
72
75
|
# @return [void]
|
|
73
76
|
def reset_configuration!
|
|
74
77
|
@configuration = Configuration.new
|
|
75
78
|
return unless defined?(Task)
|
|
76
79
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
ivars = %i[
|
|
81
|
+
@middlewares
|
|
82
|
+
@callbacks
|
|
83
|
+
@coercions
|
|
84
|
+
@validators
|
|
85
|
+
@executors
|
|
86
|
+
@mergers
|
|
87
|
+
@retriers
|
|
88
|
+
@deprecators
|
|
89
|
+
@telemetry
|
|
90
|
+
].freeze
|
|
91
|
+
|
|
92
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
93
|
+
next unless klass <= Task
|
|
94
|
+
|
|
95
|
+
ivars.each do |iv|
|
|
96
|
+
next unless klass.instance_variable_defined?(iv)
|
|
97
|
+
|
|
98
|
+
klass.instance_variable_set(iv, nil)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
86
101
|
end
|
|
87
102
|
|
|
88
103
|
end
|