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.
- checksums.yaml +7 -0
- data/.claude-plugin/marketplace.json +15 -0
- data/.claude-plugin/plugin.json +9 -0
- data/.rspec +3 -0
- data/AGENTS.md +150 -0
- data/CHANGELOG.md +766 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +555 -0
- data/UPGRADING.md +957 -0
- data/USAGE.md +1291 -0
- data/docs/getting_started.md +611 -0
- data/lib/actionable/action.rb +1220 -0
- data/lib/actionable/batch_result.rb +92 -0
- data/lib/actionable/errors.rb +36 -0
- data/lib/actionable/history.rb +91 -0
- data/lib/actionable/input_dispatch.rb +94 -0
- data/lib/actionable/job.rb +157 -0
- data/lib/actionable/measurement.rb +64 -0
- data/lib/actionable/rails/after_commit.rb +142 -0
- data/lib/actionable/rails/proxy_validator.rb +59 -0
- data/lib/actionable/rails/transactions.rb +95 -0
- data/lib/actionable/rails.rb +24 -0
- data/lib/actionable/railtie.rb +10 -0
- data/lib/actionable/rbs.rb +502 -0
- data/lib/actionable/registry.rb +64 -0
- data/lib/actionable/result.rb +166 -0
- data/lib/actionable/results/failure.rb +43 -0
- data/lib/actionable/results/skipped.rb +30 -0
- data/lib/actionable/results/success.rb +14 -0
- data/lib/actionable/rspec/coverage/report.rb +264 -0
- data/lib/actionable/rspec/coverage/tracker.rb +330 -0
- data/lib/actionable/rspec/coverage.rb +45 -0
- data/lib/actionable/rspec/matchers.rb +266 -0
- data/lib/actionable/rspec/stubs.rb +250 -0
- data/lib/actionable/rspec.rb +40 -0
- data/lib/actionable/rubocop/config.yml +8 -0
- data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
- data/lib/actionable/rubocop/plugin.rb +31 -0
- data/lib/actionable/rubocop.rb +25 -0
- data/lib/actionable/runner.rb +300 -0
- data/lib/actionable/steps/action.rb +76 -0
- data/lib/actionable/steps/base.rb +107 -0
- data/lib/actionable/steps/case.rb +124 -0
- data/lib/actionable/steps/method.rb +39 -0
- data/lib/actionable/steps.rb +27 -0
- data/lib/actionable/value_match.rb +22 -0
- data/lib/actionable/verb_position.rb +574 -0
- data/lib/actionable/version.rb +6 -0
- data/lib/actionable-rb.rb +6 -0
- data/lib/actionable.rb +59 -0
- data/sig/actionable/dsl.rbs +42 -0
- data/sig/actionable/rails.rbs +16 -0
- data/sig/actionable.rbs +2593 -0
- data/skills/actionable/SKILL.md +214 -0
- metadata +132 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
# Getting started with Actionable
|
|
2
|
+
|
|
3
|
+
How to use Actionable in a repo — productively, in about ten minutes. Actionable
|
|
4
|
+
builds typed **service objects**: you subclass `Actionable::Action`, declare an
|
|
5
|
+
ordered list of **steps**, implement each as a method, and run it. A run returns
|
|
6
|
+
one typed **result** — a `Success`, `Failure`, or `Skipped` — carrying a `code`,
|
|
7
|
+
`message`, structured `errors`, a typed `output`, and an execution `history`.
|
|
8
|
+
|
|
9
|
+
This is the task-oriented adoption guide. For the exhaustive list of step types,
|
|
10
|
+
options, and macros see [`USAGE.md`](../USAGE.md); for the feature tour see
|
|
11
|
+
[`README.md`](../README.md).
|
|
12
|
+
|
|
13
|
+
## At a glance — task → API
|
|
14
|
+
|
|
15
|
+
| You want to… | Do this |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Define a service object | `class Settle < Actionable::Action` |
|
|
18
|
+
| Add an ordered step | `step :build` |
|
|
19
|
+
| Run it | `Settle.run(**kwargs)` |
|
|
20
|
+
| Run it over a collection | `Settle.run_each(items)` → `BatchResult` |
|
|
21
|
+
| Type & validate inputs | `input { required :amount, :integer }` |
|
|
22
|
+
| Pick the input shape at runtime | `input_for(:event_type) { on 'sale', SaleShape }` |
|
|
23
|
+
| Type the result payload | `output { required :invoice, Invoice }` |
|
|
24
|
+
| Read a typed output | `result.invoice` / `result.output.invoice` |
|
|
25
|
+
| Record success / failure / skip | `succeed` / `fail(:code)` / `skip(:code)` |
|
|
26
|
+
| Stop the pipeline | `succeed!` / `fail!` / `skip!` / `halt!` |
|
|
27
|
+
| Guard a step | `step :charge, if: :ready?` |
|
|
28
|
+
| Branch on a value | `case_step :status do … end` |
|
|
29
|
+
| Compose actions | `step Other, input: %i[sku], expose: %i[line]` |
|
|
30
|
+
| Run code after the outcome | `on_success` / `on_failure` / `on_skip` / `always` |
|
|
31
|
+
| Override an inherited step or hook | redeclare it: `step :charge, if: :live?` (last wins, same position) |
|
|
32
|
+
| Record timings & history | `measure :all` (or `:sampled, rate:`), then `result.history` |
|
|
33
|
+
| Inspect an action | `Settle.describe` / `Settle.describe_text` |
|
|
34
|
+
| Read errors as sentences | `result.errors.full_messages` |
|
|
35
|
+
| Test an action | `expect(Settle).to perform_actionable(…).and_succeed` |
|
|
36
|
+
| Wrap a run in a DB transaction | `transactional model: :invoice` (Rails adapter) |
|
|
37
|
+
| Send email/webhooks only after commit | `after_commit :send_receipt` (Rails adapter, AR >= 7.2) |
|
|
38
|
+
| Run it from a background job | `include Actionable::Job::Mixin`; add `honor_retry_metadata` to honour `retry:` |
|
|
39
|
+
| Generate RBS for an action | `Actionable::RBS.generate(Settle)` |
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# Gemfile
|
|
45
|
+
gem 'actionable-rb' # then `require 'actionable'`; Bundler.require does it for you
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Upgrading an app that already uses Actionable? Follow [UPGRADING.md](../UPGRADING.md).
|
|
49
|
+
|
|
50
|
+
Requires Ruby 3.2+. The core is pure Ruby (only `field_struct`). Two optional
|
|
51
|
+
adapters are separate requires — never loaded by the core:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
require 'actionable/rails' # transactions + ProxyValidator (needs Rails / ActiveModel)
|
|
55
|
+
require 'actionable/rspec' # the perform_actionable matcher + stub helpers
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Part 1 — Any Ruby project
|
|
61
|
+
|
|
62
|
+
### Your first action
|
|
63
|
+
|
|
64
|
+
A step is just a method. Set instance variables that match declared `output`
|
|
65
|
+
fields and they're captured, coerced, and surfaced on the result — and reachable
|
|
66
|
+
directly off it. If no step records an outcome, the run auto-succeeds.
|
|
67
|
+
|
|
68
|
+
<!-- doctest -->
|
|
69
|
+
```ruby
|
|
70
|
+
class Greet < Actionable::Action
|
|
71
|
+
output { required :greeting, :string }
|
|
72
|
+
|
|
73
|
+
step :build
|
|
74
|
+
|
|
75
|
+
def build = @greeting = 'Hello, World!'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
result = Greet.run
|
|
79
|
+
result.success? # => true
|
|
80
|
+
result.greeting # => "Hello, World!"
|
|
81
|
+
result.output.greeting # => "Hello, World!"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### The result model — three honest outcomes
|
|
85
|
+
|
|
86
|
+
Every run returns exactly one result. Beyond success and failure, a **skip** is a
|
|
87
|
+
first-class outcome: the action had nothing to do (a precondition wasn't ready,
|
|
88
|
+
the work was already done). That's neither an error nor real work, so conflating
|
|
89
|
+
it with either loses information. Results can also be built directly (handy in
|
|
90
|
+
tests):
|
|
91
|
+
|
|
92
|
+
<!-- doctest -->
|
|
93
|
+
```ruby
|
|
94
|
+
ok = Actionable::Success.new(message: 'done')
|
|
95
|
+
ok.success? # => true
|
|
96
|
+
ok.ok? # => true
|
|
97
|
+
|
|
98
|
+
idle = Actionable::Skipped.new(code: :not_ready)
|
|
99
|
+
idle.skipped? # => true
|
|
100
|
+
idle.failure? # => false
|
|
101
|
+
idle.ok? # => true
|
|
102
|
+
|
|
103
|
+
bad = Actionable::Failure.new(code: :nope)
|
|
104
|
+
bad.failure? # => true
|
|
105
|
+
bad.ok? # => false
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Predicates: `success?`/`successful?`, `failure?`/`failed?`, `skipped?`, and `ok?`
|
|
109
|
+
(≡ "didn't fail" — true for both a success and a skip). Every result carries
|
|
110
|
+
`code`, `message`, `errors`, `output`, and `history`.
|
|
111
|
+
|
|
112
|
+
### Typed input
|
|
113
|
+
|
|
114
|
+
`input do … end` coerces `.run`'s keyword arguments into a typed struct and
|
|
115
|
+
exposes it to the steps as `input`. A missing or uncoercible **required** input
|
|
116
|
+
short-circuits to `Failure(:invalid_input)` *without running any steps*.
|
|
117
|
+
|
|
118
|
+
<!-- doctest -->
|
|
119
|
+
```ruby
|
|
120
|
+
class Double < Actionable::Action
|
|
121
|
+
input { required :n, :integer }
|
|
122
|
+
output { required :doubled, :integer }
|
|
123
|
+
|
|
124
|
+
step :go
|
|
125
|
+
|
|
126
|
+
def go = @doubled = input.n * 2
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
Double.run(n: '21').output.doubled # => 42
|
|
130
|
+
Double.run.code # => :invalid_input
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
For a polymorphic payload, `input_for` picks the schema at run time from a
|
|
134
|
+
discriminator (typed input without a single fixed shape):
|
|
135
|
+
|
|
136
|
+
<!-- doctest -->
|
|
137
|
+
```ruby
|
|
138
|
+
Sale = Class.new(FieldStruct::Base) { required :amount, :integer }
|
|
139
|
+
Refund = Class.new(FieldStruct::Base) { required :original_id, :integer }
|
|
140
|
+
|
|
141
|
+
class Handle < Actionable::Action
|
|
142
|
+
input_for :event_type do
|
|
143
|
+
on 'sale', Sale
|
|
144
|
+
on 'refund', Refund
|
|
145
|
+
end
|
|
146
|
+
output { optional :amount, :integer }
|
|
147
|
+
step :go
|
|
148
|
+
def go
|
|
149
|
+
@amount = input.amount if input.respond_to?(:amount)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
Handle.run(event_type: 'sale', amount: 5).output.amount # => 5
|
|
154
|
+
Handle.run(event_type: 'nope').code # => :invalid_input
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Typed output
|
|
158
|
+
|
|
159
|
+
`output do … end` declares a FieldStruct schema. Matching ivars are captured at
|
|
160
|
+
the end of the run, coerced through the schema, and exposed as `result.output`
|
|
161
|
+
plus convenience delegation (`result.total` → `result.output.total`). Only a
|
|
162
|
+
**strict success** is validated — a failed or skipped run captures output
|
|
163
|
+
best-effort and is never validated.
|
|
164
|
+
|
|
165
|
+
<!-- doctest -->
|
|
166
|
+
```ruby
|
|
167
|
+
class Sum < Actionable::Action
|
|
168
|
+
output { required :total, :integer }
|
|
169
|
+
|
|
170
|
+
step :add
|
|
171
|
+
|
|
172
|
+
def add = @total = '42' # coerced through the schema
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
Sum.run.total # => 42
|
|
176
|
+
Sum.run.output.total # => 42
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Control flow
|
|
180
|
+
|
|
181
|
+
Inside a step, five verbs record the outcome. The plain forms record and keep
|
|
182
|
+
going (**last write wins**); the bang forms also halt the pipeline. A genuine
|
|
183
|
+
`StandardError` is never swallowed — it propagates to the caller.
|
|
184
|
+
|
|
185
|
+
| Verb | Records | Halts? |
|
|
186
|
+
|------|---------|--------|
|
|
187
|
+
| `succeed(message = nil, **output)` | `Success` | no |
|
|
188
|
+
| `fail(code, message = nil, **errors)` | `Failure` | no |
|
|
189
|
+
| `fail_with(source, code: :invalid)` | `Failure` absorbing `source.errors` | no |
|
|
190
|
+
| `skip(code = :skipped, message = nil, **output)` | `Skipped` | no |
|
|
191
|
+
| `succeed!` / `fail!` / `fail_with!` / `skip!` | as above | **yes** |
|
|
192
|
+
| `halt!` | keeps current result | **yes** |
|
|
193
|
+
|
|
194
|
+
`fail`'s keyword arguments populate the result's `errors`; `succeed`'s populate
|
|
195
|
+
its `output`.
|
|
196
|
+
|
|
197
|
+
<!-- doctest -->
|
|
198
|
+
```ruby
|
|
199
|
+
class Charge < Actionable::Action
|
|
200
|
+
input { required :amount, :integer }
|
|
201
|
+
|
|
202
|
+
step :validate
|
|
203
|
+
step :charge
|
|
204
|
+
|
|
205
|
+
def validate
|
|
206
|
+
fail!(:invalid_amount, 'must be positive') unless input.amount.positive?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def charge = succeed("charged #{input.amount}")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
Charge.run(amount: 10).message # => "charged 10"
|
|
213
|
+
Charge.run(amount: -5).failure? # => true
|
|
214
|
+
Charge.run(amount: -5).code # => :invalid_amount
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
A skip is just as easy to express, and reads as "nothing to do":
|
|
218
|
+
|
|
219
|
+
<!-- doctest -->
|
|
220
|
+
```ruby
|
|
221
|
+
class Sync < Actionable::Action
|
|
222
|
+
input { optional :ready, :boolean }
|
|
223
|
+
|
|
224
|
+
step :guard
|
|
225
|
+
step :perform
|
|
226
|
+
|
|
227
|
+
def guard
|
|
228
|
+
skip!(:not_ready, 'nothing to sync yet') unless input.ready
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def perform = succeed('synced')
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
Sync.run(ready: false).skipped? # => true
|
|
235
|
+
Sync.run(ready: false).ok? # => true
|
|
236
|
+
Sync.run(ready: true).success? # => true
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Branching — case steps
|
|
240
|
+
|
|
241
|
+
A `case_step` reads a value (from a method or input) and dispatches to a branch
|
|
242
|
+
target, matched by `==`, Array membership, or `Regexp`. A branch target is any
|
|
243
|
+
step target — a method or a nested action.
|
|
244
|
+
|
|
245
|
+
<!-- doctest -->
|
|
246
|
+
```ruby
|
|
247
|
+
class Triage < Actionable::Action
|
|
248
|
+
input { required :status, :integer }
|
|
249
|
+
output { optional :level, :symbol }
|
|
250
|
+
|
|
251
|
+
def status = input.status
|
|
252
|
+
|
|
253
|
+
case_step :status do
|
|
254
|
+
on (400..499).to_a, :client_error
|
|
255
|
+
on (500..599).to_a, :server_error
|
|
256
|
+
default :ok
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def client_error = @level = :client
|
|
260
|
+
def server_error = @level = :server
|
|
261
|
+
def ok = @level = :ok
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
Triage.run(status: 404).output.level # => :client
|
|
265
|
+
Triage.run(status: 503).output.level # => :server
|
|
266
|
+
Triage.run(status: 200).output.level # => :ok
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Composing — nested actions
|
|
270
|
+
|
|
271
|
+
A step can be another action. `:input` names parent ivars to thread into the
|
|
272
|
+
child's `.run`; `:expose` limits which child outputs are absorbed back as parent
|
|
273
|
+
ivars (default: all). A child failure fails the parent (with the child's
|
|
274
|
+
code/message/errors) and halts; a child skip continues the parent.
|
|
275
|
+
|
|
276
|
+
<!-- doctest -->
|
|
277
|
+
```ruby
|
|
278
|
+
class BuildLine < Actionable::Action
|
|
279
|
+
input { required :sku, :string }
|
|
280
|
+
output { required :line, :string }
|
|
281
|
+
step :build
|
|
282
|
+
|
|
283
|
+
def build = @line = "line:#{input.sku}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
class BuildOrder < Actionable::Action
|
|
287
|
+
output { required :line, :string }
|
|
288
|
+
step :set_sku
|
|
289
|
+
step BuildLine, input: %i[sku], expose: %i[line]
|
|
290
|
+
|
|
291
|
+
def set_sku = @sku = 'ABC'
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
BuildOrder.run.output.line # => "line:ABC"
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Running a batch
|
|
298
|
+
|
|
299
|
+
`run_each` runs the action once per item and collects a `BatchResult` — the
|
|
300
|
+
common "process each row, report per item" endpoint shape. Items run
|
|
301
|
+
independently; the block maps each to `.run` arguments.
|
|
302
|
+
|
|
303
|
+
<!-- doctest -->
|
|
304
|
+
```ruby
|
|
305
|
+
class Doubler < Actionable::Action
|
|
306
|
+
input { required :n, :integer }
|
|
307
|
+
output { required :doubled, :integer }
|
|
308
|
+
step :go
|
|
309
|
+
def go = @doubled = input.n * 2
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
batch = Doubler.run_each([1, 2, 3]) { |n| {n: n} }
|
|
313
|
+
batch.size # => 3
|
|
314
|
+
batch.all_ok? # => true
|
|
315
|
+
batch.map { |r| r.output.doubled } # => [2, 4, 6]
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Lifecycle hooks & measurement
|
|
319
|
+
|
|
320
|
+
`on_success`, `on_failure`, `on_skip`, and `always` run after the main steps,
|
|
321
|
+
dispatched by the outcome (then `always`, regardless). Hooks are ordinary methods
|
|
322
|
+
and may use the control-flow verbs; a `fail!` in an outcome hook stops the rest
|
|
323
|
+
of that category, but `always` hooks still run. `measure :all` records a `History` of every
|
|
324
|
+
step (the default `measure :none` records nothing for zero overhead):
|
|
325
|
+
|
|
326
|
+
<!-- doctest -->
|
|
327
|
+
```ruby
|
|
328
|
+
class Timed < Actionable::Action
|
|
329
|
+
measure :all
|
|
330
|
+
step :work
|
|
331
|
+
def work = nil
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
history = Timed.run.history
|
|
335
|
+
history.steps.map(&:name) # => [:work]
|
|
336
|
+
history.steps.first.section # => :main
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Introspecting an action
|
|
340
|
+
|
|
341
|
+
`describe` returns a structured Hash (name, input/output field metadata, ordered
|
|
342
|
+
steps, hooks, measure mode, transaction config); `describe_text` renders the same
|
|
343
|
+
information as a human/agent-readable string. Both let a reader understand an
|
|
344
|
+
action without opening its source.
|
|
345
|
+
|
|
346
|
+
<!-- doctest -->
|
|
347
|
+
```ruby
|
|
348
|
+
class Probe < Actionable::Action
|
|
349
|
+
output { required :count, :integer }
|
|
350
|
+
step :compute
|
|
351
|
+
def compute = @count = 1
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
Probe.describe[:measure] # => :none
|
|
355
|
+
Probe.describe[:steps].first[:type] # => :method
|
|
356
|
+
Probe.describe.key?(:output) # => true
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`Probe.describe_text` reuses FieldStruct's own field summaries for the
|
|
360
|
+
input/output lines:
|
|
361
|
+
|
|
362
|
+
```
|
|
363
|
+
Probe (measure: none)
|
|
364
|
+
Input: (free-form)
|
|
365
|
+
Output:
|
|
366
|
+
count (Integer, required) — accepts in (Array | Range)
|
|
367
|
+
Steps:
|
|
368
|
+
- compute (method)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### When things go wrong — error → meaning → fix
|
|
372
|
+
|
|
373
|
+
<!-- doctest -->
|
|
374
|
+
```ruby
|
|
375
|
+
class NeedsTotal < Actionable::Action
|
|
376
|
+
output { required :total, :integer }
|
|
377
|
+
step :noop
|
|
378
|
+
def noop = nil # never sets @total
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
result = NeedsTotal.run
|
|
382
|
+
result.code # => :invalid_output
|
|
383
|
+
result.errors[:total] # => ["is required"]
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
`errors` is FieldStruct's own collection, so beyond `errors[:field]` you get
|
|
387
|
+
`full_messages` — each message as a complete, field-name-prefixed sentence:
|
|
388
|
+
|
|
389
|
+
<!-- doctest -->
|
|
390
|
+
```ruby
|
|
391
|
+
failed = Actionable::Failure.new(code: :invalid)
|
|
392
|
+
failed.errors.add(:first_name, 'is required')
|
|
393
|
+
|
|
394
|
+
failed.errors.full_messages # => ["First name is required"]
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
| You see | It means | Do |
|
|
398
|
+
|---|---|---|
|
|
399
|
+
| `Failure(:invalid_input)` | a `required` input was missing or wouldn't coerce; **no steps ran** | check `.run`'s kwargs against the `input` schema; read `result.errors` |
|
|
400
|
+
| `Failure(:invalid_output)` | a strict success couldn't satisfy the `output` schema | set every `required` output ivar (or pass it via `succeed(field: …)`) |
|
|
401
|
+
| `result.errors[:x] == ["is required"]` | a declared field is missing/blank | set the matching ivar / pass the input |
|
|
402
|
+
| `DefinitionError: … declares step(s) calling :x but does not implement them` | a step / hook / case target method is missing | implement the method, or remove the step |
|
|
403
|
+
| `DefinitionError: output field(s) :code conflict with reserved …` | an output field name shadows a reserved result attribute/ivar | rename the field |
|
|
404
|
+
| a raised `StandardError` out of `.run` | a genuine bug inside a step — **not** control flow | fix it; expected error outcomes use `fail`/`fail!`, not exceptions |
|
|
405
|
+
|
|
406
|
+
### Common mistakes
|
|
407
|
+
|
|
408
|
+
1. **Bang vs plain verbs.** `fail` records but the pipeline keeps running (last
|
|
409
|
+
write wins); `fail!` halts immediately. Reach for the bang to stop.
|
|
410
|
+
2. **Output is captured from ivars *by name*.** Only ivars matching a declared
|
|
411
|
+
`output` field are captured — a typo'd `@totals` is silently dropped. Set
|
|
412
|
+
`@total`.
|
|
413
|
+
3. **Only a strict success validates output.** A failed or skipped run captures
|
|
414
|
+
output best-effort and never validates it; don't rely on output typing on a
|
|
415
|
+
non-success.
|
|
416
|
+
4. **An invalid required input never reaches your steps.** It short-circuits to
|
|
417
|
+
`Failure(:invalid_input)` before step one — put "missing argument" handling in
|
|
418
|
+
the `input` schema, not a guard step.
|
|
419
|
+
5. **Reserved output names.** `code` / `message` / `output` / `history` /
|
|
420
|
+
`errors` / `metadata` / `result` / `input` can't be output fields (they'd shadow the
|
|
421
|
+
result). Rename them.
|
|
422
|
+
6. **Exceptions aren't failures.** A raised `StandardError` propagates to the
|
|
423
|
+
caller; it does not become a `Failure`. Use `fail`/`fail!` for expected error
|
|
424
|
+
outcomes.
|
|
425
|
+
7. **`fail` is not `raise` inside an action.** The gem's `fail` verb replaces
|
|
426
|
+
`Kernel#fail` there, so `fail 'boom'` records a `Failure` with code
|
|
427
|
+
`:boom` and the run continues (a sentence code raises `ArgumentError`). Use
|
|
428
|
+
`raise` to signal a bug. And a plain `fail` / `fail_with` / `skip` / `succeed` must be
|
|
429
|
+
the last expression of its step or hook method, or the action raises
|
|
430
|
+
`DefinitionError` on its first run; see
|
|
431
|
+
[USAGE, Control flow](../USAGE.md#where-a-plain-verb-may-go).
|
|
432
|
+
8. **Redeclaring a step overrides it.** A second `step :x` (in the class or a
|
|
433
|
+
subclass) replaces the first's options and keeps its position, so the last
|
|
434
|
+
declaration wins. See [USAGE, Steps](../USAGE.md#steps).
|
|
435
|
+
|
|
436
|
+
### Testing your actions
|
|
437
|
+
|
|
438
|
+
`require 'actionable/rspec'` adds a matcher that runs the action and asserts the
|
|
439
|
+
outcome, plus stub helpers that return real result objects without executing it:
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
require 'actionable/rspec'
|
|
443
|
+
|
|
444
|
+
RSpec.describe Charge do
|
|
445
|
+
it 'succeeds for a positive amount' do
|
|
446
|
+
expect(described_class).to perform_actionable(amount: 10).and_succeed('charged 10')
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
it 'fails for a non-positive amount' do
|
|
450
|
+
expect(described_class).to perform_actionable(amount: -5).and_fail(:invalid_amount)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
it 'skips when there is nothing to do' do
|
|
454
|
+
expect(Sync).to perform_actionable(ready: false).and_skip(:not_ready)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Make a collaborator's action return a canned result without running it:
|
|
459
|
+
allow_actionable_success(Charge, output: { amount: 10 })
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
`and_raise(ErrorClass, message)` asserts a propagated exception, and every matcher
|
|
463
|
+
accepts a block yielding `(result, exception)` for custom assertions.
|
|
464
|
+
|
|
465
|
+
To learn which outcomes your specs never reach, `require 'actionable/rspec/coverage'`
|
|
466
|
+
in a spec support file. The end of the suite then lists, per action, each
|
|
467
|
+
declared failure or skip (and success) that no spec produced. USAGE's
|
|
468
|
+
"Outcome coverage" section covers the baseline and sharded suites.
|
|
469
|
+
|
|
470
|
+
### Agent toolbox
|
|
471
|
+
|
|
472
|
+
If you're an AI assistant working in this repo, reach for the gem's machine
|
|
473
|
+
affordances instead of guessing:
|
|
474
|
+
|
|
475
|
+
- `Klass.describe` / `Klass.describe_text` — *see* an action's inputs, outputs,
|
|
476
|
+
steps, and hooks without reading its source.
|
|
477
|
+
- `Actionable.registry` — enumerate registered actions (`keys`, `values`, `[]`,
|
|
478
|
+
`each`).
|
|
479
|
+
- `Actionable::RBS.generate(Klass)` — emit RBS for an action's typed `.run`,
|
|
480
|
+
`input`, output instance variables and output accessors (Steep / Solargraph).
|
|
481
|
+
- `result.errors.full_messages` — validation errors as complete sentences.
|
|
482
|
+
- `result.to_h` / `result.to_api_h(index:)` — a plain-Hash or HTTP-element view
|
|
483
|
+
of a result, so controllers and batch endpoints don't hand-map the shape.
|
|
484
|
+
- The gem ships [`USAGE.md`](../USAGE.md) (full reference) and a Claude Code skill
|
|
485
|
+
(`skills/actionable/SKILL.md`) — both are in `bundle show actionable`.
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
## Part 2 — Rails
|
|
490
|
+
|
|
491
|
+
The core never loads `active_*`. The Rails adapter is opt-in:
|
|
492
|
+
|
|
493
|
+
```ruby
|
|
494
|
+
require 'actionable/rails' # e.g. in config/initializers/actionable.rb
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### Where Actionable fits
|
|
498
|
+
|
|
499
|
+
Use an action for a **unit of business work** — settle an invoice, onboard a
|
|
500
|
+
user, sync a record — anywhere you'd otherwise reach for a "service object". The
|
|
501
|
+
typed `input`/`output` give you a validated boundary; the three outcomes let a
|
|
502
|
+
controller branch on success / failure / skip honestly. It is **not** a model and
|
|
503
|
+
**not** a form object; it orchestrates them.
|
|
504
|
+
|
|
505
|
+
### File placement (Zeitwerk)
|
|
506
|
+
|
|
507
|
+
Put actions in an autoloaded path so Rails loads them by name. A common choice is
|
|
508
|
+
`app/actions/`:
|
|
509
|
+
|
|
510
|
+
```
|
|
511
|
+
app/actions/
|
|
512
|
+
billing/
|
|
513
|
+
settle_invoice.rb # class Billing::SettleInvoice < Actionable::Action
|
|
514
|
+
charge_card.rb # class Billing::ChargeCard < Actionable::Action
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
The path mirrors the constant (`app/actions/billing/settle_invoice.rb` ↔
|
|
518
|
+
`Billing::SettleInvoice`); Zeitwerk autoloads them — no `require` in app code.
|
|
519
|
+
|
|
520
|
+
### Transactions
|
|
521
|
+
|
|
522
|
+
`transactional` wraps the whole run in `model.transaction`. A success commits; a
|
|
523
|
+
**recorded failure or a raised exception rolls back**; a skip commits (there's
|
|
524
|
+
nothing to undo). Called inside a transaction your app already opened, the
|
|
525
|
+
action takes its own savepoint (`requires_new: true`), so a recorded failure
|
|
526
|
+
rolls back the action's writes and leaves the rest of your transaction alone;
|
|
527
|
+
pass `requires_new: false` to join it instead. The lifecycle hooks run **inside** the transaction too: a
|
|
528
|
+
write in `on_failure` rolls back with the rest, and an email or webhook sent
|
|
529
|
+
from `on_success` fires before the commit. Put external side effects in an
|
|
530
|
+
`after_commit` hook instead (below).
|
|
531
|
+
|
|
532
|
+
```ruby
|
|
533
|
+
class Billing::SettleInvoice < Actionable::Action
|
|
534
|
+
transactional model: :invoice # wraps the run in Invoice.transaction
|
|
535
|
+
# (pass a class for an explicit one)
|
|
536
|
+
input { required :invoice, Invoice }
|
|
537
|
+
|
|
538
|
+
step :debit
|
|
539
|
+
step :credit
|
|
540
|
+
|
|
541
|
+
def debit = input.invoice.debit!
|
|
542
|
+
def credit = input.invoice.credit! # a raise here rolls the debit back
|
|
543
|
+
end
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
### Side effects after the commit
|
|
547
|
+
|
|
548
|
+
`after_commit` (ActiveRecord >= 7.2) runs a hook only once the data is really
|
|
549
|
+
committed, and only when the run succeeded:
|
|
550
|
+
|
|
551
|
+
```ruby
|
|
552
|
+
class Billing::SettleInvoice < Actionable::Action
|
|
553
|
+
transactional model: :invoice
|
|
554
|
+
step :debit
|
|
555
|
+
step :credit
|
|
556
|
+
after_commit :send_receipt # or a nested action: after_commit SendReceipt, input: %i[invoice]
|
|
557
|
+
|
|
558
|
+
def send_receipt = ReceiptMailer.with(invoice: input.invoice).deliver_later
|
|
559
|
+
end
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
It waits for the **outermost** transaction: called inside a transaction your app
|
|
563
|
+
already opened, the receipt goes out when that one commits, and never if it rolls
|
|
564
|
+
back. With no transaction open it runs right after the run. A failure or a skip
|
|
565
|
+
never runs it. It can't change the result and isn't in the history; an exception
|
|
566
|
+
it raises propagates after the commit.
|
|
567
|
+
|
|
568
|
+
### Validating with ActiveModel — `ProxyValidator`
|
|
569
|
+
|
|
570
|
+
`ProxyValidator` runs ActiveModel rules against a delegate object;
|
|
571
|
+
`#formatted_errors` returns a hash ready to splat into `fail`:
|
|
572
|
+
|
|
573
|
+
```ruby
|
|
574
|
+
class InvoiceRules < Actionable::ProxyValidator
|
|
575
|
+
validates :amount, presence: true, numericality: { greater_than: 0 }
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# inside a step:
|
|
579
|
+
rules = InvoiceRules.new(@invoice)
|
|
580
|
+
fail!(:invalid, **rules.formatted_errors) unless rules.valid?
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### Types for Steep / Solargraph (RBS)
|
|
584
|
+
|
|
585
|
+
The gem ships RBS in `sig/` for the core and for the adapters you call:
|
|
586
|
+
`transactional` / `after_commit` (`actionable/rails`), `Actionable::Job`, and
|
|
587
|
+
the RSpec matchers and stubs (`actionable/rspec`). RBS has no conditional
|
|
588
|
+
loading, so those adapter methods are declared whether or not you require the
|
|
589
|
+
adapter.
|
|
590
|
+
|
|
591
|
+
If your app runs a type checker, generate RBS for your actions' dynamic `.run`
|
|
592
|
+
and output accessors:
|
|
593
|
+
|
|
594
|
+
```ruby
|
|
595
|
+
# lib/tasks/actionable_rbs.rake
|
|
596
|
+
namespace :actionable do
|
|
597
|
+
task rbs: :environment do
|
|
598
|
+
classes = [Billing::SettleInvoice, Billing::ChargeCard]
|
|
599
|
+
File.write('sig/actions.rbs',
|
|
600
|
+
classes.map { |k| Actionable::RBS.generate(k) }.join("\n"))
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
---
|
|
606
|
+
|
|
607
|
+
## Where to go next
|
|
608
|
+
|
|
609
|
+
- [`USAGE.md`](../USAGE.md) — every step type, option, and macro on one page.
|
|
610
|
+
- [`README.md`](../README.md) — the feature tour.
|
|
611
|
+
- `docs/origin/plan.md` — the design rationale and locked decisions (D1–D23).
|