dexkit 0.6.0 → 0.7.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/CHANGELOG.md +18 -0
- data/README.md +63 -0
- data/guides/llm/EVENT.md +31 -9
- data/guides/llm/OPERATION.md +246 -49
- data/lib/dex/context_setup.rb +64 -0
- data/lib/dex/event.rb +1 -0
- data/lib/dex/operation/async_proxy.rb +18 -2
- data/lib/dex/operation/guard_wrapper.rb +138 -0
- data/lib/dex/operation/jobs.rb +18 -11
- data/lib/dex/operation/once_wrapper.rb +240 -0
- data/lib/dex/operation/record_backend.rb +75 -0
- data/lib/dex/operation/record_wrapper.rb +87 -20
- data/lib/dex/operation.rb +18 -4
- data/lib/dex/test_helpers/assertions.rb +23 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +16 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34e5457c2e95efc96b2350babe7d7059854caa42ce6da92087e003f76acfb6d8
|
|
4
|
+
data.tar.gz: cf0466311827f2706fe883167b88d27a2571029c029777a1a5f37e0f44ae080a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3885a4464112b937aa88bd2661ad03aca35af5128ba9b292c7345fccd69bbd8c53a363d5f1805c0c2173c097ff06f8f98ec7fc657115fd530b84a910af9b5759
|
|
7
|
+
data.tar.gz: 25ce509ae466f259b102b4eedb8d828eb27435d6223bd052c87682b60b43584a6ccaf223ed0bec3f2141988fadebaac4638a204c4025554870e70c389ac9d4e2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.7.0] - 2026-03-08
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- **Operation record schema refactored** — the `response` column is renamed to `result`, the `error` column is split into `error_code`, `error_message`, and `error_details`, `params` no longer has `default: {}` (nil means "not captured"), and `status` is now `null: false`. The `record response: false` DSL option is now `record result: false`. Status value `done` is renamed to `completed`, and a new `error` status represents business errors via `error!`
|
|
8
|
+
- **All outcomes now recorded** — previously, only successful operations were recorded in the sync path. Now business errors (`error!`) record with status `error` and populate `error_code`/`error_message`/`error_details`, and unhandled exceptions record with status `failed`
|
|
9
|
+
- **Recording moved outside transaction** — operation records are now persisted outside the database transaction, so error and failure records survive rollbacks. Previously, records were created inside the transaction and would be rolled back alongside the operation's side effects
|
|
10
|
+
- **Pipeline order changed** — `RecordWrapper` now runs before `TransactionWrapper` (was after). The pipeline order is now: result → once → lock → record → transaction → rescue → guard → callback
|
|
11
|
+
- **`Operation.contract` shape changed** — `Contract` gains a fourth field `:guards`. Code using positional destructuring (`in Contract[params, success, errors]`) must be updated to include the new field. Keyword-based access (`.params`, `.errors`, etc.) is unaffected
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Ambient context** — `Dex.with_context(current_user: user) { ... }` sets fiber-local ambient state. The `context` DSL on operations and events maps props to ambient keys, auto-filling them when not passed explicitly. Explicit kwargs always win. Works with guards (`callable?`), events (captured at publish time), and nested operations. Introspection via `context_mappings`
|
|
16
|
+
- **`guard` DSL for precondition checks** — named, inline preconditions that detect threats (conditions under which the operation should not proceed). Guards auto-declare error codes, support dependencies (`requires:`), collect all independent failures, and skip dependent guards when a dependency fails. `callable?` and `callable` class methods check guards without running `perform` – useful for UI show/hide, disabled buttons with reasons, and API pre-validation. Contract introspection via `contract.guards`. Test helpers: `assert_callable`, `refute_callable`
|
|
17
|
+
- **`once` DSL for operation idempotency** — ensures an operation executes at most once for a given key, replaying stored results on subsequent calls. Supports prop-based keys (`once :order_id`), composite keys, block-based custom keys, call-site keys (`.once("key")`), optional expiry (`expires_in:`), and `clear_once!` for key management. Business errors are replayed; exceptions release the key for retry. Works with `.safe.call` and `.async.call`
|
|
18
|
+
- **`error_code`, `error_message`, `error_details` columns** — structured error recording replaces the single `error` string column
|
|
19
|
+
- **Recommended indexes** — `name`, `status`, `[:name, :status]` composite index, and unique partial index on `once_key` in the migration schema
|
|
20
|
+
|
|
3
21
|
## [0.6.0] - 2026-03-07
|
|
4
22
|
|
|
5
23
|
### Added
|
data/README.md
CHANGED
|
@@ -69,6 +69,69 @@ end
|
|
|
69
69
|
Order::Fulfill.new(order_id: 123).async(queue: "fulfillment").call
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
**Idempotency** with `once` — run an operation at most once for a given key. Results are replayed on duplicates:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class Payment::Charge < Dex::Operation
|
|
76
|
+
prop :order_id, Integer
|
|
77
|
+
prop :amount, Integer
|
|
78
|
+
|
|
79
|
+
once :order_id # key from prop
|
|
80
|
+
# once :order_id, :merchant_id # composite key
|
|
81
|
+
# once # all props as key
|
|
82
|
+
# once { "custom-#{order_id}" } # block-based key
|
|
83
|
+
# once :order_id, expires_in: 24.hours # expiring key
|
|
84
|
+
|
|
85
|
+
def perform
|
|
86
|
+
Gateway.charge!(order_id, amount)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Call-site key (overrides class-level declaration)
|
|
91
|
+
Payment::Charge.new(order_id: 1, amount: 500).once("ext-key-123").call
|
|
92
|
+
|
|
93
|
+
# Bypass once guard for a single call
|
|
94
|
+
Payment::Charge.new(order_id: 1, amount: 500).once(nil).call
|
|
95
|
+
|
|
96
|
+
# Clear a stored key to allow re-execution
|
|
97
|
+
Payment::Charge.clear_once!(order_id: 1)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Business errors are replayed; exceptions release the key so the operation can be retried. Requires the record backend (recording is enabled by default when `record_class` is configured).
|
|
101
|
+
|
|
102
|
+
**Guards** – inline precondition checks with introspection. Ask "can this operation run?" from views and controllers:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
guard :out_of_stock, "Product must be in stock" do
|
|
106
|
+
!product.in_stock?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# In a view or controller:
|
|
110
|
+
Order::Place.callable?(customer: customer, product: product, quantity: 1)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Ambient context** – declare which props come from ambient state. Set once in a controller, auto-fill everywhere:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
class Order::Place < Dex::Operation
|
|
117
|
+
prop :product, _Ref(Product)
|
|
118
|
+
prop :customer, _Ref(Customer)
|
|
119
|
+
context customer: :current_customer # filled from Dex.context[:current_customer]
|
|
120
|
+
|
|
121
|
+
def perform
|
|
122
|
+
Order.create!(product: product, customer: customer)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Controller
|
|
127
|
+
Dex.with_context(current_customer: current_customer) do
|
|
128
|
+
Order::Place.call(product: product) # customer auto-filled
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Tests – just pass it explicitly
|
|
132
|
+
Order::Place.call(product: product, customer: customer)
|
|
133
|
+
```
|
|
134
|
+
|
|
72
135
|
**Transactions** on by default, **advisory locking**, **recording** to database, **callbacks**, and a customizable **pipeline** – all composable, all optional.
|
|
73
136
|
|
|
74
137
|
### Testing
|
data/guides/llm/EVENT.md
CHANGED
|
@@ -235,21 +235,43 @@ Persistence failures are silently rescued — they never halt event publishing.
|
|
|
235
235
|
|
|
236
236
|
---
|
|
237
237
|
|
|
238
|
-
## Context
|
|
238
|
+
## Ambient Context
|
|
239
239
|
|
|
240
|
-
|
|
240
|
+
Events use the same `context` DSL as operations. Context-mapped props are captured at **publish time** and stored as regular props on the event — handlers don't need ambient context, they read from the event.
|
|
241
241
|
|
|
242
242
|
```ruby
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
243
|
+
class Order::Placed < Dex::Event
|
|
244
|
+
prop :order_id, Integer
|
|
245
|
+
prop :customer, _Ref(Customer)
|
|
246
|
+
context customer: :current_customer # resolved at publish time
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# In a controller with Dex.with_context(current_customer: customer):
|
|
250
|
+
Order::Placed.publish(order_id: 1) # customer auto-filled from context
|
|
251
|
+
|
|
252
|
+
# Or pass explicitly:
|
|
253
|
+
Order::Placed.publish(order_id: 1, customer: customer)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Handlers receive the event with everything already set — no `context` needed on handlers:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
class AuditTrail < Dex::Event::Handler
|
|
260
|
+
on Order::Placed
|
|
261
|
+
|
|
262
|
+
def perform
|
|
263
|
+
AuditLog.create!(customer: event.customer, action: "placed", order_id: event.order_id)
|
|
264
|
+
end
|
|
249
265
|
end
|
|
250
266
|
```
|
|
251
267
|
|
|
252
|
-
|
|
268
|
+
**Resolution order:** explicit kwarg → ambient context → prop default → TypeError.
|
|
269
|
+
|
|
270
|
+
**Introspection:** `MyEvent.context_mappings` returns the mapping hash.
|
|
271
|
+
|
|
272
|
+
### Legacy Context (Metadata)
|
|
273
|
+
|
|
274
|
+
The older `event_context` / `restore_event_context` configuration captures arbitrary metadata at publish time and restores it before async handler execution. Both mechanisms coexist.
|
|
253
275
|
|
|
254
276
|
---
|
|
255
277
|
|
data/guides/llm/OPERATION.md
CHANGED
|
@@ -11,7 +11,7 @@ All examples below build on this operation unless noted otherwise:
|
|
|
11
11
|
```ruby
|
|
12
12
|
class CreateUser < Dex::Operation
|
|
13
13
|
prop :email, String
|
|
14
|
-
prop :name,
|
|
14
|
+
prop :name, String
|
|
15
15
|
prop? :role, _Union("admin", "member"), default: "member"
|
|
16
16
|
|
|
17
17
|
success _Ref(User)
|
|
@@ -36,9 +36,10 @@ end
|
|
|
36
36
|
CreateUser.call(email: "a@b.com", name: "Alice") # shorthand for new(...).call
|
|
37
37
|
CreateUser.new(email: "a@b.com", name: "Alice").safe.call # Ok/Err wrapper
|
|
38
38
|
CreateUser.new(email: "a@b.com", name: "Alice").async.call # background job
|
|
39
|
+
CreateUser.new(email: "a@b.com", name: "Alice").once("key").call # call-site idempotency
|
|
39
40
|
```
|
|
40
41
|
|
|
41
|
-
Use `new(...)` form when chaining modifiers (`.safe`, `.async`).
|
|
42
|
+
Use `new(...)` form when chaining modifiers (`.safe`, `.async`, `.once`).
|
|
42
43
|
|
|
43
44
|
---
|
|
44
45
|
|
|
@@ -63,33 +64,33 @@ Types use `===` for validation. All constructors available in the operation clas
|
|
|
63
64
|
| `_Ref(Model)` | Model reference | `_Ref(User)`, `_Ref(Account, lock: true)` |
|
|
64
65
|
|
|
65
66
|
```ruby
|
|
66
|
-
prop :name,
|
|
67
|
-
prop :count,
|
|
68
|
-
prop :amount,
|
|
69
|
-
prop :amount,
|
|
70
|
-
prop :data,
|
|
71
|
-
prop :items,
|
|
72
|
-
prop :active,
|
|
73
|
-
prop :role,
|
|
74
|
-
prop :count,
|
|
75
|
-
prop :count,
|
|
76
|
-
prop :name,
|
|
77
|
-
prop :score,
|
|
78
|
-
prop :tags,
|
|
79
|
-
prop :ids,
|
|
80
|
-
prop :matrix,
|
|
67
|
+
prop :name, String # any String
|
|
68
|
+
prop :count, Integer # any Integer
|
|
69
|
+
prop :amount, Float # any Float
|
|
70
|
+
prop :amount, BigDecimal # any BigDecimal
|
|
71
|
+
prop :data, Hash # any Hash
|
|
72
|
+
prop :items, Array # any Array
|
|
73
|
+
prop :active, _Boolean # true or false
|
|
74
|
+
prop :role, Symbol # any Symbol
|
|
75
|
+
prop :count, _Integer(1..) # Integer >= 1
|
|
76
|
+
prop :count, _Integer(0..100) # Integer 0–100
|
|
77
|
+
prop :name, _String(length: 1..255) # String with length constraint
|
|
78
|
+
prop :score, _Float(0.0..1.0) # Float in range
|
|
79
|
+
prop :tags, _Array(String) # Array of Strings
|
|
80
|
+
prop :ids, _Array(Integer) # Array of Integers
|
|
81
|
+
prop :matrix, _Array(_Array(Integer)) # nested typed arrays
|
|
81
82
|
prop :currency, _Union("USD", "EUR", "GBP") # enum of values
|
|
82
|
-
prop :id,
|
|
83
|
-
prop :label,
|
|
84
|
-
prop :meta,
|
|
85
|
-
prop :pair,
|
|
86
|
-
prop :name,
|
|
87
|
-
prop :handler,
|
|
88
|
-
prop :handler,
|
|
89
|
-
prop :user,
|
|
90
|
-
prop :account,
|
|
91
|
-
prop :title,
|
|
92
|
-
prop? :note,
|
|
83
|
+
prop :id, _Union(String, Integer) # union of types
|
|
84
|
+
prop :label, _Nilable(String) # String or nil
|
|
85
|
+
prop :meta, _Hash(Symbol, String) # Hash with typed keys+values
|
|
86
|
+
prop :pair, _Tuple(String, Integer) # fixed-size typed array
|
|
87
|
+
prop :name, _Frozen(String) # must be frozen
|
|
88
|
+
prop :handler, _Callable # anything responding to .call
|
|
89
|
+
prop :handler, _Interface(:call, :arity) # responds to listed methods
|
|
90
|
+
prop :user, _Ref(User) # Dex-specific: model by instance or ID
|
|
91
|
+
prop :account, _Ref(Account, lock: true) # Dex-specific: with row lock
|
|
92
|
+
prop :title, String, default: "Untitled" # default value
|
|
93
|
+
prop? :note, String # optional (nilable, default: nil)
|
|
93
94
|
```
|
|
94
95
|
|
|
95
96
|
### _Ref(Model)
|
|
@@ -118,7 +119,118 @@ error :email_taken, :invalid_email
|
|
|
118
119
|
|
|
119
120
|
Both inherit from parent class. Without `error` declaration, any code is accepted.
|
|
120
121
|
|
|
121
|
-
**Introspection:** `MyOp.contract` returns a frozen `Data` with `params`, `success`, `errors` fields. Supports pattern matching and `to_h`.
|
|
122
|
+
**Introspection:** `MyOp.contract` returns a frozen `Data` with `params`, `success`, `errors`, `guards` fields. Supports pattern matching and `to_h`.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Ambient Context
|
|
127
|
+
|
|
128
|
+
Map props to ambient context keys so they auto-fill from `Dex.with_context` when not passed explicitly. Explicit kwargs always win.
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class Order::Place < Dex::Operation
|
|
132
|
+
prop :product, _Ref(Product)
|
|
133
|
+
prop :customer, _Ref(Customer)
|
|
134
|
+
prop :locale, Symbol
|
|
135
|
+
|
|
136
|
+
context customer: :current_customer # prop :customer ← Dex.context[:current_customer]
|
|
137
|
+
context :locale # shorthand: prop :locale ← Dex.context[:locale]
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Setting context** (controller, middleware):
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
Dex.with_context(current_customer: customer, locale: I18n.locale) do
|
|
145
|
+
Order::Place.call(product: product) # customer + locale auto-filled
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Resolution order:** explicit kwarg → ambient context → prop default → TypeError (if required).
|
|
150
|
+
|
|
151
|
+
**In tests** — just pass everything explicitly. No `Dex.with_context` needed:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
Order::Place.call(product: product, customer: customer, locale: :en)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Nesting** supported — inner blocks merge with outer, restore on exit. Nested operations inherit the same ambient context.
|
|
158
|
+
|
|
159
|
+
**Works with guards** — context-mapped props are available in guard blocks and `callable?`:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
Dex.with_context(current_customer: customer) do
|
|
163
|
+
Order::Place.callable?(product: product)
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Works with optional props** (`prop?`) — if ambient context has the key, it fills in. If not, the prop is nil.
|
|
168
|
+
|
|
169
|
+
**Introspection:** `MyOp.context_mappings` returns `{ customer: :current_customer, locale: :locale }`.
|
|
170
|
+
|
|
171
|
+
**DSL validation:** `context user: :current_user` raises `ArgumentError` if no `prop :user` has been declared. Context declarations must come after the props they reference.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Guards
|
|
176
|
+
|
|
177
|
+
Inline precondition checks. The guard name is the error code, the block detects the **threat** (truthy = threat detected = operation fails):
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
class PublishPost < Dex::Operation
|
|
181
|
+
prop :post, _Ref(Post)
|
|
182
|
+
prop :user, _Ref(User)
|
|
183
|
+
|
|
184
|
+
guard :unauthorized, "Only the author or admins can publish" do
|
|
185
|
+
!user.admin? && post.author != user
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
guard :already_published, "Post must be in draft state" do
|
|
189
|
+
post.published?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def perform
|
|
193
|
+
post.update!(published_at: Time.current)
|
|
194
|
+
post
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- Guards run in declaration order, before `perform`, after `rescue`
|
|
200
|
+
- All independent guards run – failures are collected, not short-circuited
|
|
201
|
+
- Guard names are auto-declared as error codes (no separate `error :unauthorized` needed)
|
|
202
|
+
- Same error code usable with `error!` in `perform`
|
|
203
|
+
|
|
204
|
+
**Dependencies:** skip dependent guards when a dependency fails:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
guard :missing_author, "Author must be present" do
|
|
208
|
+
author.blank?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
guard :unpaid_author, "Author must be a paid subscriber", requires: :missing_author do
|
|
212
|
+
author.free_plan?
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
If author is nil: only `:missing_author` is reported. `:unpaid_author` is skipped.
|
|
217
|
+
|
|
218
|
+
**Introspection** – check guards without running `perform`:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
PublishPost.callable?(post: post, user: user) # => true/false
|
|
222
|
+
PublishPost.callable?(:unauthorized, post: post, user: user) # check specific guard
|
|
223
|
+
result = PublishPost.callable(post: post, user: user) # => Ok or Err with details
|
|
224
|
+
result.details # => [{ guard: :unauthorized, message: "..." }, ...]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`callable` bypasses the pipeline – no locks, transactions, recording, or callbacks. Cheap and side-effect-free.
|
|
228
|
+
|
|
229
|
+
**Contract:** `contract.guards` returns guard metadata. `contract.errors` includes guard codes.
|
|
230
|
+
|
|
231
|
+
**Inheritance:** parent guards run first, child guards appended.
|
|
232
|
+
|
|
233
|
+
**DSL validation:** code must be Symbol, block required, `requires:` must reference previously declared guards, duplicates raise `ArgumentError`.
|
|
122
234
|
|
|
123
235
|
---
|
|
124
236
|
|
|
@@ -154,13 +266,13 @@ begin
|
|
|
154
266
|
CreateUser.call(email: "bad", name: "A")
|
|
155
267
|
rescue Dex::Error => e
|
|
156
268
|
case e
|
|
157
|
-
in {code: :not_found} then handle_not_found
|
|
158
|
-
in {code: :validation_failed, details: {field:}} then handle_field(field)
|
|
269
|
+
in { code: :not_found } then handle_not_found
|
|
270
|
+
in { code: :validation_failed, details: { field: } } then handle_field(field)
|
|
159
271
|
end
|
|
160
272
|
end
|
|
161
273
|
```
|
|
162
274
|
|
|
163
|
-
**Key differences:** `error!`/`assert!` roll back transaction, skip `after` callbacks
|
|
275
|
+
**Key differences:** `error!`/`assert!` roll back transaction, skip `after` callbacks, but are still recorded (status `error`). `success!` commits, runs `after` callbacks, records normally (status `completed`).
|
|
164
276
|
|
|
165
277
|
---
|
|
166
278
|
|
|
@@ -187,7 +299,7 @@ result.value! # re-raises Dex::Error
|
|
|
187
299
|
|
|
188
300
|
```ruby
|
|
189
301
|
case CreateUser.new(email: "a@b.com", name: "Alice").safe.call
|
|
190
|
-
in Dex::Ok(name:)
|
|
302
|
+
in Dex::Ok(name:) then puts "Created #{name}"
|
|
191
303
|
in Dex::Err(code: :email_taken) then puts "Already exists"
|
|
192
304
|
end
|
|
193
305
|
```
|
|
@@ -202,10 +314,10 @@ Map exceptions to structured `Dex::Error` codes — eliminates begin/rescue boil
|
|
|
202
314
|
|
|
203
315
|
```ruby
|
|
204
316
|
class ChargeCard < Dex::Operation
|
|
205
|
-
rescue_from Stripe::CardError,
|
|
206
|
-
rescue_from Stripe::RateLimitError,
|
|
317
|
+
rescue_from Stripe::CardError, as: :card_declined
|
|
318
|
+
rescue_from Stripe::RateLimitError, as: :rate_limited
|
|
207
319
|
rescue_from Net::OpenTimeout, Net::ReadTimeout, as: :timeout
|
|
208
|
-
rescue_from Stripe::APIError,
|
|
320
|
+
rescue_from Stripe::APIError, as: :provider_error, message: "Stripe is down"
|
|
209
321
|
|
|
210
322
|
def perform
|
|
211
323
|
Stripe::Charge.create(amount: amount, source: token)
|
|
@@ -226,7 +338,7 @@ end
|
|
|
226
338
|
class ProcessOrder < Dex::Operation
|
|
227
339
|
before :validate_stock # symbol → instance method
|
|
228
340
|
before -> { log("starting") } # lambda (instance_exec'd)
|
|
229
|
-
after
|
|
341
|
+
after :send_confirmation # runs after successful perform
|
|
230
342
|
around :with_timing # wraps everything, must yield
|
|
231
343
|
|
|
232
344
|
def validate_stock
|
|
@@ -327,27 +439,99 @@ Record execution to database. Requires `Dex.configure { |c| c.record_class = Ope
|
|
|
327
439
|
|
|
328
440
|
```ruby
|
|
329
441
|
create_table :operation_records do |t|
|
|
330
|
-
t.string
|
|
331
|
-
t.jsonb
|
|
332
|
-
t.jsonb
|
|
333
|
-
t.string
|
|
334
|
-
t.string
|
|
335
|
-
t.
|
|
442
|
+
t.string :name, null: false # operation class name
|
|
443
|
+
t.jsonb :params # serialized props (nil = not captured)
|
|
444
|
+
t.jsonb :result # serialized return value
|
|
445
|
+
t.string :status, null: false # pending/running/completed/error/failed
|
|
446
|
+
t.string :error_code # Dex::Error code or exception class
|
|
447
|
+
t.string :error_message # human-readable message
|
|
448
|
+
t.jsonb :error_details # structured details hash
|
|
449
|
+
t.string :once_key # idempotency key (used by `once`)
|
|
450
|
+
t.datetime :once_key_expires_at # key expiry (used by `once`)
|
|
451
|
+
t.datetime :performed_at # execution completion timestamp
|
|
336
452
|
t.timestamps
|
|
337
453
|
end
|
|
454
|
+
|
|
455
|
+
add_index :operation_records, :name
|
|
456
|
+
add_index :operation_records, :status
|
|
457
|
+
add_index :operation_records, [:name, :status]
|
|
338
458
|
```
|
|
339
459
|
|
|
340
460
|
Control per-operation:
|
|
341
461
|
|
|
342
462
|
```ruby
|
|
343
463
|
record false # disable entirely
|
|
344
|
-
record
|
|
345
|
-
record params: false #
|
|
464
|
+
record result: false # params only
|
|
465
|
+
record params: false # result only
|
|
346
466
|
```
|
|
347
467
|
|
|
348
|
-
Recording
|
|
468
|
+
All outcomes are recorded — success (`completed`), business errors (`error`), and exceptions (`failed`). Recording runs outside the operation's own transaction so error records survive its rollbacks. Records still participate in ambient transactions (e.g., an outer operation's transaction). Missing columns silently skipped.
|
|
469
|
+
|
|
470
|
+
Status values: `pending` (async enqueued), `running` (async executing), `completed` (success), `error` (business error via `error!`), `failed` (unhandled exception).
|
|
349
471
|
|
|
350
|
-
When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params.
|
|
472
|
+
When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params.
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
## Idempotency (once)
|
|
477
|
+
|
|
478
|
+
Prevent duplicate execution with `once`. Requires recording to be configured (uses the record backend to store and look up idempotency keys).
|
|
479
|
+
|
|
480
|
+
**Class-level declaration:**
|
|
481
|
+
|
|
482
|
+
```ruby
|
|
483
|
+
class ChargeOrder < Dex::Operation
|
|
484
|
+
prop :order_id, Integer
|
|
485
|
+
once :order_id # key: "ChargeOrder/order_id=123"
|
|
486
|
+
|
|
487
|
+
def perform
|
|
488
|
+
Stripe::Charge.create(amount: order.total)
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Key forms:
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
once :order_id # single prop → "ClassName/order_id=1"
|
|
497
|
+
once :merchant_id, :plan_id # composite → "ClassName/merchant_id=1/plan_id=2" (sorted)
|
|
498
|
+
once # bare — all props as key
|
|
499
|
+
once { "payment-#{order_id}" } # block — custom key (no auto scoping)
|
|
500
|
+
once :user_id, expires_in: 24.hours # key expires after duration
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
**Call-site key** — override or add idempotency at the call site:
|
|
504
|
+
|
|
505
|
+
```ruby
|
|
506
|
+
MyOp.new(payload: "data").once("webhook-123").call # explicit key
|
|
507
|
+
MyOp.new(order_id: 1).once(nil).call # bypass once guard entirely
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Works without a class-level `once` declaration — useful for one-off idempotency from controllers or jobs.
|
|
511
|
+
|
|
512
|
+
**Replay behavior:**
|
|
513
|
+
|
|
514
|
+
- Success results and business errors (`error!`) are replayed from the stored record. The operation does not re-execute.
|
|
515
|
+
- Unhandled exceptions release the key — the next call retries normally.
|
|
516
|
+
- Works with `.safe.call` (replays as `Ok`/`Err`) and `.async.call`.
|
|
517
|
+
|
|
518
|
+
**Clearing keys:**
|
|
519
|
+
|
|
520
|
+
```ruby
|
|
521
|
+
ChargeOrder.clear_once!(order_id: 1) # by prop values (builds scoped key)
|
|
522
|
+
ChargeOrder.clear_once!("webhook-123") # by raw string key
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Clearing is idempotent — clearing a non-existent key is a no-op. After clearing, the next call executes normally.
|
|
526
|
+
|
|
527
|
+
**Pipeline position:** result → **once** → lock → record → transaction → rescue → guard → callback. The once check runs before locking and recording, so duplicate calls short-circuit early.
|
|
528
|
+
|
|
529
|
+
**Requirements:**
|
|
530
|
+
|
|
531
|
+
- Record backend must be configured (`Dex.configure { |c| c.record_class = OperationRecord }`)
|
|
532
|
+
- The record table must have `once_key` and `once_key_expires_at` columns (see Recording schema above)
|
|
533
|
+
- `once` cannot be declared with `record false` — raises `ArgumentError`
|
|
534
|
+
- Only one `once` declaration per operation
|
|
351
535
|
|
|
352
536
|
---
|
|
353
537
|
|
|
@@ -390,7 +574,7 @@ class CreateUserTest < Minitest::Test
|
|
|
390
574
|
|
|
391
575
|
def test_example
|
|
392
576
|
result = call_operation(email: "a@b.com", name: "Alice") # => Ok or Err (safe)
|
|
393
|
-
value
|
|
577
|
+
value = call_operation!(email: "a@b.com", name: "Alice") # => raw value or raises
|
|
394
578
|
end
|
|
395
579
|
end
|
|
396
580
|
```
|
|
@@ -445,6 +629,17 @@ assert_contract(
|
|
|
445
629
|
)
|
|
446
630
|
```
|
|
447
631
|
|
|
632
|
+
### Guard Assertions
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
assert_callable(post: post, user: user) # all guards pass
|
|
636
|
+
assert_callable(PublishPost, post: post, user: user) # explicit class
|
|
637
|
+
refute_callable(:unauthorized, post: post, user: user) # specific guard fails
|
|
638
|
+
refute_callable(PublishPost, :unauthorized, post: post, user: user)
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
Guard failures on the normal `call` path produce `Dex::Error`, so `assert_operation_error` and `assert_err` also work.
|
|
642
|
+
|
|
448
643
|
### Param Validation
|
|
449
644
|
|
|
450
645
|
```ruby
|
|
@@ -526,7 +721,9 @@ Global log of all operation calls:
|
|
|
526
721
|
Dex::TestLog.calls # all entries
|
|
527
722
|
Dex::TestLog.find(CreateUser) # filter by class
|
|
528
723
|
Dex::TestLog.find(CreateUser, email: "a@b.com") # filter by class + params
|
|
529
|
-
Dex::TestLog.size
|
|
724
|
+
Dex::TestLog.size
|
|
725
|
+
Dex::TestLog.empty?
|
|
726
|
+
Dex::TestLog.clear!
|
|
530
727
|
Dex::TestLog.summary # human-readable for failure messages
|
|
531
728
|
```
|
|
532
729
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
# Shared context DSL for Operation and Event.
|
|
5
|
+
#
|
|
6
|
+
# Maps declared props to ambient context keys so they can be auto-filled
|
|
7
|
+
# from Dex.context when not passed explicitly as kwargs.
|
|
8
|
+
module ContextSetup
|
|
9
|
+
extend Dex::Concern
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
def context(*names, **mappings)
|
|
13
|
+
names.each do |name|
|
|
14
|
+
unless name.is_a?(Symbol)
|
|
15
|
+
raise ArgumentError, "context shorthand must be a Symbol, got: #{name.inspect}"
|
|
16
|
+
end
|
|
17
|
+
mappings[name] = name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
raise ArgumentError, "context requires at least one mapping" if mappings.empty?
|
|
21
|
+
|
|
22
|
+
mappings.each do |prop_name, context_key|
|
|
23
|
+
unless _context_prop_declared?(prop_name)
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"context references undeclared prop :#{prop_name}. Declare the prop before calling context."
|
|
26
|
+
end
|
|
27
|
+
unless context_key.is_a?(Symbol)
|
|
28
|
+
raise ArgumentError,
|
|
29
|
+
"context key must be a Symbol, got: #{context_key.inspect} for prop :#{prop_name}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
_context_own.merge!(mappings)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def context_mappings
|
|
37
|
+
parent = superclass.respond_to?(:context_mappings) ? superclass.context_mappings : {}
|
|
38
|
+
parent.merge(_context_own)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def new(**kwargs)
|
|
42
|
+
mappings = context_mappings
|
|
43
|
+
unless mappings.empty?
|
|
44
|
+
ambient = Dex.context
|
|
45
|
+
mappings.each do |prop_name, context_key|
|
|
46
|
+
next if kwargs.key?(prop_name)
|
|
47
|
+
kwargs[prop_name] = ambient[context_key] if ambient.key?(context_key)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def _context_own
|
|
56
|
+
@_context_own_mappings ||= {}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def _context_prop_declared?(name)
|
|
60
|
+
respond_to?(:literal_properties) && literal_properties.any? { |p| p.name == name }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/dex/event.rb
CHANGED
|
@@ -21,7 +21,9 @@ module Dex
|
|
|
21
21
|
|
|
22
22
|
def enqueue_direct_job
|
|
23
23
|
job = apply_options(Operation::DirectJob)
|
|
24
|
-
|
|
24
|
+
payload = { class_name: operation_class_name, params: serialized_params }
|
|
25
|
+
apply_once_payload!(payload)
|
|
26
|
+
job.perform_later(**payload)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def enqueue_record_job
|
|
@@ -32,7 +34,9 @@ module Dex
|
|
|
32
34
|
)
|
|
33
35
|
begin
|
|
34
36
|
job = apply_options(Operation::RecordJob)
|
|
35
|
-
|
|
37
|
+
payload = { class_name: operation_class_name, record_id: record.id.to_s }
|
|
38
|
+
apply_once_payload!(payload)
|
|
39
|
+
job.perform_later(**payload)
|
|
36
40
|
rescue => e
|
|
37
41
|
begin
|
|
38
42
|
record.destroy
|
|
@@ -68,6 +72,18 @@ module Dex
|
|
|
68
72
|
raise LoadError, "ActiveJob is required for async operations. Add 'activejob' to your Gemfile."
|
|
69
73
|
end
|
|
70
74
|
|
|
75
|
+
def apply_once_payload!(payload)
|
|
76
|
+
return unless @operation.instance_variable_defined?(:@_once_key_explicit) &&
|
|
77
|
+
@operation.instance_variable_get(:@_once_key_explicit)
|
|
78
|
+
|
|
79
|
+
once_key = @operation.instance_variable_get(:@_once_key)
|
|
80
|
+
if once_key
|
|
81
|
+
payload[:once_key] = once_key
|
|
82
|
+
else
|
|
83
|
+
payload[:once_bypass] = true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
71
87
|
def merged_options
|
|
72
88
|
@operation.class.settings_for(:async).merge(@runtime_options)
|
|
73
89
|
end
|