ruby_reactor 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +26 -16
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +1 -1
- data/CHANGELOG.md +140 -0
- data/CLAUDE.md +1 -1
- data/README.md +125 -31
- data/lib/ruby_reactor/context.rb +2 -2
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/reactor.rb +36 -18
- data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
- data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
- data/lib/ruby_reactor/executor/result_handler.rb +9 -3
- data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
- data/lib/ruby_reactor/executor/step_executor.rb +9 -2
- data/lib/ruby_reactor/executor.rb +3 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +3 -6
- data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
- data/lib/ruby_reactor/step/compose_step.rb +56 -75
- data/lib/ruby_reactor/step/input_contract.rb +128 -0
- data/lib/ruby_reactor/step/map_step.rb +177 -218
- data/lib/ruby_reactor/step.rb +116 -21
- data/lib/ruby_reactor/step_signals.rb +6 -2
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/template/result.rb +9 -2
- data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +5 -2
- data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
- data/specs/002-step-input-contracts/data-model.md +115 -0
- data/specs/002-step-input-contracts/plan.md +165 -0
- data/specs/002-step-input-contracts/quickstart.md +170 -0
- data/specs/002-step-input-contracts/research.md +233 -0
- data/specs/002-step-input-contracts/spec.md +359 -0
- data/specs/002-step-input-contracts/tasks.md +367 -0
- data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
- data/specs/004-inheritable-step-class/data-model.md +116 -0
- data/specs/004-inheritable-step-class/plan.md +174 -0
- data/specs/004-inheritable-step-class/quickstart.md +112 -0
- data/specs/004-inheritable-step-class/research.md +308 -0
- data/specs/004-inheritable-step-class/spec.md +316 -0
- data/specs/004-inheritable-step-class/tasks.md +258 -0
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
- data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
- data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
- data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
- data/specs/deferred-003-step-lock-declarations/research.md +196 -0
- data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
- data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
- data/specs/possible_feature.md +22 -0
- metadata +28 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e671a274b5dc3854189ab1f3c40ff4d7e307a567ed960dbad0be1405f05ff58
|
|
4
|
+
data.tar.gz: 0a44e9cf8dbed99b72762381300c4c503500d3025f21288e473eef377a28dbec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a39666bdba5d7c911589da0f30a12e29a9f5187c31a9a1bfb9ddc0c55aba26cac201db3e8083854f79858d6845ed98097589979873a0a4aa7cc8dfdaca635856
|
|
7
|
+
data.tar.gz: a5069c91a3942f1697a317baee2223b790b4262ca6d90f61a1b3aa0cd8b61ff9b199b7a05f58f791df11bd7b4336611907a6abd3cbce6ebfd007d9bc92949c4e
|
data/.specify/feature.json
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
SYNC IMPACT REPORT
|
|
3
3
|
==================
|
|
4
|
-
Version change: 1.1
|
|
5
|
-
|
|
4
|
+
Version change: 1.2.1 → 1.3.0 (MINOR: Development Workflow now REQUIRES every generated
|
|
5
|
+
plan.md and tasks.md to carry an explicit documentation-update task/gate, with the
|
|
6
|
+
requirement propagated into both templates — a new enforcement mechanism, not just
|
|
7
|
+
wording)
|
|
6
8
|
|
|
7
|
-
Modified principles:
|
|
8
|
-
- Principle VI: Demo-App Proof of Feature — added requirement 4, "Docker acceptance
|
|
9
|
-
run" (docker-compose.yml MUST track demo_app's services; demo: rake tasks are the
|
|
10
|
-
project's acceptance suite, runnable via `docker compose run`)
|
|
9
|
+
Modified principles: none (Development Workflow section, not a Core Principle)
|
|
11
10
|
|
|
12
|
-
Added sections: none
|
|
11
|
+
Added sections: none
|
|
13
12
|
|
|
14
13
|
Removed sections: none
|
|
15
14
|
|
|
16
15
|
Templates checked:
|
|
17
|
-
- .specify/templates/plan-template.md
|
|
18
|
-
|
|
19
|
-
- .specify/templates/spec-template.md
|
|
20
|
-
- .specify/templates/tasks-template.md
|
|
16
|
+
- .specify/templates/plan-template.md ✅ Added Constitution Check checklist item:
|
|
17
|
+
identify README.md/./documentation impact
|
|
18
|
+
- .specify/templates/spec-template.md ✅ No principle-specific content — no edit required
|
|
19
|
+
- .specify/templates/tasks-template.md ✅ Polish-phase doc task now explicit and marked
|
|
20
|
+
REQUIRED, citing README.md + ./documentation
|
|
21
21
|
- .specify/templates/checklist-template.md ✅ Generic — no edit required
|
|
22
|
-
- .specify/extensions.yml
|
|
22
|
+
- .specify/extensions.yml ✅ No before/after_constitution hooks registered
|
|
23
|
+
- README.md ✅ No principle-specific claims to update
|
|
24
|
+
- documentation/*.md ✅ No principle-specific claims to update
|
|
23
25
|
|
|
24
26
|
Deferred TODOs: none
|
|
25
27
|
-->
|
|
@@ -164,9 +166,17 @@ gaps in the matcher library surface as work instead of as workarounds.
|
|
|
164
166
|
- Feature branches target `main`. PRs MUST pass CI (RSpec + RuboCop) before merge.
|
|
165
167
|
- Releases are managed by release-please. Version bump lives in
|
|
166
168
|
`lib/ruby_reactor/version.rb`. Do not manually edit the version in gemspec.
|
|
167
|
-
-
|
|
168
|
-
`
|
|
169
|
-
|
|
169
|
+
- Every task MUST update documentation as part of its own change, not as a
|
|
170
|
+
follow-up: `README.md` and any affected file under `./documentation` MUST be
|
|
171
|
+
kept current with the behavior the task introduces, changes, or removes. A
|
|
172
|
+
task that alters observable behavior without a matching documentation update
|
|
173
|
+
is incomplete. New features additionally MUST add entries to `CHANGELOG.md`
|
|
174
|
+
under the correct semantic heading (`Features`, `Bug Fixes`, `documentation`).
|
|
175
|
+
- Every `plan.md` produced by `/speckit-plan` and every `tasks.md` produced by
|
|
176
|
+
`/speckit-tasks` MUST include at least one explicit task or checklist item to
|
|
177
|
+
update `README.md` and the relevant file(s) under `./documentation` for the
|
|
178
|
+
feature's behavior. A plan or task list that omits a documentation-update
|
|
179
|
+
task is incomplete and MUST be rejected in review.
|
|
170
180
|
- Class-based step definitions are the preferred authoring style (not inline
|
|
171
181
|
lambdas). Documentation and examples MUST reflect this.
|
|
172
182
|
- The `demo_app/` directory serves as a living integration example. Changes to
|
|
@@ -198,4 +208,4 @@ justified in the `Complexity Tracking` table of the plan.
|
|
|
198
208
|
Compliance review: at each MINOR or MAJOR gem release, confirm this constitution
|
|
199
209
|
still accurately reflects the codebase and update as needed.
|
|
200
210
|
|
|
201
|
-
**Version**: 1.
|
|
211
|
+
**Version**: 1.3.0 | **Ratified**: 2025-10-02 | **Last Amended**: 2026-09-11
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
|
|
43
43
|
[Gates determined based on constitution file]
|
|
44
44
|
|
|
45
|
+
- [ ] Documentation impact identified: which `README.md` sections and which
|
|
46
|
+
file(s) under `./documentation` this feature will require updating
|
|
47
|
+
(Constitution Development Workflow — carried into tasks.md as a required task)
|
|
48
|
+
|
|
45
49
|
## Project Structure
|
|
46
50
|
|
|
47
51
|
### Documentation (this feature)
|
|
@@ -151,7 +151,7 @@ Examples of foundational tasks (adjust based on your project):
|
|
|
151
151
|
|
|
152
152
|
**Purpose**: Improvements that affect multiple user stories
|
|
153
153
|
|
|
154
|
-
- [ ] TXXX [P]
|
|
154
|
+
- [ ] TXXX [P] Update `README.md` and affected file(s) under `./documentation` for this feature (REQUIRED — Constitution Development Workflow)
|
|
155
155
|
- [ ] TXXX Code cleanup and refactoring
|
|
156
156
|
- [ ] TXXX Performance optimization across all stories
|
|
157
157
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,145 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### ⚠ BREAKING CHANGES
|
|
6
|
+
|
|
7
|
+
* **`RubyReactor::Step` is now a base class, not a mixin.** `include RubyReactor::Step` on a
|
|
8
|
+
plain class with `def self.run(arguments, context)` is gone — no compatibility shim, no dual
|
|
9
|
+
authoring style. A step subclasses `RubyReactor::Step` and writes `run` (and optionally
|
|
10
|
+
`compensate`/`undo`) as **instance** methods reading the validated arguments and the context
|
|
11
|
+
through `inputs`/`context` accessors instead of parameters. Every instance is built fresh for
|
|
12
|
+
its one action (`run`, `undo`, or `compensate`) from the stored arguments/result/reason alone —
|
|
13
|
+
nothing an author sets in `run` is visible in a later `undo`/`compensate`, which is exactly what
|
|
14
|
+
makes rollback behave identically whether it lands in the same process as `run` or, as with an
|
|
15
|
+
`async_step`, in a separate later one. `inputs` holds the same values in all three actions: the
|
|
16
|
+
arguments with the contract's defaults applied. Only `run` enforces the contract; `undo` and
|
|
17
|
+
`compensate` never do, so rollback cannot fail on the inputs that caused the failure.
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# Before
|
|
21
|
+
class ChargeStep
|
|
22
|
+
include RubyReactor::Step
|
|
23
|
+
|
|
24
|
+
input :amount, :integer, gteq?: 1
|
|
25
|
+
|
|
26
|
+
def self.run(arguments, context)
|
|
27
|
+
Success(charge!(arguments[:amount]))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.undo(result, arguments, context)
|
|
31
|
+
refund!(result[:charge_id])
|
|
32
|
+
Success()
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# After
|
|
37
|
+
class ChargeStep < RubyReactor::Step
|
|
38
|
+
input :amount, :integer, gteq?: 1
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
Success(charge!(inputs[:amount]))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def undo
|
|
45
|
+
refund!(result[:charge_id])
|
|
46
|
+
Success()
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Migration:** for every class step, replace `include RubyReactor::Step` with
|
|
52
|
+
`< RubyReactor::Step`, turn `def self.run(args, ctx)` into `def run` reading `inputs`/`context`,
|
|
53
|
+
and likewise for `def self.undo(result, args, ctx)` / `def self.compensate(reason, args, ctx)` →
|
|
54
|
+
`def undo` / `def compensate` reading `result`/`reason`/`inputs`/`context`. The class-level
|
|
55
|
+
`MyStep.run(arguments, context)` / `.call` / `.undo(result, arguments, context)` /
|
|
56
|
+
`.compensate(reason, arguments, context)` entry points every caller (executor, worker, a direct
|
|
57
|
+
unit-test call) already used are unchanged. Inline `step { run { |args, ctx| ... } }` blocks are
|
|
58
|
+
untouched by this change.
|
|
59
|
+
|
|
60
|
+
* **A class step's signal helpers (`success!`/`fail!`/`skip!`/`halt!`) now translate correctly on
|
|
61
|
+
every execution path, including the `async_step`/`background` worker.** Previously the worker had
|
|
62
|
+
no `catch` of its own, so a signal thrown from a class step running there escaped as an
|
|
63
|
+
`UncaughtThrowError` instead of the intended `Success`/`Failure`/`Skipped`/`Halt` — the base
|
|
64
|
+
class's class-level `run`/`undo`/`compensate` now own that translation, so every caller gets it
|
|
65
|
+
for free with no worker change. Known remaining gap, unchanged by this release: an **inline**
|
|
66
|
+
`run`/`compensate`/`undo` block's signals are still uncaught on the worker path.
|
|
67
|
+
|
|
68
|
+
* **A step's own input-validation failure is now guaranteed non-retryable on every path.**
|
|
69
|
+
`result.retryable?` is `false` whether the violation happened synchronously, inside an
|
|
70
|
+
`async_step`/`background` worker, or inside a `compose`d child's own step — previously only the
|
|
71
|
+
async worker path got this right; the synchronous path and a validation failure surfacing
|
|
72
|
+
through `compose` both defaulted to `retryable? == true`.
|
|
73
|
+
|
|
74
|
+
* **`Failure(...)` takes the same arguments everywhere.** Inside a class step and inside an inline
|
|
75
|
+
`run`/`compensate`/`undo` block, `Failure` now forwards every argument to `RubyReactor.Failure`,
|
|
76
|
+
so options such as `Failure("declined", retryable: false)` work instead of raising
|
|
77
|
+
`ArgumentError`. A bare `Failure()` with no error is no longer accepted, matching
|
|
78
|
+
`RubyReactor.Failure`, and a hash error needs braces, `Failure({ code: 1 })`, because a braceless
|
|
79
|
+
`Failure(code: 1)` is now read as options.
|
|
80
|
+
|
|
81
|
+
### Features
|
|
82
|
+
|
|
83
|
+
* **Step input contracts.** A step class declares its own inputs with `input :name, :type, **predicates`
|
|
84
|
+
(plus `optional:`, `default:`, `redact:`, the `do |i| ... end` macro block and `validate:`) and
|
|
85
|
+
cross-field rules with `validate_inputs`. The contract is enforced before `run` on every path
|
|
86
|
+
(inline, retries, `async_step` and `background` workers, resume, `map`, and a direct
|
|
87
|
+
`Step.run(args, context)` call), and a violation fails with `validation_errors` and the step's
|
|
88
|
+
name after completed steps are rolled back. Subclasses inherit and extend the contract.
|
|
89
|
+
Introspection: `input_contract`, `declared_inputs`, `required_input_names`, `declares_inputs?`.
|
|
90
|
+
* **Inline step contracts.** `inputs do ... end` inside a `step` block takes the same `input` /
|
|
91
|
+
`validate_inputs` lines as a step class and is enforced the same way.
|
|
92
|
+
* **Wiring by name.** A declared input with no `argument` resolves from the reactor input of the
|
|
93
|
+
same name (never from a step result). A required input that is neither wired nor a reactor input
|
|
94
|
+
raises `Error::ValidationError` before any step runs. `Reactor.validate_definition!` runs that
|
|
95
|
+
check on demand, e.g. from an initializer or CI.
|
|
96
|
+
* For a step that owns a contract, a type or predicate on `argument`, `validate_args`, or an
|
|
97
|
+
`argument` for an undeclared input raises `Error::ValidationError` when the `step` line is
|
|
98
|
+
evaluated.
|
|
99
|
+
* `have_validation_error` now also matches validation failures raised at a step, not only
|
|
100
|
+
reactor-input failures.
|
|
101
|
+
* `Failure#to_h` includes `retryable`, so a non-retryable failure stays non-retryable after it
|
|
102
|
+
crosses a worker boundary.
|
|
103
|
+
* A step that returns another unit's validation failure (e.g. an `async_step` reader propagating
|
|
104
|
+
the worker's `Failure`) keeps its `validation_errors` on the reactor's final failure.
|
|
105
|
+
|
|
106
|
+
### Deprecations
|
|
107
|
+
|
|
108
|
+
* Rules on `argument` (`argument :x, src, :type, **predicates`) and `validate_args` keep working
|
|
109
|
+
for steps without a contract, and print one deprecation notice per declaration site. Move them
|
|
110
|
+
to `input` / `validate_inputs` on the step class, or into an `inputs do ... end` block for an
|
|
111
|
+
inline step, and keep `argument :x, src` for wiring. Removal is no earlier than the next major
|
|
112
|
+
version. See "Step Input Contracts" in the README for the migration.
|
|
113
|
+
|
|
114
|
+
### Bug Fixes
|
|
115
|
+
|
|
116
|
+
* A supplied `false` reactor input or step result no longer resolves to `nil`.
|
|
117
|
+
`Context#get_input`, `Context#get_result` and `Template::Result#fetch` now check whether the key
|
|
118
|
+
exists instead of whether the value is truthy. Code that relied on `false` arriving as `nil`
|
|
119
|
+
will now see `false`.
|
|
120
|
+
* An input-validation `Failure` stays non-retryable across serialization. `Failure`'s
|
|
121
|
+
hash extractor and the reactor's stored `failure_reason` both dropped a `retryable: false`
|
|
122
|
+
(`||` swallowed the `false`, and the reactor never stored the flag at all), so a failure
|
|
123
|
+
rebuilt from JSON or reloaded with `Reactor.find` reported `retryable? == true`.
|
|
124
|
+
* A step-contract violation inside `compose` now reaches the parent with its `validation_errors`
|
|
125
|
+
and retryability intact, instead of being rebuilt from the child's error message alone.
|
|
126
|
+
* A reactor reopened after its first run is re-checked: `validate_definition!` no longer memoizes,
|
|
127
|
+
so a step declared later can no longer reach execution with a required input unwired.
|
|
128
|
+
* A class step that calls `halt!` under `async_step` is recorded as a halt rather than an
|
|
129
|
+
ordinary `nil` success, and `result(:step)` hands the reader the `Halt` — the same way it
|
|
130
|
+
already hands over a `Failure`.
|
|
131
|
+
|
|
132
|
+
## [0.8.0](https://github.com/arturictus/ruby_reactor/compare/v0.7.1...v0.8.0) (2026-09-21)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
### ⚠ BREAKING CHANGES
|
|
136
|
+
|
|
137
|
+
* Step Instance and input validations per step ([#51](https://github.com/arturictus/ruby_reactor/issues/51))
|
|
138
|
+
|
|
139
|
+
### Features
|
|
140
|
+
|
|
141
|
+
* Step Instance and input validations per step ([#51](https://github.com/arturictus/ruby_reactor/issues/51)) ([4a20583](https://github.com/arturictus/ruby_reactor/commit/4a2058399d73e9b3bf767659dd5ba080537dc291))
|
|
142
|
+
|
|
3
143
|
## [0.7.1](https://github.com/arturictus/ruby_reactor/compare/v0.7.0...v0.7.1) (2026-09-14)
|
|
4
144
|
|
|
5
145
|
|
data/CLAUDE.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!-- SPECKIT START -->
|
|
2
2
|
For additional context about technologies to be used, project structure,
|
|
3
3
|
shell commands, and other important information, read the current plan:
|
|
4
|
-
`specs/
|
|
4
|
+
`specs/004-inheritable-step-class/plan.md`
|
|
5
5
|
<!-- SPECKIT END -->
|
data/README.md
CHANGED
|
@@ -239,23 +239,24 @@ Whichever style you use, a step's `run` returns one of four signals — all expo
|
|
|
239
239
|
|
|
240
240
|
One-line helpers end a step immediately from any call depth: `success!(value)`, `fail!(error, retry: true)`, `halt!(reason:)`, `skip!(value)` — equivalent to `return`ing the matching signal, usable in `run`, `compensate`, and `undo` bodies.
|
|
241
241
|
|
|
242
|
-
**Class steps**
|
|
242
|
+
**Class steps** subclass `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`, as instance methods reading `inputs` and `context`:
|
|
243
243
|
|
|
244
244
|
```ruby
|
|
245
|
-
class ReserveInventoryStep
|
|
246
|
-
|
|
245
|
+
class ReserveInventoryStep < RubyReactor::Step
|
|
246
|
+
# The step's input contract: enforced before `run` on every execution path.
|
|
247
|
+
input :order, :hash
|
|
247
248
|
|
|
248
|
-
def
|
|
249
|
-
reservation_id = InventoryService.reserve(
|
|
249
|
+
def run
|
|
250
|
+
reservation_id = InventoryService.reserve(inputs[:order][:items])
|
|
250
251
|
Success(reservation_id: reservation_id)
|
|
251
252
|
end
|
|
252
253
|
|
|
253
|
-
def
|
|
254
|
-
InventoryService.release_partial(
|
|
254
|
+
def compensate
|
|
255
|
+
InventoryService.release_partial(inputs[:order][:items])
|
|
255
256
|
Success()
|
|
256
257
|
end
|
|
257
258
|
|
|
258
|
-
def
|
|
259
|
+
def undo
|
|
259
260
|
InventoryService.release(result[:reservation_id])
|
|
260
261
|
Success()
|
|
261
262
|
end
|
|
@@ -322,29 +323,25 @@ RubyReactor allows you to define complex workflows as "reactors" with steps that
|
|
|
322
323
|
```ruby
|
|
323
324
|
require 'ruby_reactor'
|
|
324
325
|
|
|
325
|
-
class ValidateEmailStep
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def self.run(arguments, _context)
|
|
329
|
-
email = arguments[:email]
|
|
326
|
+
class ValidateEmailStep < RubyReactor::Step
|
|
327
|
+
def run
|
|
328
|
+
email = inputs[:email]
|
|
330
329
|
email&.include?('@') ? Success(email.strip) : Failure("Email must contain @")
|
|
331
330
|
end
|
|
332
331
|
end
|
|
333
332
|
|
|
334
|
-
class CreateUserStep
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def self.run(arguments, _context)
|
|
333
|
+
class CreateUserStep < RubyReactor::Step
|
|
334
|
+
def run
|
|
338
335
|
Success(
|
|
339
336
|
id: rand(10000),
|
|
340
|
-
email:
|
|
341
|
-
password_hash:
|
|
337
|
+
email: inputs[:email],
|
|
338
|
+
password_hash: inputs[:password_hash],
|
|
342
339
|
created_at: Time.now
|
|
343
340
|
)
|
|
344
341
|
end
|
|
345
342
|
|
|
346
|
-
def
|
|
347
|
-
Notify.to(
|
|
343
|
+
def compensate
|
|
344
|
+
Notify.to(inputs[:email])
|
|
348
345
|
Success()
|
|
349
346
|
end
|
|
350
347
|
end
|
|
@@ -1018,26 +1015,123 @@ result.error # => RubyReactor::Error::InputValidationError
|
|
|
1018
1015
|
result.error.field_errors[:name] # => "size cannot be less than 2"
|
|
1019
1016
|
```
|
|
1020
1017
|
|
|
1021
|
-
### Step
|
|
1018
|
+
### Step Input Contracts
|
|
1022
1019
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1020
|
+
A step declares the inputs it accepts; the reactor only says where each value
|
|
1021
|
+
comes from. Rules live with the unit of work, so a step reused by three
|
|
1022
|
+
reactors is validated the same way in all three.
|
|
1025
1023
|
|
|
1026
1024
|
```ruby
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1025
|
+
class ChargeStep < RubyReactor::Step
|
|
1026
|
+
input :amount, :integer, gteq?: 1
|
|
1027
|
+
input :currency, :string, included_in?: %w[USD EUR GBP]
|
|
1028
|
+
input :user, User # type? instance check
|
|
1029
|
+
input :note, :string, optional: true, default: "", max_size?: 100
|
|
1030
|
+
input :token, :string, redact: true # "[REDACTED]" in failures and traces
|
|
1031
|
+
|
|
1032
|
+
# Cross-field rules over the whole argument hash, applied last
|
|
1033
|
+
validate_inputs do
|
|
1034
|
+
required(:amount).filled(:integer, lt?: 10_000)
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
def run
|
|
1038
|
+
Success(charge!(inputs))
|
|
1039
|
+
end
|
|
1040
|
+
end
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
`input` takes the same forms as a reactor `input` (inline type and predicates,
|
|
1044
|
+
`do |i| ... end` macro block, `validate: Schema`). The contract is enforced before
|
|
1045
|
+
`run` on every path: inline execution, each retry attempt, `async_step` and
|
|
1046
|
+
`background` workers, resume, every `map` element, and a direct
|
|
1047
|
+
`ChargeStep.run(args, context)` call. A violation raises
|
|
1048
|
+
`RubyReactor::Error::InputValidationError`; inside a reactor that becomes a
|
|
1049
|
+
`Failure` with `validation_errors` and the step's name, after completed steps are
|
|
1050
|
+
rolled back, and `have_validation_error(:amount)` matches it.
|
|
1051
|
+
|
|
1052
|
+
**Wiring.** `argument` only maps values:
|
|
1053
|
+
|
|
1054
|
+
```ruby
|
|
1055
|
+
class ChargeReactor < RubyReactor::Reactor
|
|
1056
|
+
input :amount
|
|
1057
|
+
input :currency
|
|
1058
|
+
input :user
|
|
1059
|
+
|
|
1060
|
+
step :charge, ChargeStep do
|
|
1061
|
+
argument :user, result(:load_user) # explicit wiring always wins
|
|
1062
|
+
end # :amount and :currency resolve by name
|
|
1063
|
+
end
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
A declared input with no `argument` resolves from the reactor input of the same
|
|
1067
|
+
name. Step results are never used for this. A required input that is neither
|
|
1068
|
+
wired nor a reactor input raises `RubyReactor::Error::ValidationError` before any
|
|
1069
|
+
step runs, naming the reactor, the step, the input, and both fixes.
|
|
1070
|
+
`ChargeReactor.validate_definition!` runs the same check without running the
|
|
1071
|
+
reactor, e.g. in an initializer or a CI task.
|
|
1072
|
+
|
|
1073
|
+
For a step that owns a contract, these raise `RubyReactor::Error::ValidationError`
|
|
1074
|
+
when the `step` line is evaluated:
|
|
1031
1075
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1076
|
+
- a type or predicate on `argument` (`argument :amount, input(:amount), :integer`)
|
|
1077
|
+
- `validate_args`
|
|
1078
|
+
- an `argument` naming an input the step does not declare
|
|
1079
|
+
|
|
1080
|
+
There are no per-reactor overrides. If two reactors need different bounds for the
|
|
1081
|
+
same value, write two steps (or a parameterized step), or relax the contract.
|
|
1082
|
+
|
|
1083
|
+
**Inline steps** declare the same lines inside `inputs do ... end`. Inside the
|
|
1084
|
+
block `input` declares; outside it, `input(:x)` is still the template reference:
|
|
1085
|
+
|
|
1086
|
+
```ruby
|
|
1087
|
+
step :charge do
|
|
1088
|
+
inputs do
|
|
1089
|
+
input :amount, :integer, gteq?: 1
|
|
1090
|
+
input :currency, :string, included_in?: %w[USD EUR GBP]
|
|
1035
1091
|
end
|
|
1036
1092
|
|
|
1093
|
+
argument :amount, input(:amount)
|
|
1094
|
+
argument :currency, input(:currency)
|
|
1095
|
+
|
|
1037
1096
|
run { |args, _| charge!(args) }
|
|
1038
1097
|
end
|
|
1039
1098
|
```
|
|
1040
1099
|
|
|
1100
|
+
Moving an inline step into a class is deleting the `inputs do` wrapper.
|
|
1101
|
+
|
|
1102
|
+
**Presence.** A value is provided when its key exists, not when it is truthy:
|
|
1103
|
+
|
|
1104
|
+
| Supplied | Required input | Optional input with `default:` |
|
|
1105
|
+
|---|---|---|
|
|
1106
|
+
| `false`, `0`, `""`, `[]` | passes, value kept | value kept |
|
|
1107
|
+
| `nil` | fails ("must be filled") | default applied |
|
|
1108
|
+
| key absent | fails ("is missing") | default applied |
|
|
1109
|
+
|
|
1110
|
+
**Migrating from rules on `argument`.** Rules on `argument` and `validate_args`
|
|
1111
|
+
still work for steps with no contract, and print a one-time deprecation notice per
|
|
1112
|
+
declaration site. Removal is no earlier than the next major version.
|
|
1113
|
+
|
|
1114
|
+
```ruby
|
|
1115
|
+
# Before
|
|
1116
|
+
step :charge, ChargeStep do
|
|
1117
|
+
argument :amount, input(:amount), :integer, gteq?: 1
|
|
1118
|
+
validate_args { required(:amount).filled(:integer, lt?: 10_000) }
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
# After: the rules move into the step
|
|
1122
|
+
class ChargeStep < RubyReactor::Step
|
|
1123
|
+
input :amount, :integer, gteq?: 1
|
|
1124
|
+
validate_inputs { required(:amount).filled(:integer, lt?: 10_000) }
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
step :charge, ChargeStep # or keep `argument :amount, input(:amount)` for wiring
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
For an inline step, put the same `input` / `validate_inputs` lines inside
|
|
1131
|
+
`inputs do ... end`.
|
|
1132
|
+
|
|
1133
|
+
### Step Output Validation
|
|
1134
|
+
|
|
1041
1135
|
Output validation is scalar-aware — pass a type/predicates for a single value,
|
|
1042
1136
|
or a block for a hash output:
|
|
1043
1137
|
|
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -64,7 +64,7 @@ module RubyReactor
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def get_input(name, path = nil)
|
|
67
|
-
value = @inputs
|
|
67
|
+
value = Utils::FetchIndifferent.call(@inputs, name)
|
|
68
68
|
return nil if value.nil?
|
|
69
69
|
|
|
70
70
|
if path
|
|
@@ -76,7 +76,7 @@ module RubyReactor
|
|
|
76
76
|
alias input get_input
|
|
77
77
|
|
|
78
78
|
def get_result(step_name, path = nil)
|
|
79
|
-
value = @intermediate_results
|
|
79
|
+
value = Utils::FetchIndifferent.call(@intermediate_results, step_name)
|
|
80
80
|
return nil if value.nil?
|
|
81
81
|
|
|
82
82
|
if path
|
|
@@ -36,6 +36,12 @@ module RubyReactor
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def inputs(*)
|
|
40
|
+
raise RubyReactor::Error::ValidationError,
|
|
41
|
+
"interrupt :#{@name} does not take an `inputs` contract; validate the resume payload with " \
|
|
42
|
+
"`validate_payload`."
|
|
43
|
+
end
|
|
44
|
+
|
|
39
45
|
# Deprecated alias for {#validate_payload}.
|
|
40
46
|
def validate(schema = nil, &block)
|
|
41
47
|
unless @warned_validate
|
|
@@ -82,25 +82,11 @@ module RubyReactor
|
|
|
82
82
|
input_validations[name] = validator if validator
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
# Form 2 — block bound to the value macro (`do |i| ... end`)
|
|
88
|
-
# legacy — single-key schema block (`do required(:name)... end`)
|
|
89
|
-
# Form 1 / 1b — inline scalar or class type
|
|
90
|
-
# Form 0 — declaration only (no validator)
|
|
85
|
+
# The legacy single-key schema block is deprecated on reactor inputs
|
|
86
|
+
# only; the form dispatch itself is shared with step input contracts.
|
|
91
87
|
def build_input_validator_for(name, type, optional, validate, predicates, &block)
|
|
92
|
-
if validate
|
|
93
|
-
|
|
94
|
-
elsif block
|
|
95
|
-
if block.arity.nonzero?
|
|
96
|
-
build_macro_validator(name, optional, &block)
|
|
97
|
-
else
|
|
98
|
-
warn_deprecated_input_block
|
|
99
|
-
create_input_validator(block)
|
|
100
|
-
end
|
|
101
|
-
elsif type || predicates.any?
|
|
102
|
-
build_inline_validator(name, type, optional, predicates)
|
|
103
|
-
end
|
|
88
|
+
warn_deprecated_input_block if !validate && block&.arity&.zero?
|
|
89
|
+
build_declaration_validator(name, type, optional, validate, predicates, &block)
|
|
104
90
|
end
|
|
105
91
|
private :build_input_validator_for
|
|
106
92
|
|
|
@@ -163,6 +149,38 @@ module RubyReactor
|
|
|
163
149
|
step_config
|
|
164
150
|
end
|
|
165
151
|
|
|
152
|
+
# Checks that every required input of every contract-owning step is
|
|
153
|
+
# satisfied, and wires the unwired ones from same-named reactor inputs
|
|
154
|
+
# (never from step results). Runs before the first execution because
|
|
155
|
+
# the reactor's full input list is only known once its body has run;
|
|
156
|
+
# public so an app can call it at boot or in CI.
|
|
157
|
+
def validate_definition!
|
|
158
|
+
steps.each do |step_name, step_config|
|
|
159
|
+
contract = step_config.input_contract if step_config.respond_to?(:input_contract)
|
|
160
|
+
next unless contract
|
|
161
|
+
|
|
162
|
+
contract.declarations.each_value do |declaration|
|
|
163
|
+
wire_by_name!(step_name, step_config, declaration)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def wire_by_name!(step_name, step_config, declaration)
|
|
169
|
+
input_name = declaration.name
|
|
170
|
+
return if step_config.arguments.key?(input_name)
|
|
171
|
+
|
|
172
|
+
if inputs.key?(input_name)
|
|
173
|
+
step_config.arguments[input_name] =
|
|
174
|
+
{ source: RubyReactor::Template::Input.new(input_name), transform: nil, origin: :inferred }
|
|
175
|
+
elsif !declaration.optional
|
|
176
|
+
raise RubyReactor::Error::ValidationError,
|
|
177
|
+
"#{name || inspect} step :#{step_name} requires input :#{input_name}, which is neither wired " \
|
|
178
|
+
"nor a reactor input. Wire it (`argument :#{input_name}, input(:x)` / `result(:step)`) or " \
|
|
179
|
+
"declare `input :#{input_name}` on the reactor."
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
private :wire_by_name!
|
|
183
|
+
|
|
166
184
|
def returns(step_name = nil)
|
|
167
185
|
if step_name
|
|
168
186
|
reject_async_return_step!(step_name)
|