dexkit 0.9.0 → 0.11.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 +57 -1
- data/README.md +63 -254
- data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
- data/guides/llm/EVENT.md +25 -26
- data/guides/llm/FORM.md +200 -59
- data/guides/llm/OPERATION.md +115 -57
- data/guides/llm/QUERY.md +56 -0
- data/guides/llm/TOOL.md +308 -0
- data/lib/dex/context_dsl.rb +56 -0
- data/lib/dex/context_setup.rb +2 -33
- data/lib/dex/event/bus.rb +79 -11
- data/lib/dex/event/handler.rb +18 -1
- data/lib/dex/event/metadata.rb +15 -20
- data/lib/dex/event/processor.rb +2 -16
- data/lib/dex/event/test_helpers.rb +1 -1
- data/lib/dex/event.rb +3 -10
- data/lib/dex/form/context.rb +27 -0
- data/lib/dex/form/export.rb +128 -0
- data/lib/dex/form/nesting.rb +2 -0
- data/lib/dex/form.rb +119 -3
- data/lib/dex/id.rb +125 -0
- data/lib/dex/operation/async_proxy.rb +22 -4
- data/lib/dex/operation/guard_wrapper.rb +1 -1
- data/lib/dex/operation/jobs.rb +5 -4
- data/lib/dex/operation/once_wrapper.rb +1 -0
- data/lib/dex/operation/outcome.rb +14 -0
- data/lib/dex/operation/record_backend.rb +2 -1
- data/lib/dex/operation/record_wrapper.rb +14 -4
- data/lib/dex/operation/result_wrapper.rb +0 -12
- data/lib/dex/operation/test_helpers/assertions.rb +0 -88
- data/lib/dex/operation/test_helpers.rb +11 -1
- data/lib/dex/operation/ticket.rb +268 -0
- data/lib/dex/operation/trace_wrapper.rb +20 -0
- data/lib/dex/operation.rb +3 -0
- data/lib/dex/operation_failed.rb +14 -0
- data/lib/dex/query/export.rb +64 -0
- data/lib/dex/query.rb +41 -0
- data/lib/dex/test_log.rb +62 -4
- data/lib/dex/timeout.rb +14 -0
- data/lib/dex/tool.rb +388 -5
- data/lib/dex/trace.rb +291 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +22 -3
- metadata +12 -3
- data/lib/dex/event/trace.rb +0 -56
- data/lib/dex/event_test_helpers.rb +0 -3
data/guides/llm/FORM.md
CHANGED
|
@@ -10,58 +10,80 @@ All examples below build on this form unless noted otherwise:
|
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
12
|
class OnboardingForm < Dex::Form
|
|
13
|
+
description "Employee onboarding"
|
|
13
14
|
model User
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
field :first_name, :string
|
|
17
|
+
field :last_name, :string
|
|
18
|
+
field :email, :string
|
|
19
|
+
field :department, :string
|
|
20
|
+
field :start_date, :date
|
|
21
|
+
field :locale, :string
|
|
22
|
+
field? :notes, :string
|
|
23
|
+
|
|
24
|
+
context :locale
|
|
20
25
|
|
|
21
26
|
normalizes :email, with: -> { _1&.strip&.downcase.presence }
|
|
22
27
|
|
|
23
|
-
validates :email,
|
|
24
|
-
validates :first_name, :last_name, :department, presence: true
|
|
25
|
-
validates :start_date, presence: true
|
|
28
|
+
validates :email, uniqueness: true
|
|
26
29
|
|
|
27
30
|
nested_one :address do
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
validates :street, :city, :country, presence: true
|
|
31
|
+
field :street, :string
|
|
32
|
+
field :city, :string
|
|
33
|
+
field :postal_code, :string
|
|
34
|
+
field :country, :string
|
|
35
|
+
field? :apartment, :string
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
nested_many :documents do
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
validates :document_type, :document_number, presence: true
|
|
39
|
+
field :document_type, :string
|
|
40
|
+
field :document_number, :string
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
---
|
|
46
46
|
|
|
47
|
-
##
|
|
47
|
+
## Declaring Fields
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
### `field` — required
|
|
50
|
+
|
|
51
|
+
Declares a required field. Auto-adds presence validation. Unconditional `validates :attr, presence: true` deduplicates with it; scoped or conditional presence validators do not make the field optional outside those cases.
|
|
50
52
|
|
|
51
53
|
```ruby
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
field :name, :string
|
|
55
|
+
field :email, :string, desc: "Work email"
|
|
56
|
+
field :currency, :string, default: "USD"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `field?` — optional
|
|
60
|
+
|
|
61
|
+
Declares an optional field. Defaults to `nil` unless overridden.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
field? :notes, :string
|
|
65
|
+
field? :priority, :integer, default: 0
|
|
59
66
|
```
|
|
60
67
|
|
|
68
|
+
### Options
|
|
69
|
+
|
|
70
|
+
| Option | Description |
|
|
71
|
+
|--------|-------------|
|
|
72
|
+
| `desc:` | Human-readable description (for introspection and JSON Schema) |
|
|
73
|
+
| `default:` | Default value (forwarded to ActiveModel) |
|
|
74
|
+
|
|
61
75
|
### Available types
|
|
62
76
|
|
|
63
77
|
`:string`, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:time`.
|
|
64
78
|
|
|
79
|
+
### `attribute` escape hatch
|
|
80
|
+
|
|
81
|
+
Raw ActiveModel `attribute` is still available. Not tracked in field registry, no auto-presence, not in exports.
|
|
82
|
+
|
|
83
|
+
### Boolean fields
|
|
84
|
+
|
|
85
|
+
`field :active, :boolean` checks for `nil` (not `blank?`), so `false` is valid.
|
|
86
|
+
|
|
65
87
|
### `model(klass)`
|
|
66
88
|
|
|
67
89
|
Declares the backing model class. Used by:
|
|
@@ -93,11 +115,11 @@ normalizes :name, :email, with: -> { _1&.strip.presence } # multiple attrs
|
|
|
93
115
|
|
|
94
116
|
## Validation
|
|
95
117
|
|
|
96
|
-
Full ActiveModel validation DSL:
|
|
118
|
+
Full ActiveModel validation DSL. Required fields auto-validate presence — no need to add `validates :name, presence: true` when using `field :name, :string`.
|
|
97
119
|
|
|
98
120
|
```ruby
|
|
99
|
-
validates :email,
|
|
100
|
-
validates :name,
|
|
121
|
+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
122
|
+
validates :name, length: { minimum: 2, maximum: 100 }
|
|
101
123
|
validates :role, inclusion: { in: %w[admin user] }
|
|
102
124
|
validates :email, uniqueness: true # checks database
|
|
103
125
|
validate :custom_validation_method
|
|
@@ -111,6 +133,15 @@ form.errors[:email] # => ["can't be blank"]
|
|
|
111
133
|
form.errors.full_messages # => ["Email can't be blank"]
|
|
112
134
|
```
|
|
113
135
|
|
|
136
|
+
### Contextual requirements
|
|
137
|
+
|
|
138
|
+
Use `field?` with an explicit validation context:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
field? :published_at, :datetime
|
|
142
|
+
validates :published_at, presence: true, on: :publish
|
|
143
|
+
```
|
|
144
|
+
|
|
114
145
|
### ValidationError
|
|
115
146
|
|
|
116
147
|
```ruby
|
|
@@ -121,6 +152,94 @@ error.form # => the form instance
|
|
|
121
152
|
|
|
122
153
|
---
|
|
123
154
|
|
|
155
|
+
## Ambient Context
|
|
156
|
+
|
|
157
|
+
Auto-fill fields from `Dex.context` – same DSL as Operation and Event:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
class Order::Form < Dex::Form
|
|
161
|
+
field :locale, :string
|
|
162
|
+
field :currency, :string
|
|
163
|
+
|
|
164
|
+
context :locale # shorthand: field name = context key
|
|
165
|
+
context currency: :default_currency # explicit: field name → context key
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Dex.with_context(locale: "en", default_currency: "USD") do
|
|
169
|
+
form = Order::Form.new
|
|
170
|
+
form.locale # => "en"
|
|
171
|
+
form.currency # => "USD"
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Explicit values always win. Context references must point to declared fields or attributes.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Registry & Export
|
|
180
|
+
|
|
181
|
+
### Description
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
class OnboardingForm < Dex::Form
|
|
185
|
+
description "Employee onboarding"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
OnboardingForm.description # => "Employee onboarding"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Registry
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
Dex::Form.registry # => #<Set: {OnboardingForm, ...}>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Class-level `to_h`
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
OnboardingForm.to_h
|
|
201
|
+
# => {
|
|
202
|
+
# name: "OnboardingForm",
|
|
203
|
+
# description: "Employee onboarding",
|
|
204
|
+
# fields: {
|
|
205
|
+
# first_name: { type: :string, required: true },
|
|
206
|
+
# email: { type: :string, required: true },
|
|
207
|
+
# notes: { type: :string, required: false },
|
|
208
|
+
# ...
|
|
209
|
+
# },
|
|
210
|
+
# nested: {
|
|
211
|
+
# address: { type: :one, fields: { ... }, nested: { ... } },
|
|
212
|
+
# documents: { type: :many, fields: { ... } }
|
|
213
|
+
# }
|
|
214
|
+
# }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### `to_json_schema`
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
OnboardingForm.to_json_schema
|
|
221
|
+
# => {
|
|
222
|
+
# "$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
223
|
+
# type: "object",
|
|
224
|
+
# title: "OnboardingForm",
|
|
225
|
+
# description: "Employee onboarding",
|
|
226
|
+
# properties: { email: { type: "string" }, ... },
|
|
227
|
+
# required: ["first_name", "last_name", "email", ...],
|
|
228
|
+
# additionalProperties: false
|
|
229
|
+
# }
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Global export
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
Dex::Form.export(format: :json_schema)
|
|
236
|
+
# => [{ ... OnboardingForm schema ... }, ...]
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Bulk export returns top-level named forms only. Nested helper classes generated by `nested_one` and `nested_many` stay embedded in their parent export instead of appearing as separate entries.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
124
243
|
## Nested Forms
|
|
125
244
|
|
|
126
245
|
### `nested_one`
|
|
@@ -129,9 +248,9 @@ One-to-one nested form. Automatically coerces Hash input.
|
|
|
129
248
|
|
|
130
249
|
```ruby
|
|
131
250
|
nested_one :address do
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
251
|
+
field :street, :string
|
|
252
|
+
field :city, :string
|
|
253
|
+
field? :apartment, :string
|
|
135
254
|
end
|
|
136
255
|
```
|
|
137
256
|
|
|
@@ -152,9 +271,8 @@ One-to-many nested form. Handles Array, Rails numbered Hash, and `_destroy`.
|
|
|
152
271
|
|
|
153
272
|
```ruby
|
|
154
273
|
nested_many :documents do
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
validates :document_type, :document_number, presence: true
|
|
274
|
+
field :document_type, :string
|
|
275
|
+
field :document_number, :string
|
|
158
276
|
end
|
|
159
277
|
```
|
|
160
278
|
|
|
@@ -190,7 +308,7 @@ Override the auto-generated constant name:
|
|
|
190
308
|
|
|
191
309
|
```ruby
|
|
192
310
|
nested_one :address, class_name: "HomeAddress" do
|
|
193
|
-
|
|
311
|
+
field :street, :string
|
|
194
312
|
end
|
|
195
313
|
# Creates MyForm::HomeAddress instead of MyForm::Address
|
|
196
314
|
```
|
|
@@ -306,7 +424,7 @@ form.to_hash # alias for to_h
|
|
|
306
424
|
|
|
307
425
|
### Controller pattern
|
|
308
426
|
|
|
309
|
-
Strong parameters (`permit`) are not required — the form's
|
|
427
|
+
Strong parameters (`permit`) are not required — the form's field declarations are the whitelist. Just `require` the top-level key:
|
|
310
428
|
|
|
311
429
|
```ruby
|
|
312
430
|
class OnboardingController < ApplicationController
|
|
@@ -405,33 +523,34 @@ A form spanning User, Employee, and Address — the core reason form objects exi
|
|
|
405
523
|
|
|
406
524
|
```ruby
|
|
407
525
|
class OnboardingForm < Dex::Form
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
526
|
+
description "Employee onboarding"
|
|
527
|
+
|
|
528
|
+
field :first_name, :string
|
|
529
|
+
field :last_name, :string
|
|
530
|
+
field :email, :string
|
|
531
|
+
field :department, :string
|
|
532
|
+
field :position, :string
|
|
533
|
+
field :start_date, :date
|
|
534
|
+
field :locale, :string
|
|
535
|
+
field? :notes, :string
|
|
536
|
+
|
|
537
|
+
context :locale
|
|
414
538
|
|
|
415
539
|
normalizes :email, with: -> { _1&.strip&.downcase.presence }
|
|
416
540
|
|
|
417
|
-
validates :email,
|
|
418
|
-
validates :first_name, :last_name, :department, :position, presence: true
|
|
419
|
-
validates :start_date, presence: true
|
|
541
|
+
validates :email, uniqueness: { model: User }
|
|
420
542
|
|
|
421
543
|
nested_one :address do
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
validates :street, :city, :country, presence: true
|
|
544
|
+
field :street, :string
|
|
545
|
+
field :city, :string
|
|
546
|
+
field :postal_code, :string
|
|
547
|
+
field :country, :string
|
|
548
|
+
field? :apartment, :string
|
|
428
549
|
end
|
|
429
550
|
|
|
430
551
|
nested_many :documents do
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
validates :document_type, :document_number, presence: true
|
|
552
|
+
field :document_type, :string
|
|
553
|
+
field :document_number, :string
|
|
435
554
|
end
|
|
436
555
|
|
|
437
556
|
def self.for(user)
|
|
@@ -484,23 +603,38 @@ Forms are standard ActiveModel objects. Test them with plain Minitest — no spe
|
|
|
484
603
|
|
|
485
604
|
```ruby
|
|
486
605
|
class OnboardingFormTest < Minitest::Test
|
|
487
|
-
def
|
|
606
|
+
def test_required_fields_validated
|
|
488
607
|
form = OnboardingForm.new
|
|
489
608
|
assert form.invalid?
|
|
490
609
|
assert form.errors[:email].any?
|
|
491
610
|
assert form.errors[:first_name].any?
|
|
492
611
|
end
|
|
493
612
|
|
|
613
|
+
def test_optional_fields_allowed_blank
|
|
614
|
+
form = OnboardingForm.new(
|
|
615
|
+
first_name: "Alice", last_name: "Smith",
|
|
616
|
+
email: "alice@example.com", department: "Eng",
|
|
617
|
+
position: "Dev", start_date: Date.today, locale: "en"
|
|
618
|
+
)
|
|
619
|
+
assert form.valid?
|
|
620
|
+
assert_nil form.notes
|
|
621
|
+
end
|
|
622
|
+
|
|
494
623
|
def test_normalizes_email
|
|
495
624
|
form = OnboardingForm.new(email: " ALICE@EXAMPLE.COM ")
|
|
496
625
|
assert_equal "alice@example.com", form.email
|
|
497
626
|
end
|
|
498
627
|
|
|
628
|
+
def test_context_fills_locale
|
|
629
|
+
form = Dex.with_context(locale: "en") { OnboardingForm.new }
|
|
630
|
+
assert_equal "en", form.locale
|
|
631
|
+
end
|
|
632
|
+
|
|
499
633
|
def test_nested_validation_propagation
|
|
500
634
|
form = OnboardingForm.new(
|
|
501
635
|
first_name: "Alice", last_name: "Smith",
|
|
502
636
|
email: "alice@example.com", department: "Eng",
|
|
503
|
-
position: "Developer", start_date: Date.today,
|
|
637
|
+
position: "Developer", start_date: Date.today, locale: "en",
|
|
504
638
|
address: { street: "", city: "", country: "" }
|
|
505
639
|
)
|
|
506
640
|
assert form.invalid?
|
|
@@ -516,5 +650,12 @@ class OnboardingFormTest < Minitest::Test
|
|
|
516
650
|
assert_equal "Alice", h[:first_name]
|
|
517
651
|
assert_equal "123 Main", h[:address][:street]
|
|
518
652
|
end
|
|
653
|
+
|
|
654
|
+
def test_json_schema_export
|
|
655
|
+
schema = OnboardingForm.to_json_schema
|
|
656
|
+
assert_equal "object", schema[:type]
|
|
657
|
+
assert_includes schema[:required], "first_name"
|
|
658
|
+
refute_includes schema[:required], "notes"
|
|
659
|
+
end
|
|
519
660
|
end
|
|
520
661
|
```
|
data/guides/llm/OPERATION.md
CHANGED
|
@@ -111,7 +111,7 @@ Optional declarations documenting intent and catching mistakes at runtime.
|
|
|
111
111
|
success _Ref(User) # perform must return a User (or nil)
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
**`error(*codes)`** — restricts which codes `error
|
|
114
|
+
**`error(*codes)`** — restricts which codes `error!` accepts (raises `ArgumentError` on undeclared):
|
|
115
115
|
|
|
116
116
|
```ruby
|
|
117
117
|
error :email_taken, :invalid_email
|
|
@@ -264,7 +264,7 @@ info = Order::Place.explain(product: product, customer: customer, quantity: 2)
|
|
|
264
264
|
# transaction: { enabled: true },
|
|
265
265
|
# rescue_from: { "Stripe::CardError" => :card_declined },
|
|
266
266
|
# callbacks: { before: 1, after: 2, around: 0 },
|
|
267
|
-
# pipeline: [:result, :guard, :once, :lock, :record, :transaction, :rescue, :callback],
|
|
267
|
+
# pipeline: [:trace, :result, :guard, :once, :lock, :record, :transaction, :rescue, :callback],
|
|
268
268
|
# callable: true
|
|
269
269
|
# }
|
|
270
270
|
```
|
|
@@ -297,13 +297,6 @@ success!(user) # return value early
|
|
|
297
297
|
success!(name: "John", age: 30) # kwargs become Hash
|
|
298
298
|
```
|
|
299
299
|
|
|
300
|
-
**`assert!(code, &block)` / `assert!(value, code)`** — returns value if truthy, otherwise `error!(code)`:
|
|
301
|
-
|
|
302
|
-
```ruby
|
|
303
|
-
user = assert!(:not_found) { User.find_by(id: id) }
|
|
304
|
-
assert!(user.active?, :inactive)
|
|
305
|
-
```
|
|
306
|
-
|
|
307
300
|
**Dex::Error** has `code` (Symbol), `message` (String, defaults to code.to_s), `details` (any). Pattern matching:
|
|
308
301
|
|
|
309
302
|
```ruby
|
|
@@ -317,7 +310,7 @@ rescue Dex::Error => e
|
|
|
317
310
|
end
|
|
318
311
|
```
|
|
319
312
|
|
|
320
|
-
**Key differences:** `error
|
|
313
|
+
**Key differences:** `error!` rolls back transaction, skips `after` callbacks, but is still recorded (status `error`). `success!` commits, runs `after` callbacks, records normally (status `completed`).
|
|
321
314
|
|
|
322
315
|
---
|
|
323
316
|
|
|
@@ -351,6 +344,10 @@ end
|
|
|
351
344
|
|
|
352
345
|
`Ok`/`Err` are available inside operations without prefix. In other contexts (controllers, POROs), use `Dex::Ok`/`Dex::Err` or `include Dex::Match`.
|
|
353
346
|
|
|
347
|
+
**Deconstruct forms:**
|
|
348
|
+
- Hash: `in Dex::Ok(key:)` — destructures value's keys. `in Dex::Err(code:, message:)` — named error fields.
|
|
349
|
+
- Array: `in Dex::Ok[value]` — binds entire value. `in Dex::Err[error]` — binds the `Dex::Error` instance.
|
|
350
|
+
|
|
354
351
|
---
|
|
355
352
|
|
|
356
353
|
## Rescue Mapping
|
|
@@ -464,16 +461,82 @@ On timeout: raises `Dex::Error(code: :lock_timeout)`. Works with `.safe`.
|
|
|
464
461
|
Enqueue as background jobs (requires ActiveJob):
|
|
465
462
|
|
|
466
463
|
```ruby
|
|
467
|
-
CreateUser.new(email: "a@b.com", name: "Alice").async.call
|
|
468
|
-
CreateUser.new(email: "a@b.com", name: "Alice").async(queue: "urgent").call
|
|
469
|
-
CreateUser.new(email: "a@b.com", name: "Alice").async(in: 5.minutes).call
|
|
470
|
-
CreateUser.new(email: "a@b.com", name: "Alice").async(at: 1.hour.from_now).call
|
|
464
|
+
ticket = CreateUser.new(email: "a@b.com", name: "Alice").async.call
|
|
465
|
+
ticket = CreateUser.new(email: "a@b.com", name: "Alice").async(queue: "urgent").call
|
|
466
|
+
ticket = CreateUser.new(email: "a@b.com", name: "Alice").async(in: 5.minutes).call
|
|
467
|
+
ticket = CreateUser.new(email: "a@b.com", name: "Alice").async(at: 1.hour.from_now).call
|
|
471
468
|
```
|
|
472
469
|
|
|
473
470
|
Class-level defaults: `async queue: "mailers"`. Runtime options override.
|
|
474
471
|
|
|
475
472
|
Props serialize/deserialize automatically (Date, Time, BigDecimal, Symbol, `_Ref` — all handled). Non-serializable props raise `ArgumentError` at enqueue time.
|
|
476
473
|
|
|
474
|
+
### Ticket
|
|
475
|
+
|
|
476
|
+
`async.call` returns a `Dex::Operation::Ticket` with `record` (operation record if recording enabled) and `job` (the enqueued job).
|
|
477
|
+
|
|
478
|
+
```ruby
|
|
479
|
+
ticket.id # record ID
|
|
480
|
+
ticket.operation_name # operation class name
|
|
481
|
+
ticket.status # "pending"/"running"/"completed"/"error"/"failed"
|
|
482
|
+
ticket.recorded? # true if record strategy was used
|
|
483
|
+
ticket.pending? # status predicates
|
|
484
|
+
ticket.terminal? # completed? || error? || failed?
|
|
485
|
+
ticket.reload # refresh from DB, returns self
|
|
486
|
+
ticket.to_param # id.to_s (Rails path helpers)
|
|
487
|
+
ticket.as_json # { "id": ..., "name": ..., "status": ..., "result"?: ..., "error"?: ... }
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`Ticket.from_record(record)` constructs from any operation record (for polling endpoints/dashboards).
|
|
491
|
+
|
|
492
|
+
Record-dependent methods raise `ArgumentError` when `record` is nil (direct strategy without recording).
|
|
493
|
+
|
|
494
|
+
### Outcome Reconstruction
|
|
495
|
+
|
|
496
|
+
`ticket.outcome` reconstructs `Ok`/`Err` from the record — same types as `.safe.call`:
|
|
497
|
+
|
|
498
|
+
- `completed` → `Ok(result)` with deep-symbolized keys
|
|
499
|
+
- `error` → `Err(Dex::Error)` with symbolized code/details
|
|
500
|
+
- `failed`/`pending`/`running` → `nil`
|
|
501
|
+
|
|
502
|
+
Never raises, never reloads. Call `reload` first for fresh data.
|
|
503
|
+
|
|
504
|
+
### wait / wait!
|
|
505
|
+
|
|
506
|
+
Speculative sync — poll until terminal or timeout:
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
# Safe mode (Ok/Err/nil)
|
|
510
|
+
case ticket.wait(3.seconds)
|
|
511
|
+
in Dex::Ok(url:)
|
|
512
|
+
redirect_to url
|
|
513
|
+
in Dex::Err(code:, message:)
|
|
514
|
+
flash[:error] = message
|
|
515
|
+
redirect_to fallback_path
|
|
516
|
+
else
|
|
517
|
+
redirect_to pending_path(ticket)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Strict mode (value or exception)
|
|
521
|
+
result = ticket.wait!(3.seconds)
|
|
522
|
+
redirect_to result[:url]
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
| | Success | Business Error | Infra Crash | Timeout |
|
|
526
|
+
|---|---|---|---|---|
|
|
527
|
+
| `call` | value | `Dex::Error` | exception | n/a |
|
|
528
|
+
| `safe.call` | `Ok` | `Err` | exception | n/a |
|
|
529
|
+
| `wait!(t)` | value | `Dex::Error` | `OperationFailed` | `Dex::Timeout` |
|
|
530
|
+
| `wait(t)` | `Ok` | `Err` | `OperationFailed` | `nil` |
|
|
531
|
+
|
|
532
|
+
Options: `wait(timeout, interval: 0.2)`. Interval accepts a number or callable (`->(n) { ... }`).
|
|
533
|
+
|
|
534
|
+
`Dex::OperationFailed` (inherits `StandardError`): `operation_name`, `exception_class`, `exception_message`.
|
|
535
|
+
`Dex::Timeout` (inherits `StandardError`): `timeout`, `ticket_id`, `operation_name`.
|
|
536
|
+
Neither inherits `Dex::Error` — `rescue Dex::Error` never catches them.
|
|
537
|
+
|
|
538
|
+
`safe` and `async` are non-composable — `op.safe.async` and `op.async.safe` raise `NoMethodError` with prescriptive messages.
|
|
539
|
+
|
|
477
540
|
---
|
|
478
541
|
|
|
479
542
|
## Recording
|
|
@@ -481,8 +544,12 @@ Props serialize/deserialize automatically (Date, Time, BigDecimal, Symbol, `_Ref
|
|
|
481
544
|
Record execution to database. Requires `Dex.configure { |c| c.record_class = OperationRecord }`.
|
|
482
545
|
|
|
483
546
|
```ruby
|
|
484
|
-
create_table :operation_records do |t|
|
|
547
|
+
create_table :operation_records, id: :string do |t|
|
|
485
548
|
t.string :name, null: false # operation class name
|
|
549
|
+
t.string :trace_id # shared trace / correlation ID
|
|
550
|
+
t.string :actor_type # root actor type
|
|
551
|
+
t.string :actor_id # root actor ID
|
|
552
|
+
t.jsonb :trace # full trace snapshot
|
|
486
553
|
t.jsonb :params # serialized props (nil = not captured)
|
|
487
554
|
t.jsonb :result # serialized return value
|
|
488
555
|
t.string :status, null: false # pending/running/completed/error/failed
|
|
@@ -498,6 +565,8 @@ end
|
|
|
498
565
|
add_index :operation_records, :name
|
|
499
566
|
add_index :operation_records, :status
|
|
500
567
|
add_index :operation_records, [:name, :status]
|
|
568
|
+
add_index :operation_records, :trace_id
|
|
569
|
+
add_index :operation_records, [:actor_type, :actor_id]
|
|
501
570
|
```
|
|
502
571
|
|
|
503
572
|
Control per-operation:
|
|
@@ -518,12 +587,37 @@ Required attributes by feature:
|
|
|
518
587
|
- Async record jobs: `params`
|
|
519
588
|
- `once`: `once_key`, plus `once_key_expires_at` when `expires_in:` is used
|
|
520
589
|
|
|
590
|
+
Trace columns (`id`, `trace_id`, `actor_type`, `actor_id`, `trace`) are recommended for tracing. Dex persists them when present, omits them when missing.
|
|
591
|
+
|
|
521
592
|
Untyped results are sanitized to JSON-safe values before persistence: Hash keys round-trip as strings, and objects fall back to `as_json`/`to_s` under `"_dex_value"`.
|
|
522
593
|
|
|
523
594
|
Status values: `pending` (async enqueued), `running` (async executing), `completed` (success), `error` (business error via `error!`), `failed` (unhandled exception).
|
|
524
595
|
|
|
525
596
|
When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params.
|
|
526
597
|
|
|
598
|
+
## Execution tracing
|
|
599
|
+
|
|
600
|
+
Every operation call gets an `op_...` execution ID and joins a fiber-local trace shared across operations, handlers, and async jobs.
|
|
601
|
+
|
|
602
|
+
```ruby
|
|
603
|
+
Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
|
|
604
|
+
Order::Place.call(product: product, customer: customer, quantity: 2)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
Dex::Trace.trace_id # => "tr_..."
|
|
608
|
+
Dex::Trace.current # => [{ type: :actor, ... }, { type: :operation, ... }]
|
|
609
|
+
Dex::Trace.to_s # => "user:42 > Order::Place(op_2nFg7K)"
|
|
610
|
+
Dex.actor # => { type: "user", id: "123" } or nil
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
`Dex.actor` returns the actor hash (reconstituted to the shape you passed in), or `nil`. Use it in `perform` when you need to write actor info into domain models.
|
|
614
|
+
|
|
615
|
+
`Dex.system(name = nil)` is a convenience for background jobs: `Dex::Trace.start(actor: Dex.system("payroll")) { ... }`.
|
|
616
|
+
|
|
617
|
+
Tracing is always on – no opt-in needed. Async operations serialize and restore the trace automatically. When recording is enabled, `trace_id`, `actor_type`, `actor_id`, and `trace` are persisted alongside the usual record fields.
|
|
618
|
+
|
|
619
|
+
IDs are generated by `Dex::Id` – a general-purpose Stripe-style ID generator. Use it for your own models: `Dex::Id.generate("ord_")`. Parse with `Dex::Id.parse(id)` to extract prefix, timestamp, and random components.
|
|
620
|
+
|
|
527
621
|
---
|
|
528
622
|
|
|
529
623
|
## Idempotency (once)
|
|
@@ -577,7 +671,7 @@ ChargeOrder.clear_once!("webhook-123") # by raw string key
|
|
|
577
671
|
|
|
578
672
|
Clearing is idempotent — clearing a non-existent key is a no-op. After clearing, the next call executes normally.
|
|
579
673
|
|
|
580
|
-
**Pipeline position:** result → **once** → lock → record → transaction → rescue →
|
|
674
|
+
**Pipeline position:** trace → result → guard → **once** → lock → record → transaction → rescue → callback. The once check runs before locking and recording, so duplicate calls short-circuit early.
|
|
581
675
|
|
|
582
676
|
**Requirements:**
|
|
583
677
|
|
|
@@ -649,19 +743,6 @@ refute_err result
|
|
|
649
743
|
refute_err result, :not_found # Ok OR different code
|
|
650
744
|
```
|
|
651
745
|
|
|
652
|
-
### One-Liner Assertions
|
|
653
|
-
|
|
654
|
-
Call + assert in one step:
|
|
655
|
-
|
|
656
|
-
```ruby
|
|
657
|
-
assert_operation(email: "a@b.com", name: "Alice") # Ok
|
|
658
|
-
assert_operation(CreateUser, email: "a@b.com", name: "Alice") # explicit class
|
|
659
|
-
assert_operation(email: "a@b.com", name: "Alice", returns: user) # check value
|
|
660
|
-
|
|
661
|
-
assert_operation_error(:invalid_email, email: "bad", name: "A")
|
|
662
|
-
assert_operation_error(CreateUser, :email_taken, email: "taken@b.com", name: "A")
|
|
663
|
-
```
|
|
664
|
-
|
|
665
746
|
### Contract Assertions
|
|
666
747
|
|
|
667
748
|
```ruby
|
|
@@ -688,7 +769,7 @@ refute_callable(:unauthorized, post: post, user: user) # specific guar
|
|
|
688
769
|
refute_callable(PublishPost, :unauthorized, post: post, user: user)
|
|
689
770
|
```
|
|
690
771
|
|
|
691
|
-
Guard failures on the normal `call` path produce `Dex::Error`, so `
|
|
772
|
+
Guard failures on the normal `call` path produce `Dex::Error`, so `assert_err` also works.
|
|
692
773
|
|
|
693
774
|
### Param Validation
|
|
694
775
|
|
|
@@ -714,21 +795,6 @@ assert_rolls_back(User) { CreateUser.call(email: "bad", name: "A") }
|
|
|
714
795
|
assert_commits(User) { CreateUser.call(email: "ok@b.com", name: "A") }
|
|
715
796
|
```
|
|
716
797
|
|
|
717
|
-
### Batch Assertions
|
|
718
|
-
|
|
719
|
-
```ruby
|
|
720
|
-
assert_all_succeed(params_list: [
|
|
721
|
-
{ email: "a@b.com", name: "A" },
|
|
722
|
-
{ email: "b@b.com", name: "B" }
|
|
723
|
-
])
|
|
724
|
-
|
|
725
|
-
assert_all_fail(code: :invalid_email, params_list: [
|
|
726
|
-
{ email: "", name: "A" },
|
|
727
|
-
{ email: "no-at", name: "B" }
|
|
728
|
-
])
|
|
729
|
-
# Also supports message: and details: options
|
|
730
|
-
```
|
|
731
|
-
|
|
732
798
|
### Stubbing
|
|
733
799
|
|
|
734
800
|
Replace an operation within a block. Bypasses all wrappers, not recorded in TestLog:
|
|
@@ -798,19 +864,9 @@ class CreateUserTest < Minitest::Test
|
|
|
798
864
|
assert_ok(result) { |user| assert_equal "Alice", user.name }
|
|
799
865
|
end
|
|
800
866
|
|
|
801
|
-
def test_one_liner
|
|
802
|
-
assert_operation(email: "a@b.com", name: "Alice")
|
|
803
|
-
end
|
|
804
|
-
|
|
805
867
|
def test_rejects_bad_email
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
def test_batch_rejects
|
|
810
|
-
assert_all_fail(code: :invalid_email, params_list: [
|
|
811
|
-
{ email: "", name: "A" },
|
|
812
|
-
{ email: "no-at", name: "B" }
|
|
813
|
-
])
|
|
868
|
+
result = call_operation(email: "bad", name: "A")
|
|
869
|
+
assert_err result, :invalid_email
|
|
814
870
|
end
|
|
815
871
|
|
|
816
872
|
def test_stubs_dependency
|
|
@@ -887,6 +943,8 @@ end
|
|
|
887
943
|
|
|
888
944
|
Requires `gem 'ruby_llm'` in your Gemfile. Lazy-loaded — ruby-llm is only required when you call `Dex::Tool`.
|
|
889
945
|
|
|
946
|
+
See `TOOL.md` for the full Tool reference including query tools.
|
|
947
|
+
|
|
890
948
|
---
|
|
891
949
|
|
|
892
950
|
**End of reference.**
|