actionable-rb 1.9.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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/.claude-plugin/marketplace.json +15 -0
  3. data/.claude-plugin/plugin.json +9 -0
  4. data/.rspec +3 -0
  5. data/AGENTS.md +150 -0
  6. data/CHANGELOG.md +766 -0
  7. data/CODE_OF_CONDUCT.md +132 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +555 -0
  10. data/UPGRADING.md +957 -0
  11. data/USAGE.md +1291 -0
  12. data/docs/getting_started.md +611 -0
  13. data/lib/actionable/action.rb +1220 -0
  14. data/lib/actionable/batch_result.rb +92 -0
  15. data/lib/actionable/errors.rb +36 -0
  16. data/lib/actionable/history.rb +91 -0
  17. data/lib/actionable/input_dispatch.rb +94 -0
  18. data/lib/actionable/job.rb +157 -0
  19. data/lib/actionable/measurement.rb +64 -0
  20. data/lib/actionable/rails/after_commit.rb +142 -0
  21. data/lib/actionable/rails/proxy_validator.rb +59 -0
  22. data/lib/actionable/rails/transactions.rb +95 -0
  23. data/lib/actionable/rails.rb +24 -0
  24. data/lib/actionable/railtie.rb +10 -0
  25. data/lib/actionable/rbs.rb +502 -0
  26. data/lib/actionable/registry.rb +64 -0
  27. data/lib/actionable/result.rb +166 -0
  28. data/lib/actionable/results/failure.rb +43 -0
  29. data/lib/actionable/results/skipped.rb +30 -0
  30. data/lib/actionable/results/success.rb +14 -0
  31. data/lib/actionable/rspec/coverage/report.rb +264 -0
  32. data/lib/actionable/rspec/coverage/tracker.rb +330 -0
  33. data/lib/actionable/rspec/coverage.rb +45 -0
  34. data/lib/actionable/rspec/matchers.rb +266 -0
  35. data/lib/actionable/rspec/stubs.rb +250 -0
  36. data/lib/actionable/rspec.rb +40 -0
  37. data/lib/actionable/rubocop/config.yml +8 -0
  38. data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
  39. data/lib/actionable/rubocop/plugin.rb +31 -0
  40. data/lib/actionable/rubocop.rb +25 -0
  41. data/lib/actionable/runner.rb +300 -0
  42. data/lib/actionable/steps/action.rb +76 -0
  43. data/lib/actionable/steps/base.rb +107 -0
  44. data/lib/actionable/steps/case.rb +124 -0
  45. data/lib/actionable/steps/method.rb +39 -0
  46. data/lib/actionable/steps.rb +27 -0
  47. data/lib/actionable/value_match.rb +22 -0
  48. data/lib/actionable/verb_position.rb +574 -0
  49. data/lib/actionable/version.rb +6 -0
  50. data/lib/actionable-rb.rb +6 -0
  51. data/lib/actionable.rb +59 -0
  52. data/sig/actionable/dsl.rbs +42 -0
  53. data/sig/actionable/rails.rbs +16 -0
  54. data/sig/actionable.rbs +2593 -0
  55. data/skills/actionable/SKILL.md +214 -0
  56. metadata +132 -0
@@ -0,0 +1,214 @@
1
+ ---
2
+ name: actionable
3
+ description: Author, edit, and debug Actionable service objects — declaring steps, choosing control-flow verbs (succeed/fail/fail_with/skip and their bang forms), wiring typed input/output schemas (including discriminated input via input_for), running over a collection with run_each, composing nested actions and case branches, lifecycle hooks, history/sampled measurement, serializing results (to_h/to_api_h), and the optional Rails/RSpec/job adapters.
4
+ when_to_use: Use when defining or modifying a class that inherits from Actionable::Action (any step / case_step / on_success / output / input / input_for declaration), when choosing how a step should record its outcome, when running an action over a collection, when debugging why a run failed/skipped or an output wasn't captured, when mapping a result to an HTTP/job response, when testing an action with the perform_actionable matcher, or when upgrading the actionable gem to a newer version.
5
+ ---
6
+
7
+ # Working with Actionable
8
+
9
+ Actionable builds **service objects**: declare an ordered list of **steps** on an
10
+ `Actionable::Action` subclass, implement each as a method, and run it. A run
11
+ returns one value object — `Success`, `Failure`, or `Skipped` — with `code`,
12
+ `message`, `errors`, a typed `output`, and a `history`.
13
+
14
+ ## Read the bundled reference first
15
+
16
+ The gem ships a dense, example-first reference. Read it before answering a
17
+ non-trivial question — it matches the installed version:
18
+
19
+ - Run `bundle show actionable` and open `USAGE.md` at that path, or
20
+ - https://github.com/Paymentbox-com/actionable/blob/main/USAGE.md
21
+
22
+ This skill is the **workflow and the mistakes to avoid**; `USAGE.md` is the catalog.
23
+
24
+ ## Defining an action
25
+
26
+ ```ruby
27
+ class CreateInvoice < Actionable::Action
28
+ input { required :amount, :integer } # typed .run kwargs (optional block)
29
+ output { required :invoice, :string } # declared, typed result payload
30
+
31
+ step :validate
32
+ step :build
33
+
34
+ def validate
35
+ fail!(:invalid_amount) unless input.amount.positive?
36
+ end
37
+
38
+ def build
39
+ @invoice = "INV-#{input.amount}" # ivar matching an output field is captured
40
+ end
41
+ end
42
+
43
+ result = CreateInvoice.run(amount: 10)
44
+ result.success? # => true
45
+ result.invoice # => "INV-10" (delegates to result.output.invoice)
46
+ ```
47
+
48
+ When asked to build one:
49
+
50
+ 1. Declare `input`/`output` schemas if the action has a typed contract; reach for
51
+ `input.field` rather than a custom `initialize`.
52
+ 2. Add `step :name` in order; implement each as a method.
53
+ 3. Inside steps, record the outcome with a control-flow verb (below). Setting an
54
+ ivar named like an `output` field is enough to surface it — no explicit expose.
55
+ 4. Run `bin/rspec` / `bundle exec rake` and test with `perform_actionable`.
56
+
57
+ ## Choosing a control-flow verb
58
+
59
+ | Want to… | Use |
60
+ |----------|-----|
61
+ | Succeed and keep running | `succeed(message = nil, **output)` |
62
+ | Record an error, keep running | `fail(code, message = nil, **errors)` |
63
+ | Fail with a FieldStruct's errors | `fail_with(struct, code: :invalid, message: nil)` |
64
+ | Mark "nothing to do" (with optional output) | `skip(code = :skipped, message = nil, **output)` |
65
+ | Same, but stop the pipeline now | `succeed!` / `fail!` / `fail_with!` / `skip!` |
66
+ | Stop, keep whatever was recorded | `halt!` |
67
+
68
+ - **Skip is not failure.** Use `skip!` when a precondition isn't ready / there's
69
+ nothing to do — not `fail!`. A skip is `ok?` (won't read as an error) but
70
+ distinct from success (`skipped?`). Use `ok?` for "didn't fail" checks. `skip`
71
+ takes `**output` (like `succeed`), so an idempotent hit can return the existing
72
+ record's id.
73
+ - `fail_with(struct, code:)` records a failure carrying `struct`'s validation
74
+ errors — for validating a side FieldStruct in a step.
75
+ - **Declare failure codes** with `fails :refused, 'message', kind: :rejected`.
76
+ The keywords are neutral metadata the gem passes through to
77
+ `result.metadata`. Once any code is declared, failing with an undeclared one
78
+ raises `DefinitionError` (`:invalid_input` / `:invalid_output` are exempt).
79
+ `fail`'s keywords after the message are only ever errors, so an error on
80
+ `kind` stays an error.
81
+ - **Declare skip codes** with `skips :not_ready, 'message', retry: true`, read
82
+ back as `result.metadata` on the skip. Once any skip code is declared,
83
+ skipping with an undeclared one raises `DefinitionError`, the default
84
+ `:skipped` included (nothing is exempt).
85
+ - Plain verbs don't halt; last write wins. Bang verbs halt.
86
+ - **A plain `fail` / `fail_with` / `skip` / `succeed` must be the last
87
+ expression of its step or hook method** (or `return fail(...)`). `fail :x if bad?` followed by more
88
+ code raises `DefinitionError` on the action's first run — that's a `fail!`
89
+ missing its bang. The opt-in RuboCop cop `Actionable/VerbNotLast`
90
+ (`plugins: [{actionable-rb: {require_path: actionable/rubocop}}]`) flags it.
91
+ `Actionable.verb_position_check = false` turns the first-run check off (an
92
+ upgrade escape hatch; the cop is then the only guard). Don't reach for it to
93
+ silence an offense: fix the verb.
94
+ - `fail` / `skip` codes must be identifiers (`:card_expired`, `:ok?`); a
95
+ sentence raises `ArgumentError`.
96
+ - To signal a real bug, `raise` — exceptions propagate, they aren't caught.
97
+ **Never `fail 'message'` for that:** inside an action `fail` is the gem's
98
+ verb, not `Kernel#fail`: a sentence code raises `ArgumentError`, and a
99
+ one-word string (`fail 'boom'`) records a `Failure` with code `:boom` and
100
+ the run keeps going (a later `succeed` erases it).
101
+
102
+ ```ruby
103
+ def charge
104
+ fail 'gateway down' # WRONG: not an exception; raises ArgumentError (not an identifier code)
105
+ raise GatewayError, 'down' # a bug / unexpected condition: propagates
106
+ fail!(:gateway_down, 'down') # an expected failure outcome: records and halts
107
+ end
108
+ ```
109
+
110
+ ## Composition
111
+
112
+ - **Nested action:** `step Notify, input: %i[invoice], expose: %i[receipt]`. A
113
+ child failure fails the parent with its code, message, errors and metadata
114
+ intact; a child skip continues it. A parent that declares failures must
115
+ declare its children's failure codes too, but never their skip codes.
116
+ - **Branch:** `case_step :status do on 'active', :handle; default :other end`
117
+ (matches by `==`, `Regexp`, or Array membership).
118
+ - **Hooks:** `on_success` / `on_failure` / `on_skip` / `always` run after the
119
+ main steps by outcome, inside any `transactional` transaction. With the Rails
120
+ adapter, `after_commit :method` (ActiveRecord >= 7.2) runs only after the
121
+ outermost transaction commits a successful run — the place for emails,
122
+ webhooks and enqueues. It can't change the result and isn't in the history.
123
+ A bang verb in an outcome hook stops the rest of that category, but `always`
124
+ hooks still run and see the adopted result — keep cleanup/audit in `always`.
125
+ - **Override by redeclaring:** a second `step :x` / `case_step :x` / `step
126
+ Child` / hook with the same name (same class or a subclass) replaces the
127
+ earlier one's options and keeps its position — last declaration wins. There
128
+ is no removal; guard an inherited step off with `if:` / `unless:`.
129
+ - **Discriminated input:** `input_for(:event_type) { on 'sale', SaleShape;
130
+ default UnknownShape }` picks the input schema at run time from a discriminator
131
+ (typed polymorphic input). An unmatched value with no `default` →
132
+ `Failure(:invalid_input)`.
133
+ - **Batch:** `Action.run_each(items) { |i| {…} }` runs once per item and returns a
134
+ `BatchResult` (`all_ok?` / `any_failure?` / `partial?`, `to_api_h`).
135
+
136
+ ## Mistakes to avoid
137
+
138
+ - **Reserved output field names raise `Actionable::DefinitionError`.** An
139
+ `output` field named `code`/`message`/`output`/`history`/`errors`/`metadata`
140
+ (result attributes) or `result`/`input` (reserved ivars) is rejected when declared —
141
+ pick another name. (Input fields are fine; they're read via `input.x`.)
142
+ - **A missing step method raises `DefinitionError` at run start**, naming the
143
+ action and method — implement every declared step / hook / case method.
144
+ - **A successful run must satisfy its declared output**, or it becomes
145
+ `Failure(:invalid_output)`. Failures/skips capture output best-effort and are
146
+ not validated — so don't rely on output fields being present after a skip.
147
+ - **`fail` shadows `Kernel#fail`** in every instance method of an action (see
148
+ the verb section). Use `raise` for exceptions, `fail!(:code)` to stop with a
149
+ failure.
150
+ - **Don't `require 'actionable/rails'` in the core path.** Transactions and
151
+ `ProxyValidator` are opt-in; the core never loads `active_*`.
152
+
153
+ To inspect an action's shape (for tooling or to reason about it), call
154
+ `SomeAction.describe` — a Hash of its name, input/output, steps, declared
155
+ failures, hooks, measure, and transaction config (or `describe_text` for a readable summary).
156
+
157
+ ## Beyond a single run
158
+
159
+ - **Serialize a result:** `result.to_h` (plain Hash) or
160
+ `result.to_api_h(index:)` (compact HTTP/batch element: `index`, `status`,
161
+ `id`, `errors`) — don't hand-map in controllers.
162
+ - **Background jobs (`require 'actionable/job'`):** `include
163
+ Actionable::Job::Mixin` into a Sidekiq/ActiveJob job and call
164
+ `run_actionable(SomeAction, **kwargs)` in `perform`. A success/skip acks; a
165
+ transient failure raises `RetryableFailure` (queue retries); a permanent-code
166
+ failure is discarded; a genuine exception propagates. Declared `retry:`
167
+ metadata is ignored unless the action calls `honor_retry_metadata`; then
168
+ `retry: true` retries (failures and skips) and `retry: false` doesn't.
169
+ - **Prod observability:** `measure :sampled, rate: 0.1` records history on a
170
+ fraction of runs (between `:all` and `:none`).
171
+
172
+ ## Upgrading the gem
173
+
174
+ When asked to bump actionable, follow the gem's `UPGRADING.md` (next to
175
+ `USAGE.md` in `bundle show actionable`, or
176
+ https://github.com/Paymentbox-com/actionable/blob/main/UPGRADING.md) instead
177
+ of recalling changes. In short:
178
+
179
+ 1. Read the from/to versions (`Gemfile.lock`) and only the guide's sections
180
+ between them.
181
+ 2. Bump field_struct first when the target needs it (>= 0.12 from 1.6.0) and
182
+ follow its UPGRADING.md.
183
+ 3. Bump actionable; check `rg --pcre2-version`, then run the Finds the guide
184
+ lists under "Which Finds to run" (silent changes) even on a green suite.
185
+ 4. Enable the `Actionable/VerbNotLast` cop (list every app base class defined in
186
+ its own file in `ActionBaseClasses`) and run
187
+ `bundle exec rubocop --only Actionable/VerbNotLast`.
188
+ 5. Apply each **Fix**; set `Actionable.verb_position_check = false` only in
189
+ `spec_helper` for a baseline, and never leave it behind.
190
+ 6. Regenerate owned RBS with `Actionable::RBS.generate`.
191
+ 7. Run the suite with the check on.
192
+ 8. Report what was fixed, what was reviewed by hand (entries with no
193
+ reliable Find), and residual behaviour risks.
194
+
195
+ ## Testing (`require 'actionable/rspec'`)
196
+
197
+ ```ruby
198
+ expect(CreateInvoice).to perform_actionable(amount: 10).and_succeed
199
+ expect(CreateInvoice).to perform_actionable(amount: 0).and_fail(:invalid_amount)
200
+ expect(Sync).to perform_actionable.and_skip(:not_ready)
201
+
202
+ allow_actionable_failure(CreateInvoice, code: :invalid_amount) # stub without running
203
+ ```
204
+
205
+ On an action that declares failures, a stubbed failure carries the declared
206
+ metadata for its code, and a stub or `and_fail` with an undeclared code raises
207
+ `ArgumentError`.
208
+
209
+ **Outcome coverage** (`require 'actionable/rspec/coverage'`) lists, at the end
210
+ of the suite, each action's outcomes no spec reached: success, every declared
211
+ failure and every declared skip. Only a real run counts, so a stub doesn't
212
+ cover anything. When a project sets `Actionable::Coverage.baseline`, a new
213
+ declaration (`fails :x`) needs a spec that reaches it, or an entry in the
214
+ baseline file; the failure message names the exact line to add or remove.
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: actionable-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.9.0
5
+ platform: ruby
6
+ authors:
7
+ - Adrian Madrid
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: field_struct
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.12'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.12'
26
+ - !ruby/object:Gem::Dependency
27
+ name: oj
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ description: |
41
+ Actionable encapsulates business logic as an ordered sequence of steps that
42
+ return a single typed result. Pure-Ruby core (Rails transactions and validators
43
+ are an optional adapter); inputs and outputs are typed via field_struct; full RBS
44
+ signatures via Sord + YARD.
45
+ email:
46
+ - amadrid@pmtbox.com
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - ".claude-plugin/marketplace.json"
52
+ - ".claude-plugin/plugin.json"
53
+ - ".rspec"
54
+ - AGENTS.md
55
+ - CHANGELOG.md
56
+ - CODE_OF_CONDUCT.md
57
+ - LICENSE.txt
58
+ - README.md
59
+ - UPGRADING.md
60
+ - USAGE.md
61
+ - docs/getting_started.md
62
+ - lib/actionable-rb.rb
63
+ - lib/actionable.rb
64
+ - lib/actionable/action.rb
65
+ - lib/actionable/batch_result.rb
66
+ - lib/actionable/errors.rb
67
+ - lib/actionable/history.rb
68
+ - lib/actionable/input_dispatch.rb
69
+ - lib/actionable/job.rb
70
+ - lib/actionable/measurement.rb
71
+ - lib/actionable/rails.rb
72
+ - lib/actionable/rails/after_commit.rb
73
+ - lib/actionable/rails/proxy_validator.rb
74
+ - lib/actionable/rails/transactions.rb
75
+ - lib/actionable/railtie.rb
76
+ - lib/actionable/rbs.rb
77
+ - lib/actionable/registry.rb
78
+ - lib/actionable/result.rb
79
+ - lib/actionable/results/failure.rb
80
+ - lib/actionable/results/skipped.rb
81
+ - lib/actionable/results/success.rb
82
+ - lib/actionable/rspec.rb
83
+ - lib/actionable/rspec/coverage.rb
84
+ - lib/actionable/rspec/coverage/report.rb
85
+ - lib/actionable/rspec/coverage/tracker.rb
86
+ - lib/actionable/rspec/matchers.rb
87
+ - lib/actionable/rspec/stubs.rb
88
+ - lib/actionable/rubocop.rb
89
+ - lib/actionable/rubocop/config.yml
90
+ - lib/actionable/rubocop/cop/actionable/verb_not_last.rb
91
+ - lib/actionable/rubocop/plugin.rb
92
+ - lib/actionable/runner.rb
93
+ - lib/actionable/steps.rb
94
+ - lib/actionable/steps/action.rb
95
+ - lib/actionable/steps/base.rb
96
+ - lib/actionable/steps/case.rb
97
+ - lib/actionable/steps/method.rb
98
+ - lib/actionable/value_match.rb
99
+ - lib/actionable/verb_position.rb
100
+ - lib/actionable/version.rb
101
+ - sig/actionable.rbs
102
+ - sig/actionable/dsl.rbs
103
+ - sig/actionable/rails.rbs
104
+ - skills/actionable/SKILL.md
105
+ homepage: https://github.com/Paymentbox-com/actionable
106
+ licenses:
107
+ - MIT
108
+ metadata:
109
+ allowed_push_host: https://rubygems.org
110
+ homepage_uri: https://github.com/Paymentbox-com/actionable
111
+ source_code_uri: https://github.com/Paymentbox-com/actionable
112
+ changelog_uri: https://github.com/Paymentbox-com/actionable/blob/main/CHANGELOG.md
113
+ rubygems_mfa_required: 'true'
114
+ default_lint_roller_plugin: Actionable::RubocopPlugin
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: 3.2.0
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubygems_version: 4.0.20
130
+ specification_version: 4
131
+ summary: Simple, typed, composable Ruby service objects.
132
+ test_files: []