operational 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7eb527cb1be0cf712722402acc06648901bc052c16e5eda7694b17daa3aa707b
4
+ data.tar.gz: 834d9cc329f5679c536bbd7e45acc4bf4926a782e22ac5738168f729779a59de
5
+ SHA512:
6
+ metadata.gz: 697b98acdd2a9d2ed1ec6e812fc29989dafd00df0b9f49dc6a72c26458ac7ea89ff8338ed3aaaecc8d6dc2694ad873e1b9c1632e973b3be1b1100580835b495e
7
+ data.tar.gz: 5e3d5ab2d70b564912ca8f7d05d8f2fe2347971e570954c8f34442de1749469f3fe5fe23d0f65eb599bb1ecec75737eb672c08b84d2ad58c30f824edf1213d5a
data/AI_README.md ADDED
@@ -0,0 +1,328 @@
1
+ # Operational — AI Agent Reference
2
+
3
+ This document is for AI coding agents. It describes the exact API of the `operational` gem so you can generate correct code without guessing.
4
+
5
+ ## Architecture
6
+
7
+ Operational has four components:
8
+
9
+ 1. **Operation** — orchestrates a business process as a railway of steps
10
+ 2. **Form** — validates and transforms user input, decoupled from models (built on ActiveModel)
11
+ 3. **Contract** — step helpers that wire forms into operations (Build → Validate → Sync)
12
+ 4. **Controller** — Rails mixin that runs operations from controller actions
13
+
14
+ ## Operations
15
+
16
+ Subclass `Operational::Operation`. Define steps with `step`, `pass`, or `fail` at the class level. Call with `.call(state_hash)`. Returns an `Operational::Result`.
17
+
18
+ ```ruby
19
+ class CreateArticleOperation < Operational::Operation
20
+ step :init
21
+ step Contract::Build(contract: ArticleForm)
22
+ step Contract::Validate()
23
+ step Contract::Sync()
24
+ step :save
25
+ pass :notify # return value ignored, never derails
26
+ fail :handle # only runs on failure track
27
+
28
+ def init(state)
29
+ state[:model] = Article.new
30
+ # must return truthy to continue, falsy switches to failure track
31
+ end
32
+
33
+ def save(state)
34
+ state[:model].save # returns true/false naturally
35
+ end
36
+
37
+ def notify(state)
38
+ # side effect, return value doesn't matter
39
+ end
40
+
41
+ def handle(state)
42
+ # runs on failure track
43
+ # return truthy to recover back to success track
44
+ # return falsy to stay on failure track
45
+ false
46
+ end
47
+ end
48
+ ```
49
+
50
+ ### Step types
51
+
52
+ | Type | Runs when | Truthy return | Falsy return |
53
+ |--------|------------------|-----------------------|-------------------------|
54
+ | `step` | On success track | Continue success | Switch to failure track |
55
+ | `fail` | On failure track | Recover to success | Continue failure |
56
+ | `pass` | On success track | Continue success | Continue success |
57
+
58
+ ### Step actions
59
+
60
+ A step action can be:
61
+ - **Symbol** — calls instance method with `(state)` argument
62
+ - **Lambda/Proc** — called with `(state)` argument
63
+ - **Any object responding to `.call`** — called with `(state)` argument
64
+
65
+ ### State
66
+
67
+ State is a plain Ruby hash passed to `.call`. It is mutable — steps read from and write to it. The result's state is a frozen duplicate.
68
+
69
+ ```ruby
70
+ result = MyOperation.call(user: user, params: params_hash)
71
+ ```
72
+
73
+ ### Result
74
+
75
+ ```ruby
76
+ result.succeeded? # => true/false
77
+ result.failed? # => true/false
78
+ result.state # => frozen hash
79
+ result[:key] # => shorthand for result.state[:key]
80
+ result.operation # => the operation instance
81
+ ```
82
+
83
+ ### Nested operations
84
+
85
+ Use `Nested::Operation` to call one operation from within another. State is merged back. The nested result's `succeeded?` determines if the parent continues on success or failure track.
86
+
87
+ ```ruby
88
+ class CreateArticleOperation < Operational::Operation
89
+ class Present < Operational::Operation
90
+ step :init
91
+ step Contract::Build(contract: ArticleForm)
92
+
93
+ def init(state)
94
+ state[:model] = Article.new
95
+ end
96
+ end
97
+
98
+ step Nested::Operation(operation: Present)
99
+ step Contract::Validate()
100
+ step Contract::Sync()
101
+ pass :persist
102
+
103
+ def persist(state)
104
+ state[:model].save!
105
+ end
106
+ end
107
+ ```
108
+
109
+ ## Forms
110
+
111
+ Subclass `Operational::Form`. Uses `ActiveModel::Model`, `ActiveModel::Attributes`, `ActiveModel::Dirty`.
112
+
113
+ ```ruby
114
+ class ArticleForm < Operational::Form
115
+ attribute :title, :string
116
+ attribute :body, :string
117
+
118
+ validates :title, presence: true
119
+ validates :body, presence: true
120
+ end
121
+ ```
122
+
123
+ ### Form.build
124
+
125
+ ```ruby
126
+ Form.build(
127
+ model: nil, # ActiveModel instance — copies matching attributes to form
128
+ model_persisted: nil, # override persisted? detection (true/false/nil)
129
+ state: {}, # context hash, available as @state in the form
130
+ build_method: :on_build # method to call during build
131
+ )
132
+ ```
133
+
134
+ - Only attributes defined on the form are copied from the model (nil values are skipped)
135
+ - State is frozen and stored as `@state`
136
+ - If the form defines `on_build(state)`, it is called during build after attribute assignment
137
+ - `changes_applied` is called after build so dirty tracking starts clean
138
+
139
+ ### Form.validate
140
+
141
+ ```ruby
142
+ form.validate(params_hash) # => true/false
143
+ ```
144
+
145
+ - Converts `ActionController::Parameters` automatically via `to_unsafe_h`
146
+ - Only assigns params matching defined attributes (ignores unknown keys)
147
+ - Calls `valid?` and returns the result
148
+
149
+ ### Form.sync
150
+
151
+ ```ruby
152
+ form.sync(
153
+ model: nil, # ActiveModel instance — copies matching attributes back
154
+ state: {}, # passed to on_sync
155
+ sync_method: :on_sync # custom hook method name
156
+ )
157
+ ```
158
+
159
+ - Copies form attributes to model where attribute names match
160
+ - Calls `on_sync(state)` if defined on the form
161
+ - Always returns `true`
162
+
163
+ ### Important: do NOT define `#sync` on a form subclass
164
+
165
+ Defining `#sync` raises `MethodCollision`. Use `#on_sync` instead — it is called automatically during sync.
166
+
167
+ ### Helper methods
168
+
169
+ - `persisted?` — returns whether the model was persisted at build time
170
+ - `other_validators_have_passed?` — returns `errors.blank?`, useful for conditional validators
171
+ - `@state` — access the frozen state hash passed at build time
172
+
173
+ ## Contract step helpers
174
+
175
+ These are used inside operations as step actions. They return lambdas.
176
+
177
+ ### Contract::Build
178
+
179
+ ```ruby
180
+ step Contract::Build(
181
+ contract: MyForm, # required — the form class
182
+ name: :contract, # state key to store the form instance
183
+ model_key: :model, # state key containing the model to build from (used only if present in state)
184
+ model_persisted: nil, # override persisted? detection
185
+ build_method: :on_build
186
+ )
187
+ ```
188
+
189
+ Always returns `true`.
190
+
191
+ ### Contract::Validate
192
+
193
+ ```ruby
194
+ step Contract::Validate(
195
+ name: :contract, # state key where the form is stored
196
+ params_path: nil # nil → state[:params]
197
+ # :symbol → state[:params][:symbol]
198
+ # [:a, :b] → state.dig(:a, :b)
199
+ )
200
+ ```
201
+
202
+ Returns the result of `form.validate(params)` — `true`/`false`.
203
+
204
+ ### Contract::Sync
205
+
206
+ ```ruby
207
+ step Contract::Sync(
208
+ name: :contract, # state key where the form is stored
209
+ model_key: :model, # state key containing the model to sync to
210
+ sync_method: :on_sync # custom sync hook method name
211
+ )
212
+ ```
213
+
214
+ Returns `true` (from `form.sync`).
215
+
216
+ ## Controller mixin
217
+
218
+ ```ruby
219
+ class MyController < ApplicationController
220
+ include Operational::Controller
221
+
222
+ def create
223
+ if run CreateArticleOperation
224
+ redirect_to @state[:model]
225
+ else
226
+ render :new, status: :unprocessable_entity
227
+ end
228
+ end
229
+ end
230
+ ```
231
+
232
+ ### run(operation, **extras)
233
+
234
+ - Merges `extras` with default state (`params` and `current_user` if available)
235
+ - Calls `operation.call(state)`
236
+ - Sets `@state` to the frozen result state
237
+ - Returns `result.succeeded?`
238
+
239
+ ### Overridable methods
240
+
241
+ - `_operational_default_state` — override to inject custom default state
242
+ - `_operational_state_variable` — override to change the instance variable name (default: `@state`)
243
+
244
+ ## Errors
245
+
246
+ | Error class | Raised when |
247
+ |---|---|
248
+ | `Operational::InvalidContractModel` | Model doesn't respond to `attributes` |
249
+ | `Operational::UnknownStepType` | Step action is not a Symbol or callable |
250
+ | `Operational::MethodCollision` | Form subclass defines `#sync` instead of `#on_sync` |
251
+
252
+ ## File structure convention
253
+
254
+ ```
255
+ app/concepts/<domain>/
256
+ <name>_form.rb
257
+ <name>_operation.rb
258
+ ```
259
+
260
+ Example: `app/concepts/article/article_form.rb`, `app/concepts/article/create_article_operation.rb`
261
+
262
+ ## Common patterns
263
+
264
+ ### New/Create with nested Present
265
+
266
+ ```ruby
267
+ class CreateThingOperation < Operational::Operation
268
+ class Present < Operational::Operation
269
+ step :init
270
+ step Contract::Build(contract: ThingForm)
271
+
272
+ def init(state)
273
+ state[:model] = Thing.new
274
+ end
275
+ end
276
+
277
+ step Nested::Operation(operation: Present)
278
+ step Contract::Validate()
279
+ step Contract::Sync()
280
+ pass :persist
281
+
282
+ def persist(state)
283
+ state[:model].save!
284
+ end
285
+ end
286
+ ```
287
+
288
+ Controller uses `CreateThingOperation::Present` for `new` and `CreateThingOperation` for `create`.
289
+
290
+ To use a descriptive state key instead of `:model`, pass `model_key:` explicitly:
291
+
292
+ ```ruby
293
+ step Contract::Build(contract: ThingForm, model_key: :thing)
294
+ step Contract::Sync(model_key: :thing)
295
+ # state[:thing] instead of state[:model]
296
+ ```
297
+
298
+ ### Multi-model form
299
+
300
+ ```ruby
301
+ class OrderForm < Operational::Form
302
+ attribute :item_name, :string
303
+ attribute :shipping_address, :string
304
+
305
+ def on_build(state)
306
+ self.shipping_address = state[:user]&.default_address
307
+ end
308
+
309
+ def on_sync(state)
310
+ state[:shipping].update!(address: shipping_address)
311
+ end
312
+ end
313
+ ```
314
+
315
+ ### State-dependent validation
316
+
317
+ ```ruby
318
+ class ArticleForm < Operational::Form
319
+ attribute :published, :boolean
320
+ validate :admin_only_publish
321
+
322
+ def admin_only_publish
323
+ if published && !@state[:current_user]&.admin?
324
+ errors.add(:published, "requires admin privileges")
325
+ end
326
+ end
327
+ end
328
+ ```
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [Unreleased]
8
+
9
+ - Initial release
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Bryan Rite
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.