dexkit 0.8.0 → 0.10.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 +36 -0
- data/README.md +50 -18
- data/gemfiles/mongoid_no_ar.gemfile +10 -0
- data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
- data/guides/llm/EVENT.md +41 -23
- data/guides/llm/FORM.md +202 -61
- data/guides/llm/OPERATION.md +49 -20
- data/guides/llm/QUERY.md +52 -2
- data/lib/dex/context_dsl.rb +56 -0
- data/lib/dex/context_setup.rb +2 -33
- data/lib/dex/event/bus.rb +85 -8
- data/lib/dex/event/handler.rb +18 -0
- data/lib/dex/event/metadata.rb +16 -9
- data/lib/dex/event/processor.rb +1 -1
- data/lib/dex/event/test_helpers.rb +88 -0
- data/lib/dex/event/trace.rb +14 -27
- data/lib/dex/event.rb +2 -7
- data/lib/dex/event_test_helpers.rb +1 -86
- 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/uniqueness_validator.rb +17 -1
- data/lib/dex/form.rb +119 -3
- data/lib/dex/id.rb +38 -0
- data/lib/dex/operation/async_proxy.rb +13 -2
- data/lib/dex/operation/explain.rb +11 -7
- data/lib/dex/operation/jobs.rb +5 -4
- data/lib/dex/operation/lock_wrapper.rb +15 -2
- data/lib/dex/operation/once_wrapper.rb +24 -15
- data/lib/dex/operation/record_backend.rb +15 -1
- data/lib/dex/operation/record_wrapper.rb +43 -8
- data/lib/dex/operation/test_helpers/assertions.rb +359 -0
- data/lib/dex/operation/test_helpers/execution.rb +30 -0
- data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
- data/lib/dex/operation/test_helpers.rb +160 -0
- data/lib/dex/operation/trace_wrapper.rb +20 -0
- data/lib/dex/operation/transaction_adapter.rb +29 -68
- data/lib/dex/operation/transaction_wrapper.rb +10 -16
- data/lib/dex/operation.rb +2 -0
- data/lib/dex/query/backend.rb +13 -0
- data/lib/dex/query/export.rb +64 -0
- data/lib/dex/query.rb +50 -5
- data/lib/dex/ref_type.rb +4 -0
- data/lib/dex/test_helpers.rb +4 -139
- data/lib/dex/test_log.rb +62 -4
- data/lib/dex/trace.rb +291 -0
- data/lib/dex/type_coercion.rb +4 -1
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +9 -5
- metadata +16 -5
- data/lib/dex/test_helpers/assertions.rb +0 -333
- data/lib/dex/test_helpers/execution.rb +0 -28
- data/lib/dex/test_helpers/stubbing.rb +0 -59
- /data/lib/dex/{event_test_helpers → event/test_helpers}/assertions.rb +0 -0
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
|
```
|
|
@@ -224,7 +342,7 @@ validates :email, uniqueness: true
|
|
|
224
342
|
| `model:` | Explicit model class | `uniqueness: { model: User }` |
|
|
225
343
|
| `attribute:` | Column name if different | `uniqueness: { attribute: :email }` |
|
|
226
344
|
| `scope:` | Scoped uniqueness | `uniqueness: { scope: :tenant_id }` |
|
|
227
|
-
| `case_sensitive:` | Case-insensitive check | `uniqueness: { case_sensitive: false }` |
|
|
345
|
+
| `case_sensitive:` | Case-insensitive check (`LOWER()` on ActiveRecord, case-insensitive regex on Mongoid) | `uniqueness: { case_sensitive: false }` |
|
|
228
346
|
| `conditions:` | Extra query conditions | `uniqueness: { conditions: -> { where(active: true) } }` |
|
|
229
347
|
| `message:` | Custom error message | `uniqueness: { message: "already registered" }` |
|
|
230
348
|
|
|
@@ -237,7 +355,7 @@ validates :email, uniqueness: true
|
|
|
237
355
|
|
|
238
356
|
### Record exclusion
|
|
239
357
|
|
|
240
|
-
When `form.record` is persisted, the current record is excluded from the uniqueness check (for updates).
|
|
358
|
+
When `form.record` is persisted, the current record is excluded from the uniqueness check (for updates) on both ActiveRecord and Mongoid-backed forms.
|
|
241
359
|
|
|
242
360
|
---
|
|
243
361
|
|
|
@@ -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
|
@@ -95,7 +95,7 @@ prop? :note, String # optional (nilable, default: nil)
|
|
|
95
95
|
|
|
96
96
|
### _Ref(Model)
|
|
97
97
|
|
|
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
|
+
Accepts model instances or IDs, coerces IDs via `Model.find(id)`. With `lock: true`, uses `Model.lock.find(id)` (SELECT FOR UPDATE) – requires a model that responds to `.lock` (ActiveRecord). Mongoid documents do not support row locks and raise `ArgumentError` at declaration time. Instances pass through without re-locking. In serialization (recording, async), stores model ID only via `id.as_json`, so Mongoid BSON::ObjectId values are safe in ActiveJob payloads too. IDs are treated as strings in JSON Schema – this supports integer PKs, UUIDs, and Mongoid BSON::ObjectId equally.
|
|
99
99
|
|
|
100
100
|
Outside the class body (e.g., in tests), use `Dex::RefType.new(Model)` instead of `_Ref(Model)`.
|
|
101
101
|
|
|
@@ -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
|
```
|
|
@@ -408,11 +408,11 @@ end
|
|
|
408
408
|
|
|
409
409
|
## Transactions
|
|
410
410
|
|
|
411
|
-
Operations run inside database transactions
|
|
411
|
+
Operations run inside database transactions when Dex has an active transaction adapter. ActiveRecord is auto-detected. In Mongoid-only apps, no adapter is active, so transactions are automatically disabled – but `after_commit` still works (callbacks fire immediately after success). If you need Mongoid transactions, use `Mongoid.transaction` directly inside `perform`.
|
|
412
412
|
|
|
413
413
|
```ruby
|
|
414
414
|
transaction false # disable
|
|
415
|
-
transaction :
|
|
415
|
+
transaction :active_record # explicit adapter
|
|
416
416
|
```
|
|
417
417
|
|
|
418
418
|
Child classes can re-enable: `transaction true`.
|
|
@@ -433,7 +433,7 @@ end
|
|
|
433
433
|
Callbacks are always deferred — they run after the outermost operation boundary succeeds:
|
|
434
434
|
|
|
435
435
|
- **Transactional operations:** deferred until the DB transaction commits.
|
|
436
|
-
- **Non-transactional operations:** queued in memory, flushed after the operation pipeline completes successfully.
|
|
436
|
+
- **Non-transactional operations (including Mongoid-only):** queued in memory, flushed after the operation pipeline completes successfully.
|
|
437
437
|
- **Nested operations:** callbacks queue up and flush once at the outermost successful boundary.
|
|
438
438
|
- **On error (`error!` or exception):** queued callbacks are discarded.
|
|
439
439
|
|
|
@@ -441,13 +441,11 @@ Multiple blocks run in registration order.
|
|
|
441
441
|
|
|
442
442
|
**ActiveRecord:** requires Rails 7.2+ (`after_all_transactions_commit`).
|
|
443
443
|
|
|
444
|
-
**Mongoid:** deferred across nested Dex operations. Ambient `Mongoid.transaction` blocks opened outside Dex are not detected — callbacks will fire immediately in that case.
|
|
445
|
-
|
|
446
444
|
---
|
|
447
445
|
|
|
448
446
|
## Advisory Locking
|
|
449
447
|
|
|
450
|
-
Mutual exclusion via database advisory locks (requires `with_advisory_lock` gem). Wraps **outside** the transaction.
|
|
448
|
+
Mutual exclusion via database advisory locks (requires `with_advisory_lock` gem). Wraps **outside** the transaction. ActiveRecord-only; Mongoid-only apps get a clear `LoadError`.
|
|
451
449
|
|
|
452
450
|
```ruby
|
|
453
451
|
advisory_lock { "pay:#{charge_id}" } # dynamic key from props
|
|
@@ -483,8 +481,12 @@ Props serialize/deserialize automatically (Date, Time, BigDecimal, Symbol, `_Ref
|
|
|
483
481
|
Record execution to database. Requires `Dex.configure { |c| c.record_class = OperationRecord }`.
|
|
484
482
|
|
|
485
483
|
```ruby
|
|
486
|
-
create_table :operation_records do |t|
|
|
484
|
+
create_table :operation_records, id: :string do |t|
|
|
487
485
|
t.string :name, null: false # operation class name
|
|
486
|
+
t.string :trace_id # shared trace / correlation ID
|
|
487
|
+
t.string :actor_type # root actor type
|
|
488
|
+
t.string :actor_id # root actor ID
|
|
489
|
+
t.jsonb :trace # full trace snapshot
|
|
488
490
|
t.jsonb :params # serialized props (nil = not captured)
|
|
489
491
|
t.jsonb :result # serialized return value
|
|
490
492
|
t.string :status, null: false # pending/running/completed/error/failed
|
|
@@ -500,6 +502,8 @@ end
|
|
|
500
502
|
add_index :operation_records, :name
|
|
501
503
|
add_index :operation_records, :status
|
|
502
504
|
add_index :operation_records, [:name, :status]
|
|
505
|
+
add_index :operation_records, :trace_id
|
|
506
|
+
add_index :operation_records, [:actor_type, :actor_id]
|
|
503
507
|
```
|
|
504
508
|
|
|
505
509
|
Control per-operation:
|
|
@@ -510,12 +514,40 @@ record result: false # params only
|
|
|
510
514
|
record params: false # result only
|
|
511
515
|
```
|
|
512
516
|
|
|
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).
|
|
517
|
+
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). Dex validates the configured record model before use and raises if required attributes are missing.
|
|
518
|
+
|
|
519
|
+
Required attributes by feature:
|
|
520
|
+
|
|
521
|
+
- Core recording: `name`, `status`, `error_code`, `error_message`, `error_details`, `performed_at`
|
|
522
|
+
- Params capture: `params` unless `record params: false`
|
|
523
|
+
- Result capture: `result` unless `record result: false`
|
|
524
|
+
- Async record jobs: `params`
|
|
525
|
+
- `once`: `once_key`, plus `once_key_expires_at` when `expires_in:` is used
|
|
526
|
+
|
|
527
|
+
Trace columns (`id`, `trace_id`, `actor_type`, `actor_id`, `trace`) are recommended for tracing. Dex persists them when present, omits them when missing.
|
|
528
|
+
|
|
529
|
+
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"`.
|
|
514
530
|
|
|
515
531
|
Status values: `pending` (async enqueued), `running` (async executing), `completed` (success), `error` (business error via `error!`), `failed` (unhandled exception).
|
|
516
532
|
|
|
517
533
|
When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params.
|
|
518
534
|
|
|
535
|
+
## Execution tracing
|
|
536
|
+
|
|
537
|
+
Every operation call gets an `op_...` execution ID and joins a fiber-local trace shared across operations, handlers, and async jobs.
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
|
|
541
|
+
Order::Place.call(product: product, customer: customer, quantity: 2)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
Dex::Trace.trace_id # => "tr_..."
|
|
545
|
+
Dex::Trace.current # => [{ type: :actor, ... }, { type: :operation, ... }]
|
|
546
|
+
Dex::Trace.to_s # => "user:42 > Order::Place(op_2nFg7K)"
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
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.
|
|
550
|
+
|
|
519
551
|
---
|
|
520
552
|
|
|
521
553
|
## Idempotency (once)
|
|
@@ -569,12 +601,12 @@ ChargeOrder.clear_once!("webhook-123") # by raw string key
|
|
|
569
601
|
|
|
570
602
|
Clearing is idempotent — clearing a non-existent key is a no-op. After clearing, the next call executes normally.
|
|
571
603
|
|
|
572
|
-
**Pipeline position:** result → **once** → lock → record → transaction → rescue →
|
|
604
|
+
**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.
|
|
573
605
|
|
|
574
606
|
**Requirements:**
|
|
575
607
|
|
|
576
608
|
- Record backend must be configured (`Dex.configure { |c| c.record_class = OperationRecord }`)
|
|
577
|
-
- The record
|
|
609
|
+
- The record backend must satisfy the Recording requirements above, and `once` additionally requires `once_key` plus `once_key_expires_at` when `expires_in:` is used
|
|
578
610
|
- `once` cannot be declared with `record false` — raises `ArgumentError`
|
|
579
611
|
- Only one `once` declaration per operation
|
|
580
612
|
|
|
@@ -586,11 +618,10 @@ Clearing is idempotent — clearing a non-existent key is a no-op. After clearin
|
|
|
586
618
|
# config/initializers/dexkit.rb
|
|
587
619
|
Dex.configure do |config|
|
|
588
620
|
config.record_class = OperationRecord # model for recording (default: nil)
|
|
589
|
-
config.transaction_adapter = nil # auto-detect (default); or :active_record / :mongoid
|
|
590
621
|
end
|
|
591
622
|
```
|
|
592
623
|
|
|
593
|
-
All DSL methods validate arguments at declaration time — typos and wrong types raise `ArgumentError` immediately (e.g., `error "string"`, `async priority: 5`, `transaction :redis`).
|
|
624
|
+
All DSL methods validate arguments at declaration time — typos and wrong types raise `ArgumentError` immediately (e.g., `error "string"`, `async priority: 5`, `transaction :redis`). Only `:active_record` is a valid transaction adapter.
|
|
594
625
|
|
|
595
626
|
---
|
|
596
627
|
|
|
@@ -598,22 +629,20 @@ All DSL methods validate arguments at declaration time — typos and wrong types
|
|
|
598
629
|
|
|
599
630
|
```ruby
|
|
600
631
|
# test/test_helper.rb
|
|
601
|
-
require "dex/test_helpers"
|
|
632
|
+
require "dex/operation/test_helpers"
|
|
602
633
|
|
|
603
634
|
class Minitest::Test
|
|
604
|
-
include Dex::TestHelpers
|
|
635
|
+
include Dex::Operation::TestHelpers
|
|
605
636
|
end
|
|
606
637
|
```
|
|
607
638
|
|
|
608
639
|
Not autoloaded — stays out of production. TestLog and stubs are auto-cleared in `setup`.
|
|
609
640
|
|
|
610
|
-
For Mongoid-backed operation tests, run against a MongoDB replica set (MongoDB transactions require it).
|
|
611
|
-
|
|
612
641
|
### Subject & Execution
|
|
613
642
|
|
|
614
643
|
```ruby
|
|
615
644
|
class CreateUserTest < Minitest::Test
|
|
616
|
-
include Dex::TestHelpers
|
|
645
|
+
include Dex::Operation::TestHelpers
|
|
617
646
|
|
|
618
647
|
testing CreateUser # default for all helpers
|
|
619
648
|
|
|
@@ -778,7 +807,7 @@ Each entry has: `name`, `operation_class`, `params`, `result` (Ok/Err), `duratio
|
|
|
778
807
|
|
|
779
808
|
```ruby
|
|
780
809
|
class CreateUserTest < Minitest::Test
|
|
781
|
-
include Dex::TestHelpers
|
|
810
|
+
include Dex::Operation::TestHelpers
|
|
782
811
|
|
|
783
812
|
testing CreateUser
|
|
784
813
|
|
data/guides/llm/QUERY.md
CHANGED
|
@@ -10,12 +10,17 @@ All examples below build on this query unless noted otherwise:
|
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
12
|
class UserSearch < Dex::Query
|
|
13
|
+
description "Search and filter users"
|
|
14
|
+
|
|
13
15
|
scope { User.all }
|
|
14
16
|
|
|
15
17
|
prop? :name, String
|
|
16
18
|
prop? :role, _Array(String)
|
|
17
19
|
prop? :age_min, Integer
|
|
18
20
|
prop? :status, String
|
|
21
|
+
prop? :tenant, String
|
|
22
|
+
|
|
23
|
+
context tenant: :current_tenant
|
|
19
24
|
|
|
20
25
|
filter :name, :contains
|
|
21
26
|
filter :role, :in
|
|
@@ -71,6 +76,51 @@ prop? :age_min, Integer # optional integer
|
|
|
71
76
|
|
|
72
77
|
Reserved prop names: `scope`, `sort`, `resolve`, `call`, `from_params`, `to_params`, `param_key`.
|
|
73
78
|
|
|
79
|
+
### Description
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
class UserSearch < Dex::Query
|
|
83
|
+
description "Search and filter users"
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Context
|
|
88
|
+
|
|
89
|
+
Same `context` DSL as Operation and Event. Auto-fills props from `Dex.with_context`:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
class UserSearch < Dex::Query
|
|
93
|
+
prop? :tenant, String
|
|
94
|
+
context tenant: :current_tenant
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# In a controller around_action:
|
|
98
|
+
Dex.with_context(current_tenant: current_tenant) do
|
|
99
|
+
UserSearch.call(name: "ali") # tenant auto-filled
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Identity shorthand: `context :locale` maps prop `:locale` to context key `:locale`.
|
|
104
|
+
|
|
105
|
+
Explicit values always win over ambient context. Inheritance merges parent + child mappings.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Registry & Export
|
|
110
|
+
|
|
111
|
+
Queries extend `Registry` — same API as Operation, Event, and Form:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
Dex::Query.registry # => Set of all named Query subclasses
|
|
115
|
+
UserSearch.description # => "Search and filter users"
|
|
116
|
+
|
|
117
|
+
UserSearch.to_h # => { name:, description:, props:, filters:, sorts:, context: }
|
|
118
|
+
UserSearch.to_json_schema # => JSON Schema (Draft 2020-12)
|
|
119
|
+
|
|
120
|
+
Dex::Query.export(format: :hash) # => sorted array of all query to_h
|
|
121
|
+
Dex::Query.export(format: :json_schema) # => sorted array of all query to_json_schema
|
|
122
|
+
```
|
|
123
|
+
|
|
74
124
|
---
|
|
75
125
|
|
|
76
126
|
## Filters
|
|
@@ -91,7 +141,7 @@ Reserved prop names: `scope`, `sort`, `resolve`, `call`, `from_params`, `to_para
|
|
|
91
141
|
| `:in` | `IN (values)` | `filter :roles, :in, column: :role` |
|
|
92
142
|
| `:not_in` | `NOT IN (values)` | `filter :roles, :not_in, column: :role` |
|
|
93
143
|
|
|
94
|
-
String strategies (`:contains`, `:starts_with`, `:ends_with`) use case-insensitive matching. With ActiveRecord, this uses Arel `matches` (LIKE); with Mongoid, case-insensitive regex. Wildcards in values are auto-sanitized. The adapter is auto-detected from the scope.
|
|
144
|
+
String strategies (`:contains`, `:starts_with`, `:ends_with`) use case-insensitive matching. With ActiveRecord, this uses Arel `matches` (LIKE); with Mongoid, case-insensitive regex. Wildcards in values are auto-sanitized. The adapter is auto-detected from the scope, and Mongoid association scopes/proxies are normalized to `Mongoid::Criteria` automatically.
|
|
95
145
|
|
|
96
146
|
### Column Mapping
|
|
97
147
|
|
|
@@ -159,7 +209,7 @@ Only one default per class. Applied when no sort is provided.
|
|
|
159
209
|
|
|
160
210
|
### `.call`
|
|
161
211
|
|
|
162
|
-
Returns a queryable scope (`ActiveRecord::Relation` or `Mongoid::Criteria`):
|
|
212
|
+
Returns a queryable scope (`ActiveRecord::Relation` or `Mongoid::Criteria`). Association scopes like `current_user.posts` work too:
|
|
163
213
|
|
|
164
214
|
```ruby
|
|
165
215
|
users = UserSearch.call(name: "ali", role: %w[admin], sort: "-name")
|