dexkit 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +97 -5
- data/guides/llm/EVENT.md +74 -10
- data/guides/llm/FORM.md +1 -1
- data/guides/llm/OPERATION.md +352 -51
- data/guides/llm/QUERY.md +1 -1
- data/lib/dex/context_setup.rb +64 -0
- data/lib/dex/event/export.rb +56 -0
- data/lib/dex/event/handler.rb +33 -0
- data/lib/dex/event.rb +28 -0
- data/lib/dex/operation/async_proxy.rb +18 -2
- data/lib/dex/operation/explain.rb +204 -0
- data/lib/dex/operation/export.rb +144 -0
- data/lib/dex/operation/guard_wrapper.rb +149 -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 +87 -0
- data/lib/dex/operation/record_wrapper.rb +87 -20
- data/lib/dex/operation.rb +62 -4
- data/lib/dex/props_setup.rb +25 -2
- data/lib/dex/railtie.rb +84 -0
- data/lib/dex/registry.rb +63 -0
- data/lib/dex/test_helpers/assertions.rb +23 -0
- data/lib/dex/tool.rb +115 -0
- data/lib/dex/type_serializer.rb +132 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +21 -0
- metadata +11 -1
data/guides/llm/OPERATION.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Dex::Operation — LLM Reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install with `rake dex:guides` or copy manually to `app/operations/AGENTS.md`.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -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,38 +64,38 @@ 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)
|
|
96
97
|
|
|
97
|
-
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.
|
|
98
|
+
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. IDs are treated as strings in JSON Schema – this supports integer PKs, UUIDs, and Mongoid BSON::ObjectId equally.
|
|
98
99
|
|
|
99
100
|
Outside the class body (e.g., in tests), use `Dex::RefType.new(Model)` instead of `_Ref(Model)`.
|
|
100
101
|
|
|
@@ -118,7 +119,163 @@ 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`.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Explain
|
|
238
|
+
|
|
239
|
+
Full preflight check — resolves context, coerces props, evaluates guards, computes derived keys, reports settings. No side effects, `perform` never runs.
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
info = Order::Place.explain(product: product, customer: customer, quantity: 2)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Returns a frozen Hash:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
info = Order::Place.explain(product: product, customer: customer, quantity: 2)
|
|
249
|
+
# => {
|
|
250
|
+
# operation: "Order::Place",
|
|
251
|
+
# props: { product: #<Product>, customer: #<Customer>, quantity: 2 },
|
|
252
|
+
# context: {
|
|
253
|
+
# resolved: { customer: #<Customer> },
|
|
254
|
+
# mappings: { customer: :current_customer },
|
|
255
|
+
# source: { customer: :ambient } # :ambient, :explicit, or :default
|
|
256
|
+
# },
|
|
257
|
+
# guards: {
|
|
258
|
+
# passed: true,
|
|
259
|
+
# results: [{ name: :out_of_stock, passed: true }, ...]
|
|
260
|
+
# },
|
|
261
|
+
# once: { active: true, key: "Order::Place/product_id=7", status: :fresh, expires_in: nil },
|
|
262
|
+
# lock: { active: true, key: "order:7", timeout: nil },
|
|
263
|
+
# record: { enabled: true, params: true, result: true },
|
|
264
|
+
# transaction: { enabled: true },
|
|
265
|
+
# rescue_from: { "Stripe::CardError" => :card_declined },
|
|
266
|
+
# callbacks: { before: 1, after: 2, around: 0 },
|
|
267
|
+
# pipeline: [:result, :guard, :once, :lock, :record, :transaction, :rescue, :callback],
|
|
268
|
+
# callable: true
|
|
269
|
+
# }
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
- Invalid props (`Literal::TypeError`, `ArgumentError`) return a partial result with `info[:error]` — class-level info still available, instance-dependent sections degrade to empty/nil. Static lock keys preserved. Context source uses `:missing` for props without defaults. Other errors propagate normally
|
|
273
|
+
- `info[:callable]` is a full preflight verdict — checks guards AND once blocking statuses; always `false` when props are invalid
|
|
274
|
+
- Once status: `:fresh` (new), `:exists` (would replay), `:expired`, `:pending` (in-flight), `:invalid` (nil key), `:misconfigured` (anonymous op, missing record step, missing column), `:unavailable` (no backend)
|
|
275
|
+
- Guard results include `message:` on failures and `skipped: true` when a guard was skipped via `requires:` dependency
|
|
276
|
+
- Custom middleware can contribute via `_name_explain(instance, info)` class methods
|
|
277
|
+
|
|
278
|
+
**Use cases:** console debugging, admin tooling, LLM agent preflight, test assertions.
|
|
122
279
|
|
|
123
280
|
---
|
|
124
281
|
|
|
@@ -154,13 +311,13 @@ begin
|
|
|
154
311
|
CreateUser.call(email: "bad", name: "A")
|
|
155
312
|
rescue Dex::Error => e
|
|
156
313
|
case e
|
|
157
|
-
in {code: :not_found} then handle_not_found
|
|
158
|
-
in {code: :validation_failed, details: {field:}} then handle_field(field)
|
|
314
|
+
in { code: :not_found } then handle_not_found
|
|
315
|
+
in { code: :validation_failed, details: { field: } } then handle_field(field)
|
|
159
316
|
end
|
|
160
317
|
end
|
|
161
318
|
```
|
|
162
319
|
|
|
163
|
-
**Key differences:** `error!`/`assert!` roll back transaction, skip `after` callbacks
|
|
320
|
+
**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
321
|
|
|
165
322
|
---
|
|
166
323
|
|
|
@@ -187,7 +344,7 @@ result.value! # re-raises Dex::Error
|
|
|
187
344
|
|
|
188
345
|
```ruby
|
|
189
346
|
case CreateUser.new(email: "a@b.com", name: "Alice").safe.call
|
|
190
|
-
in Dex::Ok(name:)
|
|
347
|
+
in Dex::Ok(name:) then puts "Created #{name}"
|
|
191
348
|
in Dex::Err(code: :email_taken) then puts "Already exists"
|
|
192
349
|
end
|
|
193
350
|
```
|
|
@@ -202,10 +359,10 @@ Map exceptions to structured `Dex::Error` codes — eliminates begin/rescue boil
|
|
|
202
359
|
|
|
203
360
|
```ruby
|
|
204
361
|
class ChargeCard < Dex::Operation
|
|
205
|
-
rescue_from Stripe::CardError,
|
|
206
|
-
rescue_from Stripe::RateLimitError,
|
|
362
|
+
rescue_from Stripe::CardError, as: :card_declined
|
|
363
|
+
rescue_from Stripe::RateLimitError, as: :rate_limited
|
|
207
364
|
rescue_from Net::OpenTimeout, Net::ReadTimeout, as: :timeout
|
|
208
|
-
rescue_from Stripe::APIError,
|
|
365
|
+
rescue_from Stripe::APIError, as: :provider_error, message: "Stripe is down"
|
|
209
366
|
|
|
210
367
|
def perform
|
|
211
368
|
Stripe::Charge.create(amount: amount, source: token)
|
|
@@ -226,7 +383,7 @@ end
|
|
|
226
383
|
class ProcessOrder < Dex::Operation
|
|
227
384
|
before :validate_stock # symbol → instance method
|
|
228
385
|
before -> { log("starting") } # lambda (instance_exec'd)
|
|
229
|
-
after
|
|
386
|
+
after :send_confirmation # runs after successful perform
|
|
230
387
|
around :with_timing # wraps everything, must yield
|
|
231
388
|
|
|
232
389
|
def validate_stock
|
|
@@ -327,27 +484,99 @@ Record execution to database. Requires `Dex.configure { |c| c.record_class = Ope
|
|
|
327
484
|
|
|
328
485
|
```ruby
|
|
329
486
|
create_table :operation_records do |t|
|
|
330
|
-
t.string
|
|
331
|
-
t.jsonb
|
|
332
|
-
t.jsonb
|
|
333
|
-
t.string
|
|
334
|
-
t.string
|
|
335
|
-
t.
|
|
487
|
+
t.string :name, null: false # operation class name
|
|
488
|
+
t.jsonb :params # serialized props (nil = not captured)
|
|
489
|
+
t.jsonb :result # serialized return value
|
|
490
|
+
t.string :status, null: false # pending/running/completed/error/failed
|
|
491
|
+
t.string :error_code # Dex::Error code or exception class
|
|
492
|
+
t.string :error_message # human-readable message
|
|
493
|
+
t.jsonb :error_details # structured details hash
|
|
494
|
+
t.string :once_key # idempotency key (used by `once`)
|
|
495
|
+
t.datetime :once_key_expires_at # key expiry (used by `once`)
|
|
496
|
+
t.datetime :performed_at # execution completion timestamp
|
|
336
497
|
t.timestamps
|
|
337
498
|
end
|
|
499
|
+
|
|
500
|
+
add_index :operation_records, :name
|
|
501
|
+
add_index :operation_records, :status
|
|
502
|
+
add_index :operation_records, [:name, :status]
|
|
338
503
|
```
|
|
339
504
|
|
|
340
505
|
Control per-operation:
|
|
341
506
|
|
|
342
507
|
```ruby
|
|
343
508
|
record false # disable entirely
|
|
344
|
-
record
|
|
345
|
-
record params: false #
|
|
509
|
+
record result: false # params only
|
|
510
|
+
record params: false # result only
|
|
346
511
|
```
|
|
347
512
|
|
|
348
|
-
Recording
|
|
513
|
+
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.
|
|
514
|
+
|
|
515
|
+
Status values: `pending` (async enqueued), `running` (async executing), `completed` (success), `error` (business error via `error!`), `failed` (unhandled exception).
|
|
516
|
+
|
|
517
|
+
When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params.
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## Idempotency (once)
|
|
522
|
+
|
|
523
|
+
Prevent duplicate execution with `once`. Requires recording to be configured (uses the record backend to store and look up idempotency keys).
|
|
524
|
+
|
|
525
|
+
**Class-level declaration:**
|
|
526
|
+
|
|
527
|
+
```ruby
|
|
528
|
+
class ChargeOrder < Dex::Operation
|
|
529
|
+
prop :order_id, Integer
|
|
530
|
+
once :order_id # key: "ChargeOrder/order_id=123"
|
|
531
|
+
|
|
532
|
+
def perform
|
|
533
|
+
Stripe::Charge.create(amount: order.total)
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Key forms:
|
|
539
|
+
|
|
540
|
+
```ruby
|
|
541
|
+
once :order_id # single prop → "ClassName/order_id=1"
|
|
542
|
+
once :merchant_id, :plan_id # composite → "ClassName/merchant_id=1/plan_id=2" (sorted)
|
|
543
|
+
once # bare — all props as key
|
|
544
|
+
once { "payment-#{order_id}" } # block — custom key (no auto scoping)
|
|
545
|
+
once :user_id, expires_in: 24.hours # key expires after duration
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
**Call-site key** — override or add idempotency at the call site:
|
|
549
|
+
|
|
550
|
+
```ruby
|
|
551
|
+
MyOp.new(payload: "data").once("webhook-123").call # explicit key
|
|
552
|
+
MyOp.new(order_id: 1).once(nil).call # bypass once guard entirely
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Works without a class-level `once` declaration — useful for one-off idempotency from controllers or jobs.
|
|
556
|
+
|
|
557
|
+
**Replay behavior:**
|
|
558
|
+
|
|
559
|
+
- Success results and business errors (`error!`) are replayed from the stored record. The operation does not re-execute.
|
|
560
|
+
- Unhandled exceptions release the key — the next call retries normally.
|
|
561
|
+
- Works with `.safe.call` (replays as `Ok`/`Err`) and `.async.call`.
|
|
562
|
+
|
|
563
|
+
**Clearing keys:**
|
|
564
|
+
|
|
565
|
+
```ruby
|
|
566
|
+
ChargeOrder.clear_once!(order_id: 1) # by prop values (builds scoped key)
|
|
567
|
+
ChargeOrder.clear_once!("webhook-123") # by raw string key
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
Clearing is idempotent — clearing a non-existent key is a no-op. After clearing, the next call executes normally.
|
|
571
|
+
|
|
572
|
+
**Pipeline position:** result → **once** → lock → record → transaction → rescue → guard → callback. The once check runs before locking and recording, so duplicate calls short-circuit early.
|
|
349
573
|
|
|
350
|
-
|
|
574
|
+
**Requirements:**
|
|
575
|
+
|
|
576
|
+
- Record backend must be configured (`Dex.configure { |c| c.record_class = OperationRecord }`)
|
|
577
|
+
- The record table must have `once_key` and `once_key_expires_at` columns (see Recording schema above)
|
|
578
|
+
- `once` cannot be declared with `record false` — raises `ArgumentError`
|
|
579
|
+
- Only one `once` declaration per operation
|
|
351
580
|
|
|
352
581
|
---
|
|
353
582
|
|
|
@@ -390,7 +619,7 @@ class CreateUserTest < Minitest::Test
|
|
|
390
619
|
|
|
391
620
|
def test_example
|
|
392
621
|
result = call_operation(email: "a@b.com", name: "Alice") # => Ok or Err (safe)
|
|
393
|
-
value
|
|
622
|
+
value = call_operation!(email: "a@b.com", name: "Alice") # => raw value or raises
|
|
394
623
|
end
|
|
395
624
|
end
|
|
396
625
|
```
|
|
@@ -445,6 +674,17 @@ assert_contract(
|
|
|
445
674
|
)
|
|
446
675
|
```
|
|
447
676
|
|
|
677
|
+
### Guard Assertions
|
|
678
|
+
|
|
679
|
+
```ruby
|
|
680
|
+
assert_callable(post: post, user: user) # all guards pass
|
|
681
|
+
assert_callable(PublishPost, post: post, user: user) # explicit class
|
|
682
|
+
refute_callable(:unauthorized, post: post, user: user) # specific guard fails
|
|
683
|
+
refute_callable(PublishPost, :unauthorized, post: post, user: user)
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Guard failures on the normal `call` path produce `Dex::Error`, so `assert_operation_error` and `assert_err` also work.
|
|
687
|
+
|
|
448
688
|
### Param Validation
|
|
449
689
|
|
|
450
690
|
```ruby
|
|
@@ -526,7 +766,9 @@ Global log of all operation calls:
|
|
|
526
766
|
Dex::TestLog.calls # all entries
|
|
527
767
|
Dex::TestLog.find(CreateUser) # filter by class
|
|
528
768
|
Dex::TestLog.find(CreateUser, email: "a@b.com") # filter by class + params
|
|
529
|
-
Dex::TestLog.size
|
|
769
|
+
Dex::TestLog.size
|
|
770
|
+
Dex::TestLog.empty?
|
|
771
|
+
Dex::TestLog.clear!
|
|
530
772
|
Dex::TestLog.summary # human-readable for failure messages
|
|
531
773
|
```
|
|
532
774
|
|
|
@@ -583,4 +825,63 @@ end
|
|
|
583
825
|
|
|
584
826
|
---
|
|
585
827
|
|
|
828
|
+
## Registry, Export & Description
|
|
829
|
+
|
|
830
|
+
### Description
|
|
831
|
+
|
|
832
|
+
Operations can declare a human-readable description. Props can include `desc:`:
|
|
833
|
+
|
|
834
|
+
```ruby
|
|
835
|
+
class Order::Place < Dex::Operation
|
|
836
|
+
description "Places a new order, charges payment, and schedules fulfillment"
|
|
837
|
+
|
|
838
|
+
prop :product, _Ref(Product), desc: "Product to order"
|
|
839
|
+
prop :quantity, _Integer(1..), desc: "Number of units"
|
|
840
|
+
end
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
Descriptions appear in `contract.to_h`, `to_json_schema`, `explain`, and LLM tool definitions.
|
|
844
|
+
|
|
845
|
+
### Registry
|
|
846
|
+
|
|
847
|
+
```ruby
|
|
848
|
+
Dex::Operation.registry # => #<Set: {Order::Place, Order::Cancel, ...}>
|
|
849
|
+
Dex::Operation.deregister(klass) # remove from registry (useful in tests)
|
|
850
|
+
Dex::Operation.clear! # empty the registry
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
Only named, reachable classes are included. Anonymous classes and stale objects from code reloads are excluded. Populates lazily via `inherited` — in Rails, `eager_load!` to get the full list.
|
|
854
|
+
|
|
855
|
+
### Export
|
|
856
|
+
|
|
857
|
+
```ruby
|
|
858
|
+
Order::Place.contract.to_h
|
|
859
|
+
# => { name: "Order::Place", description: "...", params: { product: { type: "Ref(Product)", required: true, desc: "..." } }, ... }
|
|
860
|
+
|
|
861
|
+
Order::Place.contract.to_json_schema # params input schema (default)
|
|
862
|
+
Order::Place.contract.to_json_schema(section: :success) # success return schema
|
|
863
|
+
Order::Place.contract.to_json_schema(section: :errors) # error catalog schema
|
|
864
|
+
Order::Place.contract.to_json_schema(section: :full) # everything
|
|
865
|
+
|
|
866
|
+
Dex::Operation.export # all operations as hashes
|
|
867
|
+
Dex::Operation.export(format: :json_schema) # all as JSON Schema
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
### LLM Tools (ruby-llm integration)
|
|
871
|
+
|
|
872
|
+
```ruby
|
|
873
|
+
chat = RubyLLM.chat
|
|
874
|
+
chat.with_tools(*Dex::Tool.all) # all operations as tools
|
|
875
|
+
chat.with_tools(*Dex::Tool.from_namespace("Order")) # namespace filter
|
|
876
|
+
chat.with_tools(Dex::Tool.explain_tool) # preflight check tool
|
|
877
|
+
|
|
878
|
+
Dex.with_context(current_user: user) do
|
|
879
|
+
chat.ask("Place an order for 2 units of product #42")
|
|
880
|
+
end
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
Requires `gem 'ruby_llm'` in your Gemfile. Lazy-loaded — ruby-llm is only required when you call `Dex::Tool`.
|
|
884
|
+
|
|
885
|
+
---
|
|
886
|
+
|
|
586
887
|
**End of reference.**
|
data/guides/llm/QUERY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Dex::Query — LLM Reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install with `rake dex:guides` or copy manually to `app/queries/AGENTS.md`.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -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
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Event
|
|
5
|
+
module Export
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build_hash(source)
|
|
9
|
+
h = {}
|
|
10
|
+
h[:name] = source.name if source.name
|
|
11
|
+
desc = source.description
|
|
12
|
+
h[:description] = desc if desc
|
|
13
|
+
h[:props] = _serialize_props(source)
|
|
14
|
+
h
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build_json_schema(source)
|
|
18
|
+
descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
19
|
+
properties = {}
|
|
20
|
+
required = []
|
|
21
|
+
|
|
22
|
+
if source.respond_to?(:literal_properties)
|
|
23
|
+
source.literal_properties.each do |prop|
|
|
24
|
+
prop_desc = descs[prop.name]
|
|
25
|
+
schema = TypeSerializer.to_json_schema(prop.type, desc: prop_desc)
|
|
26
|
+
properties[prop.name.to_s] = schema
|
|
27
|
+
required << prop.name.to_s if prop.required?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
32
|
+
result[:title] = source.name if source.name
|
|
33
|
+
desc = source.description
|
|
34
|
+
result[:description] = desc if desc
|
|
35
|
+
result[:type] = "object"
|
|
36
|
+
result[:properties] = properties unless properties.empty?
|
|
37
|
+
result[:required] = required unless required.empty?
|
|
38
|
+
result[:additionalProperties] = false
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def _serialize_props(source)
|
|
43
|
+
return {} unless source.respond_to?(:literal_properties)
|
|
44
|
+
|
|
45
|
+
descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
46
|
+
source.literal_properties.each_with_object({}) do |prop, hash|
|
|
47
|
+
entry = { type: TypeSerializer.to_string(prop.type), required: prop.required? }
|
|
48
|
+
entry[:desc] = descs[prop.name] if descs[prop.name]
|
|
49
|
+
hash[prop.name] = entry
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private_class_method :_serialize_props
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|