dexkit 0.5.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 +31 -0
- data/README.md +69 -2
- data/guides/llm/EVENT.md +95 -9
- data/guides/llm/OPERATION.md +246 -49
- data/lib/dex/context_setup.rb +64 -0
- data/lib/dex/event/handler.rb +20 -1
- data/lib/dex/event.rb +1 -2
- data/lib/dex/executable.rb +44 -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/transaction_wrapper.rb +2 -1
- data/lib/dex/operation.rb +16 -36
- data/lib/dex/pipeline.rb +58 -0
- data/lib/dex/test_helpers/assertions.rb +23 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +19 -0
- metadata +11 -6
- data/lib/dex/operation/pipeline.rb +0 -60
- /data/lib/dex/{operation/settings.rb → settings.rb} +0 -0
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,36 @@
|
|
|
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
|
+
|
|
21
|
+
## [0.6.0] - 2026-03-07
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Handler callbacks** — `Dex::Event::Handler` now supports `before`, `after`, and `around` callbacks, same DSL as operations
|
|
26
|
+
- **Handler transactions** — `Dex::Event::Handler` supports `transaction` and `after_commit` DSL. Transactions are disabled by default on handlers (opt in with `transaction`)
|
|
27
|
+
- **Handler pipeline** — `Dex::Event::Handler` supports `use` for adding custom wrapper modules, same as operations
|
|
28
|
+
- **`Dex::Executable`** — shared execution skeleton (Settings, Pipeline, `use` DSL) extracted from Operation and used by both Operation and Handler
|
|
29
|
+
|
|
30
|
+
### Breaking
|
|
31
|
+
|
|
32
|
+
- **`transaction false` fully opts out** — operations with `transaction false` no longer route `after_commit` through the database adapter. Previously, `after_commit` on a non-transactional operation would still detect and defer to ambient database transactions (e.g., `ActiveRecord::Base.transaction { op.call }`); now it fires callbacks directly after the pipeline completes. To restore ambient transaction awareness, remove `transaction false` or use `transaction` (enabled)
|
|
33
|
+
|
|
3
34
|
## 0.5.0 - 2026-03-05
|
|
4
35
|
|
|
5
36
|
### Added
|
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dexkit
|
|
2
2
|
|
|
3
3
|
Rails patterns toolbelt. Equip to gain +4 DEX.
|
|
4
4
|
|
|
@@ -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
|
|
@@ -127,6 +190,10 @@ order_placed.trace do
|
|
|
127
190
|
end
|
|
128
191
|
```
|
|
129
192
|
|
|
193
|
+
**Callbacks** — `before`, `after`, `around` hooks on handlers, same DSL as operations.
|
|
194
|
+
|
|
195
|
+
**Transactions** — opt-in `transaction` and `after_commit` for handlers that write to the database.
|
|
196
|
+
|
|
130
197
|
**Suppression**, optional **persistence**, **context capture**, and **retries** with exponential backoff.
|
|
131
198
|
|
|
132
199
|
### Testing
|
|
@@ -259,7 +326,7 @@ Full documentation at **[dex.razorjack.net](https://dex.razorjack.net)**.
|
|
|
259
326
|
|
|
260
327
|
## AI Coding Assistant Setup
|
|
261
328
|
|
|
262
|
-
|
|
329
|
+
dexkit ships LLM-optimized guides. Copy them into your project so AI agents automatically know the API:
|
|
263
330
|
|
|
264
331
|
```bash
|
|
265
332
|
cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
|
data/guides/llm/EVENT.md
CHANGED
|
@@ -111,6 +111,70 @@ end
|
|
|
111
111
|
|
|
112
112
|
When retries exhausted, exception propagates normally.
|
|
113
113
|
|
|
114
|
+
### Callbacks
|
|
115
|
+
|
|
116
|
+
Same `before`/`after`/`around` DSL as operations:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
class ProcessPayment < Dex::Event::Handler
|
|
120
|
+
on PaymentReceived
|
|
121
|
+
|
|
122
|
+
before :log_start
|
|
123
|
+
after :log_end
|
|
124
|
+
|
|
125
|
+
around ->(cont) {
|
|
126
|
+
Instrumentation.measure("payment") { cont.call }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def perform
|
|
130
|
+
PaymentGateway.charge(event.amount)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def log_start = Rails.logger.info("Processing payment...")
|
|
136
|
+
def log_end = Rails.logger.info("Payment processed")
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Callbacks are inherited. Child handlers run parent callbacks first.
|
|
141
|
+
|
|
142
|
+
### Transactions
|
|
143
|
+
|
|
144
|
+
Handlers can opt into database transactions and deferred `after_commit`:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
class FulfillOrder < Dex::Event::Handler
|
|
148
|
+
on OrderPlaced
|
|
149
|
+
transaction
|
|
150
|
+
|
|
151
|
+
def perform
|
|
152
|
+
order = Order.find(event.order_id)
|
|
153
|
+
order.update!(status: "fulfilled")
|
|
154
|
+
|
|
155
|
+
after_commit { Shipment::Ship.new(order_id: order.id).async.call }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Transactions are **disabled by default** on handlers (unlike operations). Opt in with `transaction`. The `after_commit` block defers until the transaction commits; on exception, deferred blocks are discarded.
|
|
161
|
+
|
|
162
|
+
### Custom Pipeline
|
|
163
|
+
|
|
164
|
+
Handlers support the same `use` DSL as operations for adding custom wrappers:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
class Monitored < Dex::Event::Handler
|
|
168
|
+
use MetricsWrapper, as: :metrics
|
|
169
|
+
|
|
170
|
+
def perform
|
|
171
|
+
# ...
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Default handler pipeline: `[:transaction, :callback]`.
|
|
177
|
+
|
|
114
178
|
---
|
|
115
179
|
|
|
116
180
|
## Tracing (Causality)
|
|
@@ -171,21 +235,43 @@ Persistence failures are silently rescued — they never halt event publishing.
|
|
|
171
235
|
|
|
172
236
|
---
|
|
173
237
|
|
|
174
|
-
## Context
|
|
238
|
+
## Ambient Context
|
|
175
239
|
|
|
176
|
-
|
|
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.
|
|
177
241
|
|
|
178
242
|
```ruby
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
185
265
|
end
|
|
186
266
|
```
|
|
187
267
|
|
|
188
|
-
|
|
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.
|
|
189
275
|
|
|
190
276
|
---
|
|
191
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,
|
|
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
|
|