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
data/USAGE.md
ADDED
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
# Actionable — Usage
|
|
2
|
+
|
|
3
|
+
A dense, example-first reference. Every block marked for doctesting is executed
|
|
4
|
+
by `spec/docs_examples_spec.rb`, so it can't rot. For a gentler introduction see
|
|
5
|
+
the [README](README.md).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Results
|
|
10
|
+
|
|
11
|
+
A run returns one of three FieldStruct-backed value objects. They can be built
|
|
12
|
+
directly (handy in tests):
|
|
13
|
+
|
|
14
|
+
<!-- doctest -->
|
|
15
|
+
```ruby
|
|
16
|
+
ok = Actionable::Success.new(message: 'done')
|
|
17
|
+
ok.success? # => true
|
|
18
|
+
ok.ok? # => true
|
|
19
|
+
|
|
20
|
+
skipped = Actionable::Skipped.new(code: :not_ready)
|
|
21
|
+
skipped.skipped? # => true
|
|
22
|
+
skipped.failure? # => false
|
|
23
|
+
skipped.ok? # => true
|
|
24
|
+
|
|
25
|
+
bad = Actionable::Failure.new(code: :nope)
|
|
26
|
+
bad.failure? # => true
|
|
27
|
+
bad.ok? # => false
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Every result carries `code`, `message`, `errors`, `output`, and `history`.
|
|
31
|
+
Predicates: `success?`/`successful?`, `failure?`/`failed?`, `skipped?`, and `ok?`
|
|
32
|
+
(≡ `!failure?`). `to_s`/`inspect` are compact, field-sorted, and deterministic.
|
|
33
|
+
|
|
34
|
+
## Steps
|
|
35
|
+
|
|
36
|
+
Declare steps in order; each names an instance method. Steps are kept in a
|
|
37
|
+
per-class `Set` keyed by `[type, name]`, so a redeclared step collapses to one
|
|
38
|
+
entry, and the set is inherited by subclasses.
|
|
39
|
+
|
|
40
|
+
Redeclaring a step replaces it: **the last declaration wins and keeps the first
|
|
41
|
+
one's position**. A subclass can override an inherited step's guards or options
|
|
42
|
+
this way without touching the parent's steps.
|
|
43
|
+
|
|
44
|
+
<!-- doctest -->
|
|
45
|
+
```ruby
|
|
46
|
+
class Checkout < Actionable::Action
|
|
47
|
+
step :load
|
|
48
|
+
step :charge
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class DryCheckout < Checkout
|
|
52
|
+
step :charge, if: :live? # replaces Checkout's :charge, still after :load
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
DryCheckout.steps.map(&:name) # => [:load, :charge]
|
|
56
|
+
DryCheckout.steps.to_a.last.options # => {if: :live?}
|
|
57
|
+
Checkout.steps.to_a.last.options # => {}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Redeclared with | Result |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `step :x` then `step :x, if: :ok?` (same class or a subclass) | one `:x` step, guarded by `:ok?`, at the first `:x`'s position |
|
|
63
|
+
| `step Child` then `step Child, expose: []` | one nested step with the last `:input`/`:expose` |
|
|
64
|
+
| `case_step(:x) { … }` then `case_step(:x) { … }` | one case step with the last block's branches |
|
|
65
|
+
| `on_success :x` then `on_success :x, if: :ok?` (likewise `on_failure` / `on_skip` / `always`) | one hook with the last options, at the first one's position |
|
|
66
|
+
| `step :x` and `case_step :x` | **two** steps — a method step and a case step are different types |
|
|
67
|
+
|
|
68
|
+
Each hook phase is its own list, so `on_success :x` and `always :x` are two
|
|
69
|
+
hooks. There is no way to remove an inherited step or hook; guard it off with
|
|
70
|
+
`if:` / `unless:` instead.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
class Example < Actionable::Action
|
|
74
|
+
step :first
|
|
75
|
+
step :second, if: :ready? # :if / :unless guards (Symbol or callable)
|
|
76
|
+
step :third, unless: :skip_it?
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Guards take a Symbol naming a (possibly private) predicate, or a callable
|
|
81
|
+
`->(instance) { … }`. Step, hook and `case_step` methods may be private too.
|
|
82
|
+
`@result` is reserved for the run's result: a step that assigns it anything
|
|
83
|
+
else raises `Actionable::DefinitionError`.
|
|
84
|
+
|
|
85
|
+
## Control flow
|
|
86
|
+
|
|
87
|
+
| Verb | Records | Halts? | Returns |
|
|
88
|
+
|------|---------|--------|---------|
|
|
89
|
+
| `succeed(message = nil, **output)` | `Success` | no | `true` |
|
|
90
|
+
| `fail(code, message = nil, **errors)` | `Failure` | no | `false` |
|
|
91
|
+
| `fail_with(source, code: :invalid, message: nil)` | `Failure` absorbing `source.errors` | no | `false` |
|
|
92
|
+
| `skip(code = :skipped, message = nil, **output)` | `Skipped` | no | `false` |
|
|
93
|
+
| `succeed!` / `fail!` / `fail_with!` / `skip!` | as above | **yes** | — |
|
|
94
|
+
| `halt!` | keeps current `@result` | **yes** | — |
|
|
95
|
+
|
|
96
|
+
Plain (non-bang) verbs don't stop the pipeline — the final result reflects the
|
|
97
|
+
most recent record:
|
|
98
|
+
|
|
99
|
+
<!-- doctest -->
|
|
100
|
+
```ruby
|
|
101
|
+
class LastWins < Actionable::Action
|
|
102
|
+
step :a
|
|
103
|
+
step :b
|
|
104
|
+
def a = fail(:nope)
|
|
105
|
+
def b = succeed('recovered')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
LastWins.run.success? # => true
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`fail`'s keyword arguments populate the result's `errors`; `succeed`'s populate
|
|
112
|
+
its `output`. A raised `StandardError` is never caught — it propagates to the
|
|
113
|
+
caller (assert on it with the RSpec matcher's `and_raise`).
|
|
114
|
+
|
|
115
|
+
**`fail` is not `raise` inside an action.** `Actionable::Action#fail` replaces
|
|
116
|
+
Ruby's `Kernel#fail` (the alias of `raise`) in steps, hooks and any instance
|
|
117
|
+
method of the action, so `fail 'something broke'` cannot raise. A `fail` /
|
|
118
|
+
`skip` code must be an identifier: letters, digits and underscores, not
|
|
119
|
+
starting with a digit, optionally ending in `?` or `!` (`:card_expired`,
|
|
120
|
+
`:ok?`, `'NotFound'`). A sentence, a dashed or dotted string, or a class
|
|
121
|
+
raises `ArgumentError` at the call, naming the action and suggesting `raise`.
|
|
122
|
+
To signal a bug, use `raise`; to stop with a failure, use
|
|
123
|
+
`fail!(:code, 'message')`:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class Habit < Actionable::Action
|
|
127
|
+
step :check
|
|
128
|
+
def check = fail('card expired') # meant: raise
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
Habit.run # ArgumentError: Habit: failure code "card expired" is not an identifier; ...
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
<!-- doctest -->
|
|
135
|
+
```ruby
|
|
136
|
+
class Fixed < Actionable::Action
|
|
137
|
+
step :check
|
|
138
|
+
step :finish
|
|
139
|
+
def check = fail!(:card_expired, 'card expired') # records and halts
|
|
140
|
+
def finish = succeed
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
Fixed.run.code # => :card_expired
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A one-word string (`fail 'boom'`) is a valid code, so it still records a
|
|
147
|
+
failure `:boom` instead of raising. Code outside the action (a plain helper
|
|
148
|
+
object, a model) still gets `Kernel#fail`.
|
|
149
|
+
|
|
150
|
+
`fail_with` absorbs an arbitrary FieldStruct's validation errors into the failure
|
|
151
|
+
— the same machinery `:invalid_input` / `:invalid_output` use internally, exposed
|
|
152
|
+
as a verb for validating a side struct:
|
|
153
|
+
|
|
154
|
+
<!-- doctest -->
|
|
155
|
+
```ruby
|
|
156
|
+
EventShape = Class.new(FieldStruct::Base) { required :event_type, :string }
|
|
157
|
+
|
|
158
|
+
class Ingest < Actionable::Action
|
|
159
|
+
step :validate
|
|
160
|
+
def validate = fail_with!(EventShape.new, code: :invalid_event)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
result = Ingest.run
|
|
164
|
+
result.code # => :invalid_event
|
|
165
|
+
result.errors[:event_type] # => ["is required"]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`skip` takes output kwargs too (symmetry with `succeed`) — captured best-effort,
|
|
169
|
+
never validated — so an idempotent hit can return the existing record's data:
|
|
170
|
+
|
|
171
|
+
<!-- doctest -->
|
|
172
|
+
```ruby
|
|
173
|
+
class Dedup < Actionable::Action
|
|
174
|
+
output { optional :existing_id, :integer }
|
|
175
|
+
step :check
|
|
176
|
+
def check = skip!(:duplicate, 'already processed', existing_id: 42)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
result = Dedup.run
|
|
180
|
+
result.skipped? # => true
|
|
181
|
+
result.existing_id # => 42
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Where a plain verb may go
|
|
185
|
+
|
|
186
|
+
`fail`, `fail_with`, `skip` and `succeed` record an outcome **and keep
|
|
187
|
+
running**, so in `fail :card_expired if card.expired?` followed by `notify`,
|
|
188
|
+
the `notify` still runs (`fail!` was meant). The rule: **in a step or hook method, a plain
|
|
189
|
+
recording verb must be the method's last expression, or explicitly end it.**
|
|
190
|
+
Bang verbs (`fail!`, `skip!`, `succeed!`, `fail_with!`) can go anywhere.
|
|
191
|
+
|
|
192
|
+
On an action's first run, every step method (case-step branch targets
|
|
193
|
+
included), lifecycle hook method (`on_success` / `on_failure` / `on_skip` /
|
|
194
|
+
`always`, and `after_commit` with the Rails adapter) is checked by parsing its
|
|
195
|
+
source with Prism. A misplaced verb raises `DefinitionError` naming the action,
|
|
196
|
+
method, `file:line` and verb, with the fix ("use fail! to stop here, or make it
|
|
197
|
+
the method's last expression"). The check runs once per class (a later run pays
|
|
198
|
+
nothing for it), and a failing check raises on every run until fixed. It is
|
|
199
|
+
skipped where it can't be done: no Prism (Ruby 3.2 without the `prism` gem; it
|
|
200
|
+
is a default gem from 3.3), or no readable source (a method from `eval` or
|
|
201
|
+
`define_method`). A step naming an `alias_method` is checked against the
|
|
202
|
+
original `def` (the cop doesn't follow aliases). A module `prepend`ed over a step method hides the class's own
|
|
203
|
+
`def` from the check: the wrapper is what runs first, so only its body is read
|
|
204
|
+
(the cop still reads the class's `def`).
|
|
205
|
+
|
|
206
|
+
| The verb call (`fail`, `fail_with`, `skip`, `succeed`, or `self.fail` …) | |
|
|
207
|
+
|---|---|
|
|
208
|
+
| is the method's last statement (a modifier `fail :x if bad?` counts as the statement) | OK |
|
|
209
|
+
| is the last statement of an `if` / `elsif` / `else` / `unless` / ternary / `case`-`when` / `case`-`in` / `rescue` / `ensure` branch, or the right side of `&&` / `and` / `\|\|` / `or`, whose construct is itself last | OK |
|
|
210
|
+
| is returned: `return fail(:x) if bad?`, or followed directly by `return` | OK |
|
|
211
|
+
| is the last statement of a block or loop body (or followed directly by `next` / `break` there), and the block's call is itself last: `items.each { \|i\| fail :bad if i.bad? }` | OK |
|
|
212
|
+
| is followed by an `ensure` (cleanup runs even after a bang verb) | OK |
|
|
213
|
+
| has another receiver (`gateway.fail`, `items.skip(1)`) | not checked |
|
|
214
|
+
| is followed by more code in the method, a branch whose construct is followed by more code, or a begin body that has an `else` | **offense** |
|
|
215
|
+
| is the end of a block or loop body whose call is followed by more code: `items.each { … fail :bad … }; save` | **offense** |
|
|
216
|
+
| is the left side of `and` / `or`: `fail(:x) and return` never returns (`fail` returns `false`); write `return fail(:x)` | **offense** |
|
|
217
|
+
| is nested in a larger expression: `log(fail(:x))`, `@ok = fail(:x)` | **offense** |
|
|
218
|
+
|
|
219
|
+
Methods that aren't steps or hooks (helpers they call) are not checked.
|
|
220
|
+
|
|
221
|
+
**Turning the first-run check off.** It is on by default. As an escape hatch
|
|
222
|
+
while upgrading (an app with misplaced verbs you can't fix yet), switch it off
|
|
223
|
+
for the whole process, e.g. in an initializer:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
Actionable.verb_position_check = false # Actionable.verb_position_check? reads it back
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Off, `run` never parses a method's source and never loads Prism. **The
|
|
230
|
+
`Actionable/VerbNotLast` cop is then the only guard**, so run it in CI until the
|
|
231
|
+
switch is back on. A class that ran while the check was off is checked on its
|
|
232
|
+
first run after it's turned back on. The switch doesn't cover the code check at
|
|
233
|
+
the `fail` / `skip` call (sentence codes raise `ArgumentError`): that check is a
|
|
234
|
+
per-call regex match, not a parse, costs about 0.4 µs, and catches a wrong
|
|
235
|
+
result code rather than a style problem.
|
|
236
|
+
|
|
237
|
+
**The RuboCop cop.** The gem ships `Actionable/VerbNotLast`, the same rules as
|
|
238
|
+
a lint, *not* loaded by `require 'actionable'`. Opt in from `.rubocop.yml`:
|
|
239
|
+
|
|
240
|
+
```yaml
|
|
241
|
+
plugins:
|
|
242
|
+
- actionable-rb:
|
|
243
|
+
require_path: actionable/rubocop
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
(or `require: [actionable/rubocop]` on RuboCop < 1.72). The cop only sees one
|
|
247
|
+
file: it checks a class that is an action and declares `step` / `case_step`,
|
|
248
|
+
and the methods that class names in a step, case-step branch or hook
|
|
249
|
+
declaration. Methods a parent class declares as steps are left to the
|
|
250
|
+
first-run check.
|
|
251
|
+
|
|
252
|
+
A class counts as an action when its superclass is in `ActionBaseClasses`
|
|
253
|
+
(full names; default `Actionable::Action` and `ApplicationAction`; a leading
|
|
254
|
+
`::` is ignored), or is a class defined in the same file (`class Base < …` or
|
|
255
|
+
`Base = Class.new(…)`, any depth) whose own superclass does. The superclass
|
|
256
|
+
name resolves the way Ruby looks it up: from the enclosing `module` / `class`
|
|
257
|
+
outwards (a compact `class A::B` opens only `A::B`), to the first class,
|
|
258
|
+
module or listed base by that name, else the top level. So `Base` inside
|
|
259
|
+
`module B` is `B::Base` when the file defines one, not an `A::Base` action,
|
|
260
|
+
and a file's own `Legacy::ApplicationAction` shadows the listed one. Any
|
|
261
|
+
other class is left alone, so a dry-transaction
|
|
262
|
+
(`include Dry::Transaction` with its own `step`) gets no false positives, and
|
|
263
|
+
neither does a class that reopens an action without naming its superclass
|
|
264
|
+
(the first-run check still covers it). Name your app's own base classes:
|
|
265
|
+
|
|
266
|
+
```yaml
|
|
267
|
+
Actionable/VerbNotLast:
|
|
268
|
+
ActionBaseClasses:
|
|
269
|
+
- Actionable::Action
|
|
270
|
+
- ApplicationAction
|
|
271
|
+
- Billing::BaseAction
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The list replaces the default, so keep the two defaults in it if you need them.
|
|
275
|
+
|
|
276
|
+
## Declared failures
|
|
277
|
+
|
|
278
|
+
`fails(code, message = nil, **metadata)` declares one failure code an action can
|
|
279
|
+
end with. The keywords after the message are neutral per-code metadata: the gem
|
|
280
|
+
stores them, attaches them to the failure, and shows them in `describe`, but
|
|
281
|
+
never reads them. Your app gives the keys their meaning. A `fail` or `fail_with`
|
|
282
|
+
with a declared code carries its metadata as `result.metadata`, and its declared
|
|
283
|
+
message when the call gives none. `declared_failures` reads the declarations
|
|
284
|
+
back as `{code => metadata}`, and `failure_declarations` as
|
|
285
|
+
`{code => {message:, metadata:}}`:
|
|
286
|
+
|
|
287
|
+
<!-- doctest -->
|
|
288
|
+
```ruby
|
|
289
|
+
class Charge < Actionable::Action
|
|
290
|
+
fails :refused, 'the card was refused', kind: :rejected
|
|
291
|
+
fails :not_found, kind: :not_found
|
|
292
|
+
|
|
293
|
+
step :charge
|
|
294
|
+
def charge = fail!(:refused, nil, kind: 'is not a known kind')
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
result = Charge.run
|
|
298
|
+
result.code # => :refused
|
|
299
|
+
result.message # => "the card was refused"
|
|
300
|
+
result.metadata # => {kind: :rejected}
|
|
301
|
+
result.errors[:kind] # => ["is not a known kind"]
|
|
302
|
+
Charge.declared_failures # => {refused: {kind: :rejected}, not_found: {kind: :not_found}}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Nothing but `**errors` follows `fail`'s message, so an error on a field named
|
|
306
|
+
like a metadata key (`kind` above) stays an error. Declarations are inherited,
|
|
307
|
+
and a subclass can add more. Declaring `:invalid_input` or `:invalid_output`
|
|
308
|
+
attaches that metadata to the failures the gem builds itself. A failure whose
|
|
309
|
+
code isn't declared has `metadata == {}`, and `Failure#to_h` includes it.
|
|
310
|
+
|
|
311
|
+
**Strict once declared.** An action that declares at least one code may only fail
|
|
312
|
+
with codes it declares (`allows_failure_code?(code)` answers whether it may). Any other `fail` / `fail!` / `fail_with` / `fail_with!`
|
|
313
|
+
raises `Actionable::DefinitionError` from the call itself, naming the code and
|
|
314
|
+
the declared list:
|
|
315
|
+
|
|
316
|
+
<!-- doctest -->
|
|
317
|
+
```ruby
|
|
318
|
+
class Refund < Actionable::Action
|
|
319
|
+
fails :refused, kind: :rejected
|
|
320
|
+
step :go
|
|
321
|
+
def go = fail!(:timeout)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
begin
|
|
325
|
+
Refund.run
|
|
326
|
+
rescue Actionable::DefinitionError => e
|
|
327
|
+
e.message.include?('fails with :timeout, which it does not declare') # => true
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Two cases stay unchecked, so existing actions keep working unchanged:
|
|
332
|
+
|
|
333
|
+
- an action that declares no failures fails with any code, as before;
|
|
334
|
+
- the codes the gem emits itself, `Actionable::Action::GEM_FAILURE_CODES`
|
|
335
|
+
(`:invalid_input` and `:invalid_output`), are always allowed.
|
|
336
|
+
|
|
337
|
+
`fail_with`'s default code `:invalid` is not exempt: the action writes that
|
|
338
|
+
`fail_with`, so a declaring action declares `:invalid` or passes its own `code:`.
|
|
339
|
+
|
|
340
|
+
## Declared skips
|
|
341
|
+
|
|
342
|
+
`skips(code, message = nil, **metadata)` declares one skip code an action can end
|
|
343
|
+
with, so skips get the same contract as failures (decision D21). The metadata is
|
|
344
|
+
neutral, as with `fails`. A `skip` or `skip!` with a declared code
|
|
345
|
+
carries its metadata as `result.metadata`, and its declared message when the
|
|
346
|
+
call gives none. `declared_skips` reads the declarations back as
|
|
347
|
+
`{code => metadata}`, and `skip_declarations` as `{code => {message:, metadata:}}`:
|
|
348
|
+
|
|
349
|
+
<!-- doctest -->
|
|
350
|
+
```ruby
|
|
351
|
+
class SyncField < Actionable::Action
|
|
352
|
+
skips :not_ready, 'the field is not created yet', retry: true
|
|
353
|
+
skips :already_synced, retry: false
|
|
354
|
+
|
|
355
|
+
step :sync
|
|
356
|
+
def sync = skip!(:not_ready)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
result = SyncField.run
|
|
360
|
+
result.skipped? # => true
|
|
361
|
+
result.code # => :not_ready
|
|
362
|
+
result.message # => "the field is not created yet"
|
|
363
|
+
result.metadata # => {retry: true}
|
|
364
|
+
SyncField.declared_skips # => {not_ready: {retry: true}, already_synced: {retry: false}}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Declarations are inherited, and a subclass can add more. A skip whose code isn't
|
|
368
|
+
declared has `metadata == {}`, and `Skipped#to_h` includes it.
|
|
369
|
+
|
|
370
|
+
**Strict once declared.** An action that declares at least one skip code may only
|
|
371
|
+
skip with codes it declares (`allows_skip_code?(code)` answers whether it may).
|
|
372
|
+
Any other `skip` / `skip!` raises `Actionable::DefinitionError` from the call
|
|
373
|
+
itself. The gem emits no skip codes of its own, so nothing is exempt, and that
|
|
374
|
+
includes `skip`'s implicit default `:skipped`: a declaring action that calls a
|
|
375
|
+
bare `skip!` declares `:skipped` too.
|
|
376
|
+
|
|
377
|
+
<!-- doctest -->
|
|
378
|
+
```ruby
|
|
379
|
+
class Archive < Actionable::Action
|
|
380
|
+
skips :already_archived
|
|
381
|
+
step :go
|
|
382
|
+
def go = skip!
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
begin
|
|
386
|
+
Archive.run
|
|
387
|
+
rescue Actionable::DefinitionError => e
|
|
388
|
+
e.message.include?('skips with :skipped, which it does not declare') # => true
|
|
389
|
+
end
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
An action that declares no skips skips with any code, as before. Declared
|
|
393
|
+
failures and declared skips are independent, so declaring one never makes the
|
|
394
|
+
other strict.
|
|
395
|
+
|
|
396
|
+
## Typed outcomes
|
|
397
|
+
|
|
398
|
+
`typed_outcomes` gives a skip and a failure an output contract of their own,
|
|
399
|
+
the way `output` gives one to a success (decision D23). It is opt-in and
|
|
400
|
+
inherited, so a host base class can turn it on for every action beneath it;
|
|
401
|
+
`typed_outcomes?` reads it, and `typed_outcomes enabled: false` turns it off
|
|
402
|
+
again for a subclass.
|
|
403
|
+
|
|
404
|
+
A block on `skips` or `fails` declares the output a result with that code
|
|
405
|
+
carries, in the same FieldStruct DSL as `output`. `skip_output_schema(code)` and
|
|
406
|
+
`failure_output_schema(code)` read it back, and `describe` shows its fields.
|
|
407
|
+
Under typed outcomes, a failure takes its output from the `output:` keyword of
|
|
408
|
+
`fail` / `fail!` (and of `fail_with` / `fail_with!`); every other keyword is
|
|
409
|
+
still an error:
|
|
410
|
+
|
|
411
|
+
<!-- doctest -->
|
|
412
|
+
```ruby
|
|
413
|
+
class ActivateKey < Actionable::Action
|
|
414
|
+
typed_outcomes
|
|
415
|
+
input { required :id, :integer }
|
|
416
|
+
output { required :id, :integer }
|
|
417
|
+
|
|
418
|
+
skips(:already_active) { required :id, :integer }
|
|
419
|
+
fails(:invalid) { optional :field, :string }
|
|
420
|
+
|
|
421
|
+
step :activate
|
|
422
|
+
|
|
423
|
+
def activate
|
|
424
|
+
return skip!(:already_active, nil, id: input.id) if input.id == 1
|
|
425
|
+
return fail!(:invalid, 'bad id', output: {field: 'id'}, id: 'is negative') if input.id.negative?
|
|
426
|
+
|
|
427
|
+
@id = input.id
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
skipped = ActivateKey.run(id: 1)
|
|
432
|
+
skipped.class # => ActivateKey::Skipped
|
|
433
|
+
skipped.id # => 1
|
|
434
|
+
failed = ActivateKey.run(id: -1)
|
|
435
|
+
failed.class # => ActivateKey::Failure
|
|
436
|
+
failed.output.field # => "id"
|
|
437
|
+
failed.errors[:id] # => ["is negative"]
|
|
438
|
+
ActivateKey.run(id: 2).class # => ActivateKey::Success
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
The rules:
|
|
442
|
+
|
|
443
|
+
- **A code with a schema** is built from the keywords it was recorded with,
|
|
444
|
+
never from instance variables, and validated like a success: output that
|
|
445
|
+
breaks the schema ends the run as `invalid_output`. This holds whether or not
|
|
446
|
+
`typed_outcomes` is on, since the block itself is the opt-in for that code.
|
|
447
|
+
So the block also ends instance-variable capture for that code: after
|
|
448
|
+
`@note = 'x'; skip!(:dup)`, a `skips(:dup) { required :note, :string }`
|
|
449
|
+
ends the run as `invalid_output`, because `note:` was never passed.
|
|
450
|
+
- **A code without one** carries an empty output under typed outcomes. Without
|
|
451
|
+
them it keeps the old best-effort capture into the success schema. The
|
|
452
|
+
failures the gem builds (`invalid_input`, `invalid_output`) keep their
|
|
453
|
+
output either way, because the invalid struct is what explains them.
|
|
454
|
+
- **Output with nowhere to go is refused.** Under typed outcomes, output keywords
|
|
455
|
+
on a code without a schema raise `Actionable::DefinitionError`, naming the
|
|
456
|
+
code and asking for a `fails :code do … end` (or `skips`) schema. `output:`
|
|
457
|
+
must be a Hash or a FieldStruct; anything else raises `ArgumentError`.
|
|
458
|
+
- **Result classes.** `.run` returns the action's own `Success`, `Failure` or
|
|
459
|
+
`Skipped`, defined inside it. Each is a subclass of the gem class, or of the
|
|
460
|
+
parent action's own for a subclass, so `result.is_a?(Actionable::Failure)`
|
|
461
|
+
still holds. An action that already has its own constant by one of those names
|
|
462
|
+
is refused. A host's own result subclass is returned as it is, which the
|
|
463
|
+
generated RBS does not describe: it still types `.run` as the action's own
|
|
464
|
+
classes, a known gap.
|
|
465
|
+
- **Nested failures.** A child's failure that crosses becomes the parent's own
|
|
466
|
+
`Failure`. Its output is judged by the schema the *parent* declares for the
|
|
467
|
+
code, and the parent must still declare the code. A parent without typed
|
|
468
|
+
outcomes gets a plain `Actionable::Failure`, never the typed child's class.
|
|
469
|
+
The gem's own `invalid_input` / `invalid_output` cross with their output and
|
|
470
|
+
are not judged by a parent schema.
|
|
471
|
+
- **Stubs.** The RSpec stub helpers return the action's own result classes too,
|
|
472
|
+
with the output shaped as a real run would shape it: built through the code's
|
|
473
|
+
schema (an `ArgumentError` when it breaks the schema, since a real run would
|
|
474
|
+
end as `invalid_output` instead), and empty for a code without one (output
|
|
475
|
+
given for such a code raises `ArgumentError`).
|
|
476
|
+
- **RBS.** `Actionable::RBS.generate` types `.run` as
|
|
477
|
+
`(Success | Skipped | Failure)`, with each `code` and `output` narrowed to
|
|
478
|
+
what that outcome can carry (see [RBS for your actions](#rbs-for-your-actions)).
|
|
479
|
+
The readers a result delegates to its output are declared on `Skipped` and
|
|
480
|
+
`Failure` too.
|
|
481
|
+
|
|
482
|
+
Two things change for a spec when an action turns typed outcomes on. Results
|
|
483
|
+
compare by class, so `ActivateKey.run(id: 2) == Actionable::Success.new(...)` is
|
|
484
|
+
false even with the same output; compare with `ActivateKey::Success`, or use
|
|
485
|
+
`be_success` and the output. And the `allow_actionable_*` / `stub_actionable_*`
|
|
486
|
+
helpers return a new object of the action's own class, not one you built with
|
|
487
|
+
`actionable_failure_result`, so assert on what the helper returns.
|
|
488
|
+
|
|
489
|
+
## Output
|
|
490
|
+
|
|
491
|
+
`output do … end` declares a typed schema. Matching ivars are captured at the
|
|
492
|
+
end of the run; `succeed`'s keyword arguments overlay them (kwargs win):
|
|
493
|
+
|
|
494
|
+
<!-- doctest -->
|
|
495
|
+
```ruby
|
|
496
|
+
class Calc < Actionable::Action
|
|
497
|
+
output { optional :total, :integer }
|
|
498
|
+
step :compute
|
|
499
|
+
step :finish
|
|
500
|
+
def compute = @total = 1
|
|
501
|
+
def finish = succeed(total: 99) # overrides the captured ivar
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
Calc.run.output.total # => 99
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Only a **strict success** is validated. If a success can't satisfy its declared
|
|
508
|
+
output, the run becomes a `Failure(:invalid_output)` carrying the errors; a
|
|
509
|
+
failed or skipped run captures output best-effort and is never validated.
|
|
510
|
+
|
|
511
|
+
<!-- doctest -->
|
|
512
|
+
```ruby
|
|
513
|
+
class NeedsTotal < Actionable::Action
|
|
514
|
+
output { required :total, :integer }
|
|
515
|
+
step :noop
|
|
516
|
+
def noop = nil # never sets @total
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
result = NeedsTotal.run
|
|
520
|
+
result.failure? # => true
|
|
521
|
+
result.code # => :invalid_output
|
|
522
|
+
result.errors[:total] # => ["is required"]
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
`errors` is FieldStruct's own collection, so alongside the per-field
|
|
526
|
+
`errors[:field]` / `errors.to_h` it answers `full_messages` — each message
|
|
527
|
+
rendered as a complete sentence with the humanized field name prepended
|
|
528
|
+
(`:base` messages pass through unprefixed):
|
|
529
|
+
|
|
530
|
+
<!-- doctest -->
|
|
531
|
+
```ruby
|
|
532
|
+
result = Actionable::Failure.new(code: :invalid)
|
|
533
|
+
result.errors.add(:first_name, 'is required')
|
|
534
|
+
|
|
535
|
+
result.errors.full_messages # => ["First name is required"]
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Declared output fields delegate off the result (`result.total` →
|
|
539
|
+
`result.output.total`), except names that collide with the result's own
|
|
540
|
+
attributes (`code`/`message`/`output`/`history`/`errors`).
|
|
541
|
+
|
|
542
|
+
## Input
|
|
543
|
+
|
|
544
|
+
`input do … end` coerces `.run`'s keyword arguments into a typed struct and
|
|
545
|
+
exposes it as `input`:
|
|
546
|
+
|
|
547
|
+
<!-- doctest -->
|
|
548
|
+
```ruby
|
|
549
|
+
class Double < Actionable::Action
|
|
550
|
+
input { required :n, :integer }
|
|
551
|
+
output { required :doubled, :integer }
|
|
552
|
+
step :go
|
|
553
|
+
def go = @doubled = input.n * 2
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
Double.run(n: '21').output.doubled # => 42
|
|
557
|
+
Double.run.code # => :invalid_input
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
A missing or uncoercible required input yields `Failure(:invalid_input)` without
|
|
561
|
+
running any steps. `input` with no block reads the declared schema, like `output`. An already-built input instance can be passed instead of
|
|
562
|
+
keyword arguments. With no `input` block, `.run`'s arguments go to the
|
|
563
|
+
constructor (the free-form path).
|
|
564
|
+
|
|
565
|
+
### Discriminated input
|
|
566
|
+
|
|
567
|
+
`input_for(discriminator) { … }` picks the input schema at run time from a
|
|
568
|
+
discriminator value — typed input for a polymorphic payload. Branches use the
|
|
569
|
+
same matching as `case_step` (`==`, Array membership, `Regexp`); an unmatched
|
|
570
|
+
value with no `default` short-circuits to `Failure(:invalid_input)`.
|
|
571
|
+
|
|
572
|
+
<!-- doctest -->
|
|
573
|
+
```ruby
|
|
574
|
+
SaleShape = Class.new(FieldStruct::Base) { required :amount, :integer }
|
|
575
|
+
RefundShape = Class.new(FieldStruct::Base) { required :original_id, :integer }
|
|
576
|
+
|
|
577
|
+
class Ingest < Actionable::Action
|
|
578
|
+
input_for :event_type do
|
|
579
|
+
on 'sale', SaleShape
|
|
580
|
+
on 'refund', RefundShape
|
|
581
|
+
end
|
|
582
|
+
output { optional :amount, :integer }
|
|
583
|
+
step :capture
|
|
584
|
+
def capture
|
|
585
|
+
@amount = input.amount if input.respond_to?(:amount)
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
Ingest.run(event_type: 'sale', amount: '5').output.amount # => 5
|
|
590
|
+
Ingest.run(event_type: 'wat').code # => :invalid_input
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
## Batch runs
|
|
594
|
+
|
|
595
|
+
`Action.run_each(enumerable) { |item| run_args }` runs the action once per item
|
|
596
|
+
and collects the per-item results into a `BatchResult` (Enumerable). Each item
|
|
597
|
+
runs independently, so one failure never affects another. The block maps an item
|
|
598
|
+
to `.run` arguments (a Hash → kwargs, an Array → positional, anything else → a
|
|
599
|
+
single argument); omit it to pass each item directly.
|
|
600
|
+
|
|
601
|
+
<!-- doctest -->
|
|
602
|
+
```ruby
|
|
603
|
+
class Doubler < Actionable::Action
|
|
604
|
+
input { required :n, :integer }
|
|
605
|
+
output { required :doubled, :integer }
|
|
606
|
+
step :go
|
|
607
|
+
def go = @doubled = input.n * 2
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
batch = Doubler.run_each([1, 'x', 3]) { |n| {n: n} } # 'x' -> :invalid_input
|
|
611
|
+
batch.size # => 3
|
|
612
|
+
batch.all_ok? # => false
|
|
613
|
+
batch.any_failure? # => true
|
|
614
|
+
batch.partial? # => true
|
|
615
|
+
batch.successes.map { |r| r.output.doubled } # => [2, 6]
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
`BatchResult` answers `all_ok?` / `any_failure?` / `partial?`, exposes
|
|
619
|
+
`successes` / `failures` / `skips`, and `to_api_h` renders the whole batch as an
|
|
620
|
+
array of indexed API elements.
|
|
621
|
+
|
|
622
|
+
## Lifecycle hooks
|
|
623
|
+
|
|
624
|
+
```ruby
|
|
625
|
+
class Flow < Actionable::Action
|
|
626
|
+
step :work
|
|
627
|
+
on_success :notify # runs only on a success
|
|
628
|
+
on_failure :rollback # runs only on a failure
|
|
629
|
+
on_skip :record # runs only on a skip
|
|
630
|
+
always :audit # runs regardless, after the outcome hook
|
|
631
|
+
end
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
Dispatch is by the post-main outcome and does not re-dispatch. Hooks may use the
|
|
635
|
+
control-flow verbs. A bang verb in a hook (`fail!`, `skip!`, `succeed!`) records
|
|
636
|
+
its result *and* stops the remaining hooks of its phase, so
|
|
637
|
+
`fail!(:verification_failed)` in `on_success` makes the run a `Failure` (and
|
|
638
|
+
rolls back a `transactional` action); `halt!` stops them and keeps the current
|
|
639
|
+
result. A nested action used as a hook that fails counts as a `fail!`.
|
|
640
|
+
|
|
641
|
+
The outcome hooks and the `always` hooks are separate phases, so `always` runs
|
|
642
|
+
even after an outcome hook halts, and sees the adopted result:
|
|
643
|
+
|
|
644
|
+
| A halt in… | Still runs | Final result |
|
|
645
|
+
|---|---|---|
|
|
646
|
+
| an outcome hook (`on_success` / `on_failure` / `on_skip`) | every `always` hook (not the rest of that outcome category) | the halting hook's |
|
|
647
|
+
| an `always` hook | nothing (the rest of the `always` hooks are skipped) | the halting hook's |
|
|
648
|
+
|
|
649
|
+
`always` hooks run inside the `transactional` transaction like the other hooks,
|
|
650
|
+
and appear in the history when measuring. Output is re-captured and
|
|
651
|
+
re-validated after each hook, and `succeed`/`skip` kwargs keep winning over
|
|
652
|
+
ivars on every refresh.
|
|
653
|
+
|
|
654
|
+
For side effects that must wait until the database work really committed (an
|
|
655
|
+
email, a webhook, an enqueue), the Rails adapter adds an `after_commit` hook
|
|
656
|
+
kind — see [Rails adapter](#rails-adapter).
|
|
657
|
+
|
|
658
|
+
## Nested actions
|
|
659
|
+
|
|
660
|
+
```ruby
|
|
661
|
+
step Notify, input: %i[invoice], expose: %i[receipt]
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
`:input` names parent ivars to pass to the child's `.run`; `:expose` limits which
|
|
665
|
+
child outputs are absorbed back as parent ivars (default: all). Only fields the
|
|
666
|
+
child actually set (an ivar it assigned or a `succeed`/`skip` kwarg) are
|
|
667
|
+
absorbed, so a field it left nil or at its default never overwrites the
|
|
668
|
+
parent's value. A child failure
|
|
669
|
+
fails the parent and halts; a child skip continues the parent like a success.
|
|
670
|
+
|
|
671
|
+
The child's failure crosses intact: its code, message, every error (whatever the
|
|
672
|
+
field is called), its `metadata`, and its result class. It doesn't go through
|
|
673
|
+
the parent's `fail`, so an override of `fail` in a host base class doesn't see
|
|
674
|
+
it. The parent's own declaration for that code fills in metadata keys the child
|
|
675
|
+
didn't set. A parent that declares failures must declare the codes its children
|
|
676
|
+
can fail with, since they become its own.
|
|
677
|
+
|
|
678
|
+
A child skip never becomes the parent's outcome (decision D17), so a parent that
|
|
679
|
+
declares skips does not declare its children's skip codes. The child's skip and
|
|
680
|
+
its metadata stay on the child's result.
|
|
681
|
+
|
|
682
|
+
## Case steps
|
|
683
|
+
|
|
684
|
+
<!-- doctest -->
|
|
685
|
+
```ruby
|
|
686
|
+
class Branch < Actionable::Action
|
|
687
|
+
input { required :kind, :string }
|
|
688
|
+
output { optional :picked, :string }
|
|
689
|
+
def kind = input.kind
|
|
690
|
+
|
|
691
|
+
case_step :kind do
|
|
692
|
+
on 'a', :pick_a
|
|
693
|
+
on %w[b c], :pick_bc # Array membership
|
|
694
|
+
on(/\Az/, :pick_z) # Regexp
|
|
695
|
+
default :pick_default
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def pick_a = @picked = 'a'
|
|
699
|
+
def pick_bc = @picked = 'bc'
|
|
700
|
+
def pick_z = @picked = 'z'
|
|
701
|
+
def pick_default = @picked = 'default'
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
Branch.run(kind: 'b').output.picked # => "bc"
|
|
705
|
+
Branch.run(kind: 'zed').output.picked # => "z"
|
|
706
|
+
Branch.run(kind: 'x').output.picked # => "default"
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
A branch target is any step target (method or nested action). When nothing
|
|
710
|
+
matches and there's no `default`, the case step is a no-op.
|
|
711
|
+
|
|
712
|
+
A branch (or the `default`) takes `if:` / `unless:` like any step. The branch
|
|
713
|
+
is picked by value alone; its guard then decides whether the target runs. A
|
|
714
|
+
matched branch whose guard opts out does nothing: it does not fall through to
|
|
715
|
+
the next branch or the `default`.
|
|
716
|
+
|
|
717
|
+
<!-- doctest -->
|
|
718
|
+
```ruby
|
|
719
|
+
class Notify < Actionable::Action
|
|
720
|
+
input { required :kind, :string }
|
|
721
|
+
output { optional :sent, :string }
|
|
722
|
+
def kind = input.kind
|
|
723
|
+
def sms_enabled? = false
|
|
724
|
+
|
|
725
|
+
case_step :kind do
|
|
726
|
+
on 'sms', :send_sms, if: :sms_enabled?
|
|
727
|
+
default :send_email
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
def send_sms = @sent = 'sms'
|
|
731
|
+
def send_email = @sent = 'email'
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
Notify.run(kind: 'sms').output.sent # => nil (guarded out, no fall-through)
|
|
735
|
+
Notify.run(kind: 'push').output.sent # => "email"
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
## History & measurement
|
|
739
|
+
|
|
740
|
+
<!-- doctest -->
|
|
741
|
+
```ruby
|
|
742
|
+
class Timed < Actionable::Action
|
|
743
|
+
measure :all
|
|
744
|
+
step :work
|
|
745
|
+
def work = nil
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
history = Timed.run.history
|
|
749
|
+
history.steps.map(&:name) # => [:work]
|
|
750
|
+
history.steps.first.section # => :main
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
`measure :none` (the default) records nothing. `measure :sampled, rate: 0.1`
|
|
754
|
+
records a fraction of runs (rate between 0 and 1) for low-overhead production
|
|
755
|
+
observability — when a run is sampled in, it measures fully and cascades into its
|
|
756
|
+
nested actions. A measuring run cascades into the nested actions it invokes.
|
|
757
|
+
`History#took` sums step durations; `History#to_json` serializes via Oj.
|
|
758
|
+
Measurement state is fiber-local: a nested action run inside another Fiber
|
|
759
|
+
(including `Enumerator#next`) is not recorded in the parent's history.
|
|
760
|
+
|
|
761
|
+
<!-- doctest -->
|
|
762
|
+
```ruby
|
|
763
|
+
class Sampled < Actionable::Action
|
|
764
|
+
measure :sampled, rate: 1.0 # always, for a deterministic example
|
|
765
|
+
step :work
|
|
766
|
+
def work = nil
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
Sampled.run.history.steps.map(&:name) # => [:work]
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
## Registry
|
|
773
|
+
|
|
774
|
+
Named action subclasses register themselves, keyed by class name:
|
|
775
|
+
|
|
776
|
+
<!-- doctest -->
|
|
777
|
+
```ruby
|
|
778
|
+
class Registered < Actionable::Action
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
Actionable.registry[Registered.name].equal?(Registered) # => true
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
`Actionable.registry` is read-only for callers: `[]`, `each`, `keys`, `values`,
|
|
785
|
+
`size`, `empty?`.
|
|
786
|
+
|
|
787
|
+
## Introspection — `Action.describe`
|
|
788
|
+
|
|
789
|
+
A structured, at-a-glance summary of an action (name, input/output field
|
|
790
|
+
metadata, ordered steps with type, declared failures and skips, hooks, measure
|
|
791
|
+
mode, transaction config) so humans and agents can understand it without reading
|
|
792
|
+
source. `describe[:failures]` and `describe[:skips]` each map a declared code to
|
|
793
|
+
`{message:, metadata:}`:
|
|
794
|
+
|
|
795
|
+
<!-- doctest -->
|
|
796
|
+
```ruby
|
|
797
|
+
class Probe < Actionable::Action
|
|
798
|
+
output { required :count, :integer }
|
|
799
|
+
step :compute
|
|
800
|
+
def compute = @count = 1
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
Probe.describe[:steps].first[:type] # => :method
|
|
804
|
+
Probe.describe.key?(:output) # => true
|
|
805
|
+
Probe.describe[:measure] # => :none
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
`Action.describe_text` renders the same information as a human/agent-readable
|
|
809
|
+
multi-line summary instead of a Hash. The input/output field lines reuse
|
|
810
|
+
FieldStruct's own `Metadata#describe` (each field's type, required-ness, and the
|
|
811
|
+
options its type accepts). Declared failures, declared skips and hook sections
|
|
812
|
+
are listed only when there are any:
|
|
813
|
+
|
|
814
|
+
```
|
|
815
|
+
Probe (measure: none)
|
|
816
|
+
Input: (free-form)
|
|
817
|
+
Output:
|
|
818
|
+
count (Integer, required) — accepts in (Array | Range)
|
|
819
|
+
Steps:
|
|
820
|
+
- compute (method)
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
With `fails :refused, 'the card was refused', kind: :rejected` declared, a
|
|
824
|
+
`Failures:` section follows the steps:
|
|
825
|
+
|
|
826
|
+
```
|
|
827
|
+
Failures:
|
|
828
|
+
- refused (kind: :rejected): the card was refused
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
With `skips :not_ready, 'the field is not created yet', retry: true` declared, a
|
|
832
|
+
`Skips:` section follows the failures:
|
|
833
|
+
|
|
834
|
+
```
|
|
835
|
+
Skips:
|
|
836
|
+
- not_ready (retry: true): the field is not created yet
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
## Serializing a result
|
|
840
|
+
|
|
841
|
+
`Result#to_h` is a plain-Hash view (`code`, `message`, `output`/`history`
|
|
842
|
+
rendered to primitives, `errors` as a Hash). `Result#to_api_h(index:)` is a
|
|
843
|
+
compact element for an HTTP/batch response — position, `status`
|
|
844
|
+
(`:success`/`:failure`/`:skipped`), the `id` read off the typed output, and
|
|
845
|
+
`errors` — so a controller doesn't hand-map the shape:
|
|
846
|
+
|
|
847
|
+
<!-- doctest -->
|
|
848
|
+
```ruby
|
|
849
|
+
class MakeWidget < Actionable::Action
|
|
850
|
+
output { required :id, :integer }
|
|
851
|
+
step :build
|
|
852
|
+
def build = @id = 99
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
result = MakeWidget.run
|
|
856
|
+
result.status # => :success
|
|
857
|
+
result.to_h[:output] # => {:id=>99}
|
|
858
|
+
result.to_api_h(index: 0) # => {:index=>0, :status=>:success, :id=>99, :errors=>{}}
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
## Guardrails (`DefinitionError`)
|
|
862
|
+
|
|
863
|
+
Misconfigured actions fail loudly with `Actionable::DefinitionError` instead of a
|
|
864
|
+
cryptic `NoMethodError`:
|
|
865
|
+
|
|
866
|
+
- **Reserved output field names (at declaration):** an `output` field that
|
|
867
|
+
shadows a result attribute (`code` / `message` / `output` / `history` /
|
|
868
|
+
`errors` / `metadata`) or a reserved instance variable (`result` / `input`) raises
|
|
869
|
+
immediately. Pick another name. (Input fields aren't affected — they're read
|
|
870
|
+
via `input.x`.)
|
|
871
|
+
- **Undeclared failure codes (at the `fail` call):** once an action declares a
|
|
872
|
+
failure with `fails`, failing with a code it doesn't declare raises. See
|
|
873
|
+
[Declared failures](#declared-failures).
|
|
874
|
+
- **Undeclared skip codes (at the `skip` call):** once an action declares a skip
|
|
875
|
+
with `skips`, skipping with a code it doesn't declare raises, the default
|
|
876
|
+
`:skipped` included. See [Declared skips](#declared-skips).
|
|
877
|
+
- **Missing step methods (at run start):** every method a step needs (method
|
|
878
|
+
steps, case value sources and method branch targets, hook steps) must be
|
|
879
|
+
implemented, or the run raises a `DefinitionError` naming the action and the
|
|
880
|
+
missing method.
|
|
881
|
+
- **A plain verb with code after it (at first run):** a `fail` / `fail_with` /
|
|
882
|
+
`skip` / `succeed` that isn't its step or hook method's last expression raises a
|
|
883
|
+
`DefinitionError` naming the action, method, `file:line` and verb. See
|
|
884
|
+
[Where a plain verb may go](#where-a-plain-verb-may-go).
|
|
885
|
+
- **Sentence codes (at the `fail` / `skip` call):** a code that isn't an
|
|
886
|
+
identifier raises `ArgumentError`. See [Control flow](#control-flow).
|
|
887
|
+
|
|
888
|
+
## Rails adapter
|
|
889
|
+
|
|
890
|
+
`require 'actionable/rails'` (never loaded by the core). CI runs it on
|
|
891
|
+
ActiveRecord 7.1 and 8.x; only `after_commit` needs 7.2:
|
|
892
|
+
|
|
893
|
+
- `transactional model: :invoice` (or a class) wraps the run in
|
|
894
|
+
`model.transaction`. A success commits; a recorded failure or a raised
|
|
895
|
+
exception rolls back; a skip commits. Inside a transaction that is already
|
|
896
|
+
open, the action takes its own savepoint (`requires_new: true`), so its failed
|
|
897
|
+
writes roll back without the outer transaction committing them; pass
|
|
898
|
+
`requires_new: false` to join the outer one instead. Lifecycle hooks run inside the
|
|
899
|
+
transaction, so a hook's writes share its fate and its external side effects
|
|
900
|
+
fire before the commit.
|
|
901
|
+
- `after_commit :method` (or a nested action: `after_commit Notify, input: %i[order]`,
|
|
902
|
+
with `:if` / `:unless` guards like any hook) runs a side effect only after the
|
|
903
|
+
database work really committed. Needs ActiveRecord >= 7.2 (it is built on
|
|
904
|
+
`ActiveRecord.after_all_transactions_commit`); declaring it on an older
|
|
905
|
+
ActiveRecord, or with ActiveRecord not loaded, raises a `DefinitionError`.
|
|
906
|
+
Existing hooks are unchanged. Inherited like the other hooks. `describe`
|
|
907
|
+
lists them under `hooks[:after_commit]`, and `describe_text` adds an
|
|
908
|
+
`after_commit:` line when there are any.
|
|
909
|
+
|
|
910
|
+
| Scenario | Runs? | When |
|
|
911
|
+
|---|---|---|
|
|
912
|
+
| `transactional` action succeeds at the top level | yes | right after its transaction commits, before `.run` returns |
|
|
913
|
+
| succeeds inside an outer `Model.transaction` | yes | after the **outermost** transaction commits, not at the action's savepoint |
|
|
914
|
+
| succeeds, then an enclosing transaction rolls back | no | — |
|
|
915
|
+
| the run ends in a `Failure` | no | its writes rolled back, so nothing committed |
|
|
916
|
+
| the run ends in a `Skipped` | no | a skip's writes still commit, but there was nothing to do, so no side effect |
|
|
917
|
+
| non-transactional action, no transaction open | yes | immediately after the run, before `.run` returns |
|
|
918
|
+
| nested actions, each with `after_commit` | each once, child first | after the outermost commit; a child's hook is dropped if a transactional parent then fails |
|
|
919
|
+
|
|
920
|
+
The run is already over when the hook fires: it can't change the result (a
|
|
921
|
+
halting verb only stops the remaining `after_commit` hooks) and it is not
|
|
922
|
+
recorded in the history. An exception it raises propagates (decision D4) from
|
|
923
|
+
wherever the commit happened — the outer `transaction` call, or `.run` when no
|
|
924
|
+
transaction was open — after the writes are committed.
|
|
925
|
+
- `Actionable::ProxyValidator` runs ActiveModel rules against a delegate object;
|
|
926
|
+
`#formatted_errors` returns a hash ready to splat into `fail`.
|
|
927
|
+
|
|
928
|
+
## RSpec integration
|
|
929
|
+
|
|
930
|
+
`require 'actionable/rspec'`:
|
|
931
|
+
|
|
932
|
+
- `perform_actionable(*args, **kwargs)` with `.and_succeed(message)`,
|
|
933
|
+
`.and_fail(code, message)`, `.and_skip(code, message)`,
|
|
934
|
+
`.and_raise(error_class, message)`, and an optional block yielding
|
|
935
|
+
`(result, exception)`.
|
|
936
|
+
- `allow_actionable_success` / `stub_actionable_success` (and `_failure` /
|
|
937
|
+
`_skip` variants) build real result objects so `.run` returns them without
|
|
938
|
+
executing the action. `actionable_failure_result(code:, message:, output:,
|
|
939
|
+
errors:, metadata:)` and `actionable_skip_result(code:, message:, output:,
|
|
940
|
+
metadata:)` build a failure or a skip without stubbing anything.
|
|
941
|
+
|
|
942
|
+
On an action that declares failures, the RSpec support holds to the
|
|
943
|
+
declarations. A stubbed failure carries the declared metadata for its code (and
|
|
944
|
+
the declared message when you give none), and a stub or `and_fail` with a code
|
|
945
|
+
the action doesn't declare raises `ArgumentError`, since it would test a
|
|
946
|
+
failure the action can never return. The exemptions match the runtime: actions
|
|
947
|
+
that declare none, and `GEM_FAILURE_CODES`.
|
|
948
|
+
|
|
949
|
+
Skips work the same way on an action that declares skips. A stubbed skip
|
|
950
|
+
carries the declared metadata and message for its code, and a stub or
|
|
951
|
+
`and_skip` with an undeclared code raises `ArgumentError`. As at runtime, the
|
|
952
|
+
default `:skipped` is not exempt, so `allow_actionable_skip(action)` with no
|
|
953
|
+
`code:` raises unless the action declares `:skipped`. A bare `and_skip` names no
|
|
954
|
+
code and is always accepted.
|
|
955
|
+
|
|
956
|
+
## Outcome coverage
|
|
957
|
+
|
|
958
|
+
`require 'actionable/rspec/coverage'` (decision D22) records every real `.run`
|
|
959
|
+
of a named action as `[class name, outcome, code]` and, at the end of the
|
|
960
|
+
suite, reports each action's outcomes that no spec reached. It is off unless
|
|
961
|
+
required, and `actionable/rspec` does not load it.
|
|
962
|
+
|
|
963
|
+
An action's expected outcomes come from its declarations at run time, as
|
|
964
|
+
outcome keys. `"success"` is always expected, plus `"failure:<code>"` for each
|
|
965
|
+
`declared_failures` code and `"skipped:<code>"` for each `declared_skips` code.
|
|
966
|
+
`Coverage.expected_outcomes(SomeAction)` lists them.
|
|
967
|
+
|
|
968
|
+
A run counts only when the action really ran. Nested `Steps::Action` runs and
|
|
969
|
+
each `run_each` item count. A stub of `.run` replaces the recording, so a
|
|
970
|
+
stubbed result never counts as tested; the gem's stub helpers note their runs
|
|
971
|
+
in `Coverage.stubbed`, which the report shows as "Stubbed only".
|
|
972
|
+
|
|
973
|
+
```ruby
|
|
974
|
+
# spec/support/actionable_coverage.rb
|
|
975
|
+
require 'actionable/rspec/coverage'
|
|
976
|
+
|
|
977
|
+
Actionable::Coverage.loader = -> { Rails.application.eager_load! }
|
|
978
|
+
Actionable::Coverage.baseline = 'spec/actionable_coverage_baseline.yml'
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
Settings, all on `Actionable::Coverage`:
|
|
982
|
+
|
|
983
|
+
| Setting | Default | Meaning |
|
|
984
|
+
|---|---|---|
|
|
985
|
+
| `root` | `Actionable::Action` | Report its named descendants that have steps. Anonymous classes and actions with no steps (abstract bases) are left out, and so is a class whose name no longer resolves to it. |
|
|
986
|
+
| `include_prefixes` / `exclude_prefixes` | `[]` | Name filters. A String matches as a prefix, a Regexp by `match?`. |
|
|
987
|
+
| `loader` | `nil` | Called before the actions are listed. A class that was never loaded can't be reported, so a Rails host eager-loads here. |
|
|
988
|
+
| `gem_codes` | `[]` | Gem codes to expect too. `:invalid_input` counts for an action whose input has a required field, `:invalid_output` for one whose output does. |
|
|
989
|
+
| `only_observed` | `false` | Report only actions with at least one real run, so a targeted run doesn't flag every action it never touched. |
|
|
990
|
+
| `baseline` | `nil` | Path to a committed JSON or YAML baseline of known gaps (below). |
|
|
991
|
+
| `new_gap_hint` | `DEFAULT_NEW_GAP_HINT` | The advice after "New untested outcomes." in the mismatch message. The default, `'Write a spec that reaches each one, or add it to the baseline:'`, offers both fixes; a host whose baseline only shrinks sets its own. |
|
|
992
|
+
|
|
993
|
+
`reset_config!` restores the defaults; `enable!`, `disable!`, `enabled?` and
|
|
994
|
+
`reset!` control the recording, and `observed` holds the real runs.
|
|
995
|
+
|
|
996
|
+
The report (`Actionable::Coverage::Report`) is a table of each action's
|
|
997
|
+
never-reached outcomes followed by the totals:
|
|
998
|
+
|
|
999
|
+
```text
|
|
1000
|
+
Actionable outcome coverage
|
|
1001
|
+
Action Never reached Stubbed only
|
|
1002
|
+
Billing::Charge failure:refused, failure:not_found failure:refused
|
|
1003
|
+
Billing::Refund success
|
|
1004
|
+
Actions: 2 Expected outcomes: 4 Reached: 1 Missing: 3
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
`rows`, `totals`, `to_h` and `to_json` give the same data to a machine, and
|
|
1008
|
+
`ACTIONABLE_COVERAGE_JSON=path` writes the JSON next to the text.
|
|
1009
|
+
|
|
1010
|
+
**The baseline.** Missing outcomes alone never fail a suite. With
|
|
1011
|
+
`Coverage.baseline` set to a file of `{"Action::Name" => [outcome keys]}`,
|
|
1012
|
+
the report fails in two cases, and its message lists each entry to change:
|
|
1013
|
+
|
|
1014
|
+
- an untested outcome the baseline doesn't list (a new gap: write the spec,
|
|
1015
|
+
or add the entry);
|
|
1016
|
+
- a baseline entry that is now covered, is no longer declared, or names an
|
|
1017
|
+
action that no longer exists (remove the entry).
|
|
1018
|
+
|
|
1019
|
+
`Report#to_baseline` returns the current gaps in that shape and
|
|
1020
|
+
`#write_baseline(path)` writes them, as YAML for a `.yml`/`.yaml` path and JSON
|
|
1021
|
+
otherwise. Running the report with `ACTIONABLE_COVERAGE_UPDATE_BASELINE=true`
|
|
1022
|
+
rewrites `Coverage.baseline` from the current gaps instead of checking it,
|
|
1023
|
+
which is how a host adopts the baseline and how it accepts a deliberate change.
|
|
1024
|
+
|
|
1025
|
+
**Unsharded suites.** The `after(:suite)` hook prints the report and, when it
|
|
1026
|
+
disagrees with the baseline, raises `Coverage::BaselineMismatch`, which fails
|
|
1027
|
+
the run.
|
|
1028
|
+
|
|
1029
|
+
**Sharded suites.** When `ACTIONABLE_COVERAGE_FILE` names a path, the hook
|
|
1030
|
+
writes that process's runs there (`Coverage.write(path)`) and reports nothing,
|
|
1031
|
+
since one shard can't judge the whole suite. Each rspec process needs its own
|
|
1032
|
+
path. A later step merges the files with the expected outcomes computed in
|
|
1033
|
+
that process: `Coverage.report!(files: 'tmp/actionable_coverage/*.json')`, or
|
|
1034
|
+
`Report.new(files:)` for the report alone. A glob that matches no file raises
|
|
1035
|
+
rather than reporting every outcome missing, and `Coverage.read(path)` reads
|
|
1036
|
+
one file back. From a Rails CI step:
|
|
1037
|
+
|
|
1038
|
+
```bash
|
|
1039
|
+
RAILS_ENV=test bin/rails runner 'require "./spec/support/actionable_coverage"; Actionable::Coverage.report!(files: "tmp/actionable_coverage/*.json")'
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
The baseline is only meaningful for a full run. A local or targeted run
|
|
1043
|
+
reports every action it didn't touch, so set `baseline` only in CI and, for
|
|
1044
|
+
local runs, `only_observed = true` (or require the file only when an env var
|
|
1045
|
+
asks for it).
|
|
1046
|
+
|
|
1047
|
+
## Background jobs
|
|
1048
|
+
|
|
1049
|
+
`require 'actionable/job'` — a framework-agnostic helper that runs an action
|
|
1050
|
+
inside a background job and maps its result to a queue disposition:
|
|
1051
|
+
`Actionable::Job.disposition(result)` returns `:ack` (a success or skip), `:retry`
|
|
1052
|
+
(a transient failure), or `:discard` (a failure whose code is in the
|
|
1053
|
+
non-retryable set, default `%i[invalid_input invalid_output not_found]`).
|
|
1054
|
+
|
|
1055
|
+
Include `Actionable::Job::Mixin` into your Sidekiq worker or ActiveJob job and
|
|
1056
|
+
call `run_actionable` from `perform`. It records `actionable_result`, returns for
|
|
1057
|
+
an ack/discard, and raises `Actionable::Job::RetryableFailure` for a retry so the
|
|
1058
|
+
queue applies its own backoff. A genuine exception inside the action propagates
|
|
1059
|
+
unchanged (the queue retries it):
|
|
1060
|
+
|
|
1061
|
+
```ruby
|
|
1062
|
+
require 'actionable/job'
|
|
1063
|
+
|
|
1064
|
+
class ProjectEventJob
|
|
1065
|
+
include Sidekiq::Job # or: ActiveJob::Base
|
|
1066
|
+
include Actionable::Job::Mixin
|
|
1067
|
+
|
|
1068
|
+
def perform(event_id)
|
|
1069
|
+
run_actionable(ProjectApiEvent, event_id: event_id)
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
# optional: customize which failure codes are permanent (not retried)
|
|
1073
|
+
def actionable_permanent_codes = %i[invalid_input not_found unprocessable]
|
|
1074
|
+
end
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
**Retry from declared metadata (opt-in).** Declared metadata is neutral by
|
|
1078
|
+
default (D20/D21): `skips :not_ready, retry: true` alone still acks. An action
|
|
1079
|
+
that calls `honor_retry_metadata` (added to `Actionable::Action` by
|
|
1080
|
+
`require 'actionable/job'`, so load the adapter before the action) lets the
|
|
1081
|
+
`retry:` key of its declared codes decide the disposition:
|
|
1082
|
+
|
|
1083
|
+
| Result | `retry: true` | `retry: false` | no `retry:` key |
|
|
1084
|
+
|---|---|---|---|
|
|
1085
|
+
| `Failure` | `:retry` (even a permanent code) | `:discard` (even a transient code) | default mapping |
|
|
1086
|
+
| `Skipped` | `:retry` | `:ack` | `:ack` |
|
|
1087
|
+
|
|
1088
|
+
`run_actionable` raises `RetryableFailure` for every `:retry`, a retried skip
|
|
1089
|
+
included (`e.result` is the `Skipped`), so the same queue configuration covers
|
|
1090
|
+
both: Sidekiq retries any raised job by default; under ActiveJob declare
|
|
1091
|
+
`retry_on Actionable::Job::RetryableFailure` (otherwise the error goes to the
|
|
1092
|
+
queue backend's own handling, as for any transient failure). CI checks both
|
|
1093
|
+
against the real backends: ActiveJob's test adapter with `retry_on` (a retry is
|
|
1094
|
+
a re-enqueue; once attempts run out the error is raised) and Sidekiq's inline
|
|
1095
|
+
testing mode. The macro is inherited, and a
|
|
1096
|
+
subclass backs out with `honor_retry_metadata(enabled: false)`;
|
|
1097
|
+
`honor_retry_metadata?` reads it. Without a job, ask
|
|
1098
|
+
`Actionable::Job.disposition(result, honor_retry: true)`.
|
|
1099
|
+
|
|
1100
|
+
<!-- doctest -->
|
|
1101
|
+
```ruby
|
|
1102
|
+
require 'actionable/job'
|
|
1103
|
+
|
|
1104
|
+
class Reindex < Actionable::Action
|
|
1105
|
+
honor_retry_metadata
|
|
1106
|
+
skips :not_ready, retry: true
|
|
1107
|
+
|
|
1108
|
+
step :reindex
|
|
1109
|
+
def reindex = skip!(:not_ready)
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
Reindex.honor_retry_metadata? # => true
|
|
1113
|
+
Actionable::Job.disposition(Reindex.run, honor_retry: true) # => :retry
|
|
1114
|
+
Actionable::Job.disposition(Reindex.run) # => :ack
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
## RBS for your actions
|
|
1118
|
+
|
|
1119
|
+
```ruby
|
|
1120
|
+
Actionable::RBS.generate(CreateInvoice)
|
|
1121
|
+
# => RBS with a typed `.run` (from the input schema) and output accessors /
|
|
1122
|
+
# result delegation (from the output schema), wrapped in module nesting.
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
For an action with `input` and `output` schemas:
|
|
1126
|
+
|
|
1127
|
+
```ruby
|
|
1128
|
+
class CreateInvoice < Actionable::Action
|
|
1129
|
+
input do
|
|
1130
|
+
required :amount, :integer
|
|
1131
|
+
optional :tags, :array, of: :string
|
|
1132
|
+
end
|
|
1133
|
+
output { required :total, :integer }
|
|
1134
|
+
step :compute
|
|
1135
|
+
|
|
1136
|
+
def compute
|
|
1137
|
+
@total = input.amount * 2
|
|
1138
|
+
end
|
|
1139
|
+
end
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
```
|
|
1143
|
+
class CreateInvoice < ::Actionable::Action
|
|
1144
|
+
def self.run: (amount: ::Integer, ?tags: ::Array[::String?]?) -> Result
|
|
1145
|
+
| (InputSchema input) -> Result
|
|
1146
|
+
|
|
1147
|
+
def input: () -> InputSchema
|
|
1148
|
+
|
|
1149
|
+
@total: ::Integer?
|
|
1150
|
+
|
|
1151
|
+
class InputSchema < ::FieldStruct::Base
|
|
1152
|
+
attr_reader amount: ::Integer
|
|
1153
|
+
def amount=: (::Integer? value) -> ::Integer?
|
|
1154
|
+
attr_reader tags: ::Array[::String?]?
|
|
1155
|
+
def tags=: (::Array[::String?]? value) -> ::Array[::String?]?
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
class Output < ::FieldStruct::Base
|
|
1159
|
+
attr_reader total: ::Integer
|
|
1160
|
+
def total=: (::Integer? value) -> ::Integer?
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
class Result < ::Actionable::Result
|
|
1164
|
+
def output: () -> Output
|
|
1165
|
+
def total: () -> ::Integer
|
|
1166
|
+
end
|
|
1167
|
+
end
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
Field types come from `FieldStruct::RBS.type_for`, so they match what
|
|
1171
|
+
`FieldStruct::RBS.generate` emits for a named struct: writers take and return
|
|
1172
|
+
`T?`, scalar array elements are nullable, a field's `rbs_type:` override wins,
|
|
1173
|
+
and a `:value` field is `untyped`. Types describe a valid instance: check
|
|
1174
|
+
`valid?` before trusting them on a struct that may not be.
|
|
1175
|
+
|
|
1176
|
+
- **`InputSchema`, like `Output` and `Result`, exists only in RBS.** At runtime
|
|
1177
|
+
the input schema is an anonymous class (`CreateInvoice.input`), and a step's
|
|
1178
|
+
`input` is an instance of it. Generation raises `ArgumentError` when the
|
|
1179
|
+
action declares a fixed `input` schema and Ruby already resolves
|
|
1180
|
+
`CreateInvoice::InputSchema` (a constant by that name on the action, an
|
|
1181
|
+
ancestor or an included module), since the RBS name would then describe
|
|
1182
|
+
something else. An `input_for` (discriminated) action gets no `InputSchema`.
|
|
1183
|
+
- **Output instance variables** (`@total: ::Integer?`) are declared so steps
|
|
1184
|
+
that assign them type-check; each is nullable because it is unset until a
|
|
1185
|
+
step assigns it. The generated RBS owns these declarations: **don't declare
|
|
1186
|
+
them again in your own sig files**, since RBS rejects an instance variable
|
|
1187
|
+
declared in two places.
|
|
1188
|
+
|
|
1189
|
+
An action that declares failures also gets a `failure_code` type alias listing
|
|
1190
|
+
every code it can fail with, the declared ones first, then `GEM_FAILURE_CODES`:
|
|
1191
|
+
|
|
1192
|
+
```
|
|
1193
|
+
type failure_code = :refused | :not_found | :invalid_input | :invalid_output
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
An action that declares skips gets a `skip_code` alias the same way, after
|
|
1197
|
+
`failure_code` when both are declared. It lists the declared codes only, since
|
|
1198
|
+
the gem emits no skips of its own:
|
|
1199
|
+
|
|
1200
|
+
```
|
|
1201
|
+
type skip_code = :not_ready | :already_synced
|
|
1202
|
+
```
|
|
1203
|
+
|
|
1204
|
+
Without typed outcomes the aliases are type aids only: nothing in the generated
|
|
1205
|
+
`Result` uses them, since `code` is shared by all three outcomes. Under
|
|
1206
|
+
`typed_outcomes` (decision D23) they type each outcome's `code`, and `.run`
|
|
1207
|
+
returns the action's own result classes, each with the output it can carry:
|
|
1208
|
+
|
|
1209
|
+
```
|
|
1210
|
+
def self.run: (id: ::Integer) -> (Success | Skipped | Failure)
|
|
1211
|
+
|
|
1212
|
+
class AlreadyActiveSkipOutput < ::FieldStruct::Base ... end
|
|
1213
|
+
class InvalidFailureOutput < ::FieldStruct::Base ... end
|
|
1214
|
+
|
|
1215
|
+
class Success < ::Actionable::Success
|
|
1216
|
+
def code: () -> :success
|
|
1217
|
+
def output: () -> Output
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
class Skipped < ::Actionable::Skipped
|
|
1221
|
+
def code: () -> skip_code
|
|
1222
|
+
def output: () -> (AlreadyActiveSkipOutput | ::Hash[::Symbol, untyped])
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
class Failure < ::Actionable::Failure
|
|
1226
|
+
def code: () -> failure_code
|
|
1227
|
+
def output: () -> (InvalidFailureOutput | AlreadyActiveSkipOutput | Output | ::Hash[::Symbol, untyped])
|
|
1228
|
+
end
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
A failure's output also admits every skip schema and `Output`, since output that
|
|
1232
|
+
breaks one ends the run as `invalid_output` carrying it. For the same reason
|
|
1233
|
+
every reader declared on `Failure` is nullable (`def key: () -> ::KeySummary?`):
|
|
1234
|
+
a broken output can lack a required field. A code with no schema, or no
|
|
1235
|
+
declarations at all, types as a Hash and a plain `::Symbol`.
|
|
1236
|
+
|
|
1237
|
+
Each output class is named after its code, camelized: `:already_active` gives
|
|
1238
|
+
`AlreadyActiveSkipOutput`. A trailing `?` or `!`, which can't appear in a class
|
|
1239
|
+
name, becomes `Predicate` or `Bang`: `fails(:expired?) { … }` gives
|
|
1240
|
+
`ExpiredPredicateFailureOutput` and `skips(:done!) { … }` gives
|
|
1241
|
+
`DoneBangSkipOutput`, so `:expired` and `:expired?` never share a class.
|
|
1242
|
+
`generate` raises `ArgumentError` when two codes of one kind would still land on
|
|
1243
|
+
one name (`:ready` and `:ready_`, or `:expired?` and `:expired_predicate`), or
|
|
1244
|
+
when a code's class name would not be plain ASCII starting with a capital
|
|
1245
|
+
(`:日本`, `:foo_é`): rename one code.
|
|
1246
|
+
|
|
1247
|
+
### Base actions
|
|
1248
|
+
|
|
1249
|
+
The action is declared with its real superclass, and every named ancestor up to
|
|
1250
|
+
`Actionable::Action` is declared before it, without members, so the RBS resolves
|
|
1251
|
+
(and `rbs validate` passes) even when nobody generates your abstract bases:
|
|
1252
|
+
|
|
1253
|
+
```ruby
|
|
1254
|
+
class ApplicationAction < Actionable::Action; end
|
|
1255
|
+
module Codes
|
|
1256
|
+
class BaseAction < ApplicationAction; end
|
|
1257
|
+
class DecideMapping < BaseAction
|
|
1258
|
+
output { required :id, :integer }
|
|
1259
|
+
end
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
Actionable::RBS.generate(Codes::DecideMapping)
|
|
1263
|
+
```
|
|
1264
|
+
|
|
1265
|
+
```
|
|
1266
|
+
class ApplicationAction < ::Actionable::Action
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
module Codes
|
|
1270
|
+
class BaseAction < ::ApplicationAction
|
|
1271
|
+
end
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
module Codes
|
|
1275
|
+
class DecideMapping < ::Codes::BaseAction
|
|
1276
|
+
def self.run: (*untyped) -> Result
|
|
1277
|
+
...
|
|
1278
|
+
end
|
|
1279
|
+
end
|
|
1280
|
+
```
|
|
1281
|
+
|
|
1282
|
+
Write each action's RBS to its own file: the ancestor declarations repeat,
|
|
1283
|
+
byte-identical, in every file that shares a base, and RBS merges them. They also
|
|
1284
|
+
merge with a base's own signature when you generate or write that too, as long
|
|
1285
|
+
as it names the same superclass. A namespace that is a class opens as `class`
|
|
1286
|
+
(without a superclass), not `module`. An anonymous ancestor is skipped: the
|
|
1287
|
+
next named one becomes the superclass.
|
|
1288
|
+
|
|
1289
|
+
A subclass of a typed action subclasses its parent's result classes
|
|
1290
|
+
(`class Success < ::Billing::BaseAction::Success`), so a typed ancestor's
|
|
1291
|
+
declaration also carries its `Success`, `Skipped` and `Failure`, empty.
|