dexkit 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 +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +108 -0
- data/guides/llm/OPERATION.md +553 -0
- data/lib/dex/error.rb +17 -0
- data/lib/dex/match.rb +13 -0
- data/lib/dex/operation/async_proxy.rb +115 -0
- data/lib/dex/operation/async_wrapper.rb +35 -0
- data/lib/dex/operation/callback_wrapper.rb +113 -0
- data/lib/dex/operation/jobs.rb +64 -0
- data/lib/dex/operation/lock_wrapper.rb +77 -0
- data/lib/dex/operation/outcome.rb +70 -0
- data/lib/dex/operation/pipeline.rb +60 -0
- data/lib/dex/operation/props_setup.rb +50 -0
- data/lib/dex/operation/record_backend.rb +64 -0
- data/lib/dex/operation/record_wrapper.rb +135 -0
- data/lib/dex/operation/rescue_wrapper.rb +63 -0
- data/lib/dex/operation/result_wrapper.rb +92 -0
- data/lib/dex/operation/safe_wrapper.rb +9 -0
- data/lib/dex/operation/settings.rb +26 -0
- data/lib/dex/operation/transaction_adapter.rb +54 -0
- data/lib/dex/operation/transaction_wrapper.rb +87 -0
- data/lib/dex/operation.rb +192 -0
- data/lib/dex/ref_type.rb +34 -0
- data/lib/dex/test_helpers/assertions.rb +310 -0
- data/lib/dex/test_helpers/execution.rb +28 -0
- data/lib/dex/test_helpers/stubbing.rb +59 -0
- data/lib/dex/test_helpers.rb +146 -0
- data/lib/dex/test_log.rb +55 -0
- data/lib/dex/version.rb +5 -0
- data/lib/dexkit.rb +57 -0
- metadata +160 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7be5c6e58f2741692419cf25f9a8e76df8003036dd17139073561fe5141e3bdc
|
|
4
|
+
data.tar.gz: 28f77abedf2552c3a2f319abd175f61855f956517295e1ea435a3dbf88327735
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 800756f2f2f22dc4e487794fc129ccafc57ee815b3e26aea67c1f8eef9195654106cd7ab7b91525c4e5325da1696e94069ffc78e2d1302dc1f7a5d6f6eff8e82
|
|
7
|
+
data.tar.gz: 7bf8212783439db36adb9ae04854f981d2e2e6d9db8ca7bf9cd0768faa566aec98addc56f12640b7d0c284e3a7f4ca9d8afa05ba1f90568241970214bd8307d5
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-03-01
|
|
4
|
+
|
|
5
|
+
- Initial public release
|
|
6
|
+
- **Operation** base class with typed properties (via `literal` gem), structured errors, and Ok/Err result pattern
|
|
7
|
+
- Contract DSL: `success` and `error` declarations with runtime validation
|
|
8
|
+
- Safe execution: `.safe.call` returns Ok/Err instead of raising
|
|
9
|
+
- Rescue mapping: `rescue_from` to convert exceptions into typed errors
|
|
10
|
+
- Callbacks: `before`, `after`, `around` lifecycle hooks
|
|
11
|
+
- Async execution via ActiveJob (`async` DSL)
|
|
12
|
+
- Transaction support for ActiveRecord and Mongoid
|
|
13
|
+
- Advisory locking via `with_advisory_lock` gem
|
|
14
|
+
- Operation recording (persistence of execution results to DB)
|
|
15
|
+
- `Dex::RefType` — model reference type with automatic `find` coercion
|
|
16
|
+
- **Test helpers**: `call_operation`, `call_operation!`, result/contract/param assertions, async and transaction assertions, batch assertions
|
|
17
|
+
- **Stubbing & spying**: `stub_operation`, `spy_on_operation`
|
|
18
|
+
- **TestLog**: global activity log for test introspection
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jacek Galanciak
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Dexkit
|
|
2
|
+
|
|
3
|
+
Rails patterns toolbelt. Equip to gain +4 DEX.
|
|
4
|
+
|
|
5
|
+
**[Documentation](https://dex.razorjack.net)**
|
|
6
|
+
|
|
7
|
+
## Operations
|
|
8
|
+
|
|
9
|
+
Service objects with typed properties, transactions, error handling, and more.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class CreateUser < Dex::Operation
|
|
13
|
+
prop :name, String
|
|
14
|
+
prop :email, String
|
|
15
|
+
|
|
16
|
+
success _Ref(User)
|
|
17
|
+
error :email_taken
|
|
18
|
+
|
|
19
|
+
def perform
|
|
20
|
+
error!(:email_taken) if User.exists?(email: email)
|
|
21
|
+
User.create!(name: name, email: email)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
user = CreateUser.call(name: "Alice", email: "alice@example.com")
|
|
26
|
+
user.name # => "Alice"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### What you get out of the box
|
|
30
|
+
|
|
31
|
+
**Typed properties** – powered by [literal](https://github.com/joeldrapper/literal). Plain classes, ranges, unions, arrays, nilable, and model references with auto-find:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
prop :amount, _Integer(1..)
|
|
35
|
+
prop :currency, _Union("USD", "EUR", "GBP")
|
|
36
|
+
prop :user, _Ref(User) # accepts User instance or ID
|
|
37
|
+
prop? :note, String # optional (nil by default)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Structured errors** with `error!`, `assert!`, and `rescue_from`:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
user = assert!(:not_found) { User.find_by(id: user_id) }
|
|
44
|
+
|
|
45
|
+
rescue_from Stripe::CardError, as: :card_declined
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Ok / Err** – pattern match on operation outcomes with `.safe.call`:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
include Dex::Match
|
|
52
|
+
|
|
53
|
+
case CreateUser.new(email: email).safe.call
|
|
54
|
+
in Ok(name:)
|
|
55
|
+
puts "Welcome, #{name}!"
|
|
56
|
+
in Err(code: :email_taken)
|
|
57
|
+
puts "Already registered"
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Async execution** via ActiveJob:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
SendWelcomeEmail.new(user_id: 123).async(queue: "mailers").call
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Transactions** on by default, **advisory locking**, **recording** to database, **callbacks**, and a customizable **pipeline** – all composable, all optional.
|
|
68
|
+
|
|
69
|
+
### Testing
|
|
70
|
+
|
|
71
|
+
First-class test helpers for Minitest:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
class CreateUserTest < Minitest::Test
|
|
75
|
+
testing CreateUser
|
|
76
|
+
|
|
77
|
+
def test_creates_user
|
|
78
|
+
assert_operation(name: "Alice", email: "alice@example.com")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_rejects_duplicate_email
|
|
82
|
+
assert_operation_error(:email_taken, name: "Alice", email: "taken@example.com")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Installation
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
gem "dexkit"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
Full documentation at **[dex.razorjack.net](https://dex.razorjack.net)**.
|
|
96
|
+
|
|
97
|
+
## AI Coding Assistant Setup
|
|
98
|
+
|
|
99
|
+
Dexkit ships LLM-optimized guides. Copy them into your project so AI agents automatically know the API:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
|
|
103
|
+
cp $(bundle show dexkit)/guides/llm/TESTING.md test/CLAUDE.md
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# Dex::Operation — LLM Reference
|
|
2
|
+
|
|
3
|
+
Copy this to your app's operations directory (e.g., `app/operations/AGENTS.md`) so coding agents know the full API when implementing and testing operations.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Reference Operation
|
|
8
|
+
|
|
9
|
+
All examples below build on this operation unless noted otherwise:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class CreateUser < Dex::Operation
|
|
13
|
+
prop :email, String
|
|
14
|
+
prop :name, String
|
|
15
|
+
prop? :role, _Union("admin", "member"), default: "member"
|
|
16
|
+
|
|
17
|
+
success _Ref(User)
|
|
18
|
+
error :email_taken, :invalid_email
|
|
19
|
+
|
|
20
|
+
def perform
|
|
21
|
+
error!(:invalid_email) unless email.include?("@")
|
|
22
|
+
error!(:email_taken) if User.exists?(email: email)
|
|
23
|
+
User.create!(email: email, name: name, role: role)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Calling:**
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
CreateUser.call(email: "a@b.com", name: "Alice") # shorthand for new(...).call
|
|
32
|
+
CreateUser.new(email: "a@b.com", name: "Alice").safe.call # Ok/Err wrapper
|
|
33
|
+
CreateUser.new(email: "a@b.com", name: "Alice").async.call # background job
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Use `new(...)` form when chaining modifiers (`.safe`, `.async`).
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Properties
|
|
41
|
+
|
|
42
|
+
Define typed inputs with `prop` (required) and `prop?` (optional — nilable, defaults to `nil`). Access directly as reader methods (`name`, `user`). Invalid values raise `Literal::TypeError`.
|
|
43
|
+
|
|
44
|
+
Reserved names: `call`, `perform`, `async`, `safe`, `initialize`.
|
|
45
|
+
|
|
46
|
+
### Literal Types Cheatsheet
|
|
47
|
+
|
|
48
|
+
Types use `===` for validation. All constructors available in the operation class body:
|
|
49
|
+
|
|
50
|
+
| Constructor | Description | Example |
|
|
51
|
+
|-------------|-------------|---------|
|
|
52
|
+
| Plain class | Matches with `===` | `String`, `Integer`, `Float`, `Hash`, `Array` |
|
|
53
|
+
| `_Integer(range)` | Constrained integer | `_Integer(1..)`, `_Integer(0..100)` |
|
|
54
|
+
| `_String(constraints)` | Constrained string | `_String(length: 1..500)` |
|
|
55
|
+
| `_Array(type)` | Typed array | `_Array(Integer)`, `_Array(String)` |
|
|
56
|
+
| `_Union(*values)` | Enum of values | `_Union("USD", "EUR", "GBP")` |
|
|
57
|
+
| `_Nilable(type)` | Nilable wrapper | `_Nilable(String)` |
|
|
58
|
+
| `_Ref(Model)` | Model reference | `_Ref(User)`, `_Ref(Account, lock: true)` |
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
prop :name, String # any String
|
|
62
|
+
prop :count, Integer # any Integer
|
|
63
|
+
prop :amount, Float # any Float
|
|
64
|
+
prop :amount, BigDecimal # any BigDecimal
|
|
65
|
+
prop :data, Hash # any Hash
|
|
66
|
+
prop :items, Array # any Array
|
|
67
|
+
prop :active, _Boolean # true or false
|
|
68
|
+
prop :role, Symbol # any Symbol
|
|
69
|
+
prop :count, _Integer(1..) # Integer >= 1
|
|
70
|
+
prop :count, _Integer(0..100) # Integer 0–100
|
|
71
|
+
prop :name, _String(length: 1..255) # String with length constraint
|
|
72
|
+
prop :score, _Float(0.0..1.0) # Float in range
|
|
73
|
+
prop :tags, _Array(String) # Array of Strings
|
|
74
|
+
prop :ids, _Array(Integer) # Array of Integers
|
|
75
|
+
prop :matrix, _Array(_Array(Integer)) # nested typed arrays
|
|
76
|
+
prop :currency, _Union("USD", "EUR", "GBP") # enum of values
|
|
77
|
+
prop :id, _Union(String, Integer) # union of types
|
|
78
|
+
prop :label, _Nilable(String) # String or nil
|
|
79
|
+
prop :meta, _Hash(Symbol, String) # Hash with typed keys+values
|
|
80
|
+
prop :pair, _Tuple(String, Integer) # fixed-size typed array
|
|
81
|
+
prop :name, _Frozen(String) # must be frozen
|
|
82
|
+
prop :handler, _Callable # anything responding to .call
|
|
83
|
+
prop :handler, _Interface(:call, :arity) # responds to listed methods
|
|
84
|
+
prop :user, _Ref(User) # Dex-specific: model by instance or ID
|
|
85
|
+
prop :account, _Ref(Account, lock: true) # Dex-specific: with row lock
|
|
86
|
+
prop :title, String, default: "Untitled" # default value
|
|
87
|
+
prop? :note, String # optional (nilable, default: nil)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### _Ref(Model)
|
|
91
|
+
|
|
92
|
+
Accepts model instances or IDs, coerces IDs via `Model.find(id)`. With `lock: true`, uses `Model.lock.find(id)` (SELECT FOR UPDATE). Instances pass through without re-locking. In serialization (recording, async), stores model ID only.
|
|
93
|
+
|
|
94
|
+
Outside the class body (e.g., in tests), use `Dex::RefType.new(Model)` instead of `_Ref(Model)`.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Contract
|
|
99
|
+
|
|
100
|
+
Optional declarations documenting intent and catching mistakes at runtime.
|
|
101
|
+
|
|
102
|
+
**`success(type)`** — validates return value (`nil` always allowed; raises `ArgumentError` on mismatch):
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
success _Ref(User) # perform must return a User (or nil)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**`error(*codes)`** — restricts which codes `error!`/`assert!` accept (raises `ArgumentError` on undeclared):
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
error :email_taken, :invalid_email
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Both inherit from parent class. Without `error` declaration, any code is accepted.
|
|
115
|
+
|
|
116
|
+
**Introspection:** `MyOp.contract` returns a frozen `Data` with `params`, `success`, `errors` fields. Supports pattern matching and `to_h`.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Flow Control
|
|
121
|
+
|
|
122
|
+
All three halt execution immediately via non-local exit (work from `perform`, helpers, and callbacks).
|
|
123
|
+
|
|
124
|
+
**`error!(code, message = nil, details: nil)`** — halt with failure, roll back transaction, raise `Dex::Error`:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
error!(:not_found, "User not found")
|
|
128
|
+
error!(:validation_failed, details: { field: "email" })
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**`success!(value = nil, **attrs)`** — halt with success, commit transaction:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
success!(user) # return value early
|
|
135
|
+
success!(name: "John", age: 30) # kwargs become Hash
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**`assert!(code, &block)` / `assert!(value, code)`** — returns value if truthy, otherwise `error!(code)`:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
user = assert!(:not_found) { User.find_by(id: id) }
|
|
142
|
+
assert!(user.active?, :inactive)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Dex::Error** has `code` (Symbol), `message` (String, defaults to code.to_s), `details` (any). Pattern matching:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
begin
|
|
149
|
+
CreateUser.call(email: "bad", name: "A")
|
|
150
|
+
rescue Dex::Error => e
|
|
151
|
+
case e
|
|
152
|
+
in {code: :not_found} then handle_not_found
|
|
153
|
+
in {code: :validation_failed, details: {field:}} then handle_field(field)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Key differences:** `error!`/`assert!` roll back transaction, skip `after` callbacks and recording. `success!` commits, runs `after` callbacks, records normally.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Safe Execution (Ok/Err)
|
|
163
|
+
|
|
164
|
+
`.safe.call` wraps results instead of raising. Only catches `Dex::Error` — other exceptions propagate normally.
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
result = CreateUser.new(email: "a@b.com", name: "Alice").safe.call
|
|
168
|
+
|
|
169
|
+
# Ok
|
|
170
|
+
result.ok? # => true
|
|
171
|
+
result.value # => User instance (also: result.name delegates to value)
|
|
172
|
+
|
|
173
|
+
# Err
|
|
174
|
+
result.error? # => true
|
|
175
|
+
result.code # => :email_taken
|
|
176
|
+
result.message # => "email_taken"
|
|
177
|
+
result.details # => nil or Hash
|
|
178
|
+
result.value! # re-raises Dex::Error
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Pattern matching:**
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
case CreateUser.new(email: "a@b.com", name: "Alice").safe.call
|
|
185
|
+
in Dex::Ok(name:) then puts "Created #{name}"
|
|
186
|
+
in Dex::Err(code: :email_taken) then puts "Already exists"
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`include Dex::Match` to use `Ok`/`Err` without `Dex::` prefix.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Rescue Mapping
|
|
195
|
+
|
|
196
|
+
Map exceptions to structured `Dex::Error` codes — eliminates begin/rescue boilerplate:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class ChargeCard < Dex::Operation
|
|
200
|
+
rescue_from Stripe::CardError, as: :card_declined
|
|
201
|
+
rescue_from Stripe::RateLimitError, as: :rate_limited
|
|
202
|
+
rescue_from Net::OpenTimeout, Net::ReadTimeout, as: :timeout
|
|
203
|
+
rescue_from Stripe::APIError, as: :provider_error, message: "Stripe is down"
|
|
204
|
+
|
|
205
|
+
def perform
|
|
206
|
+
Stripe::Charge.create(amount: amount, source: token)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- `as:` (required): error code Symbol. `message:` (optional): overrides exception message
|
|
212
|
+
- Original exception preserved in `err.details[:original]`
|
|
213
|
+
- Subclass exceptions match parent handlers; child handlers take precedence
|
|
214
|
+
- Converted errors trigger transaction rollback and work with `.safe` (consistent with `error!`)
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Callbacks
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
class ProcessOrder < Dex::Operation
|
|
222
|
+
before :validate_stock # symbol → instance method
|
|
223
|
+
before -> { log("starting") } # lambda (instance_exec'd)
|
|
224
|
+
after :send_confirmation # runs after successful perform
|
|
225
|
+
around :with_timing # wraps everything, must yield
|
|
226
|
+
|
|
227
|
+
def validate_stock
|
|
228
|
+
error!(:out_of_stock) unless in_stock?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def with_timing
|
|
232
|
+
start = Time.now
|
|
233
|
+
yield
|
|
234
|
+
puts Time.now - start
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
- **Order:** `around` wraps → `before` → `perform` → `after`
|
|
240
|
+
- `around` with proc/lambda: receives continuation arg, call `cont.call`
|
|
241
|
+
- `before` calling `error!` stops everything; `after` skipped on error
|
|
242
|
+
- Callbacks run **inside** the transaction — errors trigger rollback
|
|
243
|
+
- Inheritance: parent callbacks run first, then child
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Transactions
|
|
248
|
+
|
|
249
|
+
Operations run inside database transactions by default. All changes roll back on error. Nested operations share the outer transaction.
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
transaction false # disable
|
|
253
|
+
transaction :mongoid # adapter override (default: auto-detect AR → Mongoid)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Child classes can re-enable: `transaction true`.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Advisory Locking
|
|
261
|
+
|
|
262
|
+
Mutual exclusion via database advisory locks (requires `with_advisory_lock` gem). Wraps **outside** the transaction.
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
advisory_lock { "pay:#{charge_id}" } # dynamic key from props
|
|
266
|
+
advisory_lock "daily-report" # static key
|
|
267
|
+
advisory_lock "report", timeout: 5 # with timeout (seconds)
|
|
268
|
+
advisory_lock # class name as key
|
|
269
|
+
advisory_lock :compute_key # instance method
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
On timeout: raises `Dex::Error(code: :lock_timeout)`. Works with `.safe`.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Async Execution
|
|
277
|
+
|
|
278
|
+
Enqueue as background jobs (requires ActiveJob):
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
CreateUser.new(email: "a@b.com", name: "Alice").async.call
|
|
282
|
+
CreateUser.new(email: "a@b.com", name: "Alice").async(queue: "urgent").call
|
|
283
|
+
CreateUser.new(email: "a@b.com", name: "Alice").async(in: 5.minutes).call
|
|
284
|
+
CreateUser.new(email: "a@b.com", name: "Alice").async(at: 1.hour.from_now).call
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Class-level defaults: `async queue: "mailers"`. Runtime options override.
|
|
288
|
+
|
|
289
|
+
Props serialize/deserialize automatically (Date, Time, BigDecimal, Symbol, `_Ref` — all handled). Non-serializable props raise `ArgumentError` at enqueue time.
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Recording
|
|
294
|
+
|
|
295
|
+
Record execution to database. Requires `Dex.configure { |c| c.record_class = OperationRecord }`.
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
create_table :operation_records do |t|
|
|
299
|
+
t.string :name # Required: operation class name
|
|
300
|
+
t.jsonb :params # Optional: serialized props
|
|
301
|
+
t.jsonb :response # Optional: serialized result
|
|
302
|
+
t.string :status # Optional: pending/running/done/failed (for async)
|
|
303
|
+
t.string :error # Optional: error code on failure
|
|
304
|
+
t.datetime :performed_at # Optional
|
|
305
|
+
t.timestamps
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Control per-operation:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
record false # disable entirely
|
|
313
|
+
record response: false # params only
|
|
314
|
+
record params: false # response only
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Recording happens inside the transaction — rolled back on `error!`/`assert!`. Missing columns silently skipped.
|
|
318
|
+
|
|
319
|
+
When both async and recording are enabled, Dexkit automatically stores only the record ID in the job payload instead of full params. The record tracks `status` (pending → running → done/failed) and `error` (code or exception class name).
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Configuration
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
# config/initializers/dexkit.rb
|
|
327
|
+
Dex.configure do |config|
|
|
328
|
+
config.record_class = OperationRecord # model for recording (default: nil)
|
|
329
|
+
config.transaction_adapter = nil # auto-detect (default); or :active_record / :mongoid
|
|
330
|
+
end
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
All DSL methods validate arguments at declaration time — typos and wrong types raise `ArgumentError` immediately (e.g., `error "string"`, `async priority: 5`, `transaction :redis`).
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## Testing
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
# test/test_helper.rb
|
|
341
|
+
require "dex/test_helpers"
|
|
342
|
+
|
|
343
|
+
class Minitest::Test
|
|
344
|
+
include Dex::TestHelpers
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Not autoloaded — stays out of production. TestLog and stubs are auto-cleared in `setup`.
|
|
349
|
+
|
|
350
|
+
### Subject & Execution
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
class CreateUserTest < Minitest::Test
|
|
354
|
+
include Dex::TestHelpers
|
|
355
|
+
|
|
356
|
+
testing CreateUser # default for all helpers
|
|
357
|
+
|
|
358
|
+
def test_example
|
|
359
|
+
result = call_operation(email: "a@b.com", name: "Alice") # => Ok or Err (safe)
|
|
360
|
+
value = call_operation!(email: "a@b.com", name: "Alice") # => raw value or raises
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
All helpers accept an explicit class as first arg: `call_operation(OtherOp, name: "x")`.
|
|
366
|
+
|
|
367
|
+
### Result Assertions
|
|
368
|
+
|
|
369
|
+
```ruby
|
|
370
|
+
assert_ok result # passes if Ok
|
|
371
|
+
assert_ok result, user # checks value equality
|
|
372
|
+
assert_ok(result) { |val| assert val } # yields value
|
|
373
|
+
|
|
374
|
+
assert_err result # passes if Err
|
|
375
|
+
assert_err result, :not_found # checks code
|
|
376
|
+
assert_err result, :fail, message: "x" # checks message (String or Regex)
|
|
377
|
+
assert_err result, :fail, details: { field: "email" }
|
|
378
|
+
assert_err(result, :fail) { |err| assert err } # yields Dex::Error
|
|
379
|
+
|
|
380
|
+
refute_ok result
|
|
381
|
+
refute_err result
|
|
382
|
+
refute_err result, :not_found # Ok OR different code
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### One-Liner Assertions
|
|
386
|
+
|
|
387
|
+
Call + assert in one step:
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
assert_operation(email: "a@b.com", name: "Alice") # Ok
|
|
391
|
+
assert_operation(CreateUser, email: "a@b.com", name: "Alice") # explicit class
|
|
392
|
+
assert_operation(email: "a@b.com", name: "Alice", returns: user) # check value
|
|
393
|
+
|
|
394
|
+
assert_operation_error(:invalid_email, email: "bad", name: "A")
|
|
395
|
+
assert_operation_error(CreateUser, :email_taken, email: "taken@b.com", name: "A")
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Contract Assertions
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
assert_params(:name, :email, :role) # exhaustive names (order-independent)
|
|
402
|
+
assert_params(name: String, email: String) # with types
|
|
403
|
+
assert_accepts_param(:name) # subset check
|
|
404
|
+
|
|
405
|
+
assert_success_type(Dex::RefType.new(User)) # use Dex::RefType outside class body
|
|
406
|
+
assert_error_codes(:email_taken, :invalid_email)
|
|
407
|
+
|
|
408
|
+
assert_contract(
|
|
409
|
+
params: [:name, :email, :role],
|
|
410
|
+
success: Dex::RefType.new(User),
|
|
411
|
+
errors: [:email_taken, :invalid_email]
|
|
412
|
+
)
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### Param Validation
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
assert_invalid_params(name: 123) # asserts Literal::TypeError
|
|
419
|
+
assert_valid_params(email: "a@b.com", name: "Alice") # no error (doesn't call perform)
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Async & Transaction Assertions
|
|
423
|
+
|
|
424
|
+
Async requires `ActiveJob::TestHelper`:
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
assert_enqueues_operation(email: "a@b.com", name: "Alice")
|
|
428
|
+
assert_enqueues_operation(CreateUser, email: "a@b.com", name: "Alice", queue: "default")
|
|
429
|
+
refute_enqueues_operation { do_something }
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Transaction:
|
|
433
|
+
|
|
434
|
+
```ruby
|
|
435
|
+
assert_rolls_back(User) { CreateUser.call(email: "bad", name: "A") }
|
|
436
|
+
assert_commits(User) { CreateUser.call(email: "ok@b.com", name: "A") }
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Batch Assertions
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
assert_all_succeed(params_list: [
|
|
443
|
+
{ email: "a@b.com", name: "A" },
|
|
444
|
+
{ email: "b@b.com", name: "B" }
|
|
445
|
+
])
|
|
446
|
+
|
|
447
|
+
assert_all_fail(code: :invalid_email, params_list: [
|
|
448
|
+
{ email: "", name: "A" },
|
|
449
|
+
{ email: "no-at", name: "B" }
|
|
450
|
+
])
|
|
451
|
+
# Also supports message: and details: options
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Stubbing
|
|
455
|
+
|
|
456
|
+
Replace an operation within a block. Bypasses all wrappers, not recorded in TestLog:
|
|
457
|
+
|
|
458
|
+
```ruby
|
|
459
|
+
stub_operation(SendWelcomeEmail, returns: "fake") do
|
|
460
|
+
call_operation!(email: "a@b.com", name: "Alice")
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
stub_operation(SendWelcomeEmail, error: :not_found) do
|
|
464
|
+
# raises Dex::Error(code: :not_found)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
stub_operation(SendWelcomeEmail, error: { code: :fail, message: "oops" }) do
|
|
468
|
+
# raises Dex::Error with code and message
|
|
469
|
+
end
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### Spying
|
|
473
|
+
|
|
474
|
+
Observe real execution without modifying behavior:
|
|
475
|
+
|
|
476
|
+
```ruby
|
|
477
|
+
spy_on_operation(SendWelcomeEmail) do |spy|
|
|
478
|
+
CreateUser.call(email: "a@b.com", name: "Alice")
|
|
479
|
+
|
|
480
|
+
spy.called? # => true
|
|
481
|
+
spy.called_once? # => true
|
|
482
|
+
spy.call_count # => 1
|
|
483
|
+
spy.last_result # => Ok or Err
|
|
484
|
+
spy.called_with?(email: "a@b.com") # => true (subset match)
|
|
485
|
+
end
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### TestLog
|
|
489
|
+
|
|
490
|
+
Global log of all operation calls:
|
|
491
|
+
|
|
492
|
+
```ruby
|
|
493
|
+
Dex::TestLog.calls # all entries
|
|
494
|
+
Dex::TestLog.find(CreateUser) # filter by class
|
|
495
|
+
Dex::TestLog.find(CreateUser, email: "a@b.com") # filter by class + params
|
|
496
|
+
Dex::TestLog.size; Dex::TestLog.empty?; Dex::TestLog.clear!
|
|
497
|
+
Dex::TestLog.summary # human-readable for failure messages
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Each entry has: `name`, `operation_class`, `params`, `result` (Ok/Err), `duration`, `caller_location`.
|
|
501
|
+
|
|
502
|
+
### Complete Test Example
|
|
503
|
+
|
|
504
|
+
```ruby
|
|
505
|
+
class CreateUserTest < Minitest::Test
|
|
506
|
+
include Dex::TestHelpers
|
|
507
|
+
|
|
508
|
+
testing CreateUser
|
|
509
|
+
|
|
510
|
+
def test_contract
|
|
511
|
+
assert_params(:name, :email, :role)
|
|
512
|
+
assert_success_type(Dex::RefType.new(User))
|
|
513
|
+
assert_error_codes(:email_taken, :invalid_email)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def test_creates_user
|
|
517
|
+
result = call_operation(email: "a@b.com", name: "Alice")
|
|
518
|
+
assert_ok(result) { |user| assert_equal "Alice", user.name }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def test_one_liner
|
|
522
|
+
assert_operation(email: "a@b.com", name: "Alice")
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def test_rejects_bad_email
|
|
526
|
+
assert_operation_error(:invalid_email, email: "bad", name: "A")
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def test_batch_rejects
|
|
530
|
+
assert_all_fail(code: :invalid_email, params_list: [
|
|
531
|
+
{ email: "", name: "A" },
|
|
532
|
+
{ email: "no-at", name: "B" }
|
|
533
|
+
])
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def test_stubs_dependency
|
|
537
|
+
stub_operation(SendWelcomeEmail, returns: true) do
|
|
538
|
+
call_operation!(email: "a@b.com", name: "Alice")
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def test_spies_on_dependency
|
|
543
|
+
spy_on_operation(SendWelcomeEmail) do |spy|
|
|
544
|
+
call_operation!(email: "a@b.com", name: "Alice")
|
|
545
|
+
assert spy.called_once?
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
**End of reference.**
|