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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +50 -18
  4. data/gemfiles/mongoid_no_ar.gemfile +10 -0
  5. data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
  6. data/guides/llm/EVENT.md +41 -23
  7. data/guides/llm/FORM.md +202 -61
  8. data/guides/llm/OPERATION.md +49 -20
  9. data/guides/llm/QUERY.md +52 -2
  10. data/lib/dex/context_dsl.rb +56 -0
  11. data/lib/dex/context_setup.rb +2 -33
  12. data/lib/dex/event/bus.rb +85 -8
  13. data/lib/dex/event/handler.rb +18 -0
  14. data/lib/dex/event/metadata.rb +16 -9
  15. data/lib/dex/event/processor.rb +1 -1
  16. data/lib/dex/event/test_helpers.rb +88 -0
  17. data/lib/dex/event/trace.rb +14 -27
  18. data/lib/dex/event.rb +2 -7
  19. data/lib/dex/event_test_helpers.rb +1 -86
  20. data/lib/dex/form/context.rb +27 -0
  21. data/lib/dex/form/export.rb +128 -0
  22. data/lib/dex/form/nesting.rb +2 -0
  23. data/lib/dex/form/uniqueness_validator.rb +17 -1
  24. data/lib/dex/form.rb +119 -3
  25. data/lib/dex/id.rb +38 -0
  26. data/lib/dex/operation/async_proxy.rb +13 -2
  27. data/lib/dex/operation/explain.rb +11 -7
  28. data/lib/dex/operation/jobs.rb +5 -4
  29. data/lib/dex/operation/lock_wrapper.rb +15 -2
  30. data/lib/dex/operation/once_wrapper.rb +24 -15
  31. data/lib/dex/operation/record_backend.rb +15 -1
  32. data/lib/dex/operation/record_wrapper.rb +43 -8
  33. data/lib/dex/operation/test_helpers/assertions.rb +359 -0
  34. data/lib/dex/operation/test_helpers/execution.rb +30 -0
  35. data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
  36. data/lib/dex/operation/test_helpers.rb +160 -0
  37. data/lib/dex/operation/trace_wrapper.rb +20 -0
  38. data/lib/dex/operation/transaction_adapter.rb +29 -68
  39. data/lib/dex/operation/transaction_wrapper.rb +10 -16
  40. data/lib/dex/operation.rb +2 -0
  41. data/lib/dex/query/backend.rb +13 -0
  42. data/lib/dex/query/export.rb +64 -0
  43. data/lib/dex/query.rb +50 -5
  44. data/lib/dex/ref_type.rb +4 -0
  45. data/lib/dex/test_helpers.rb +4 -139
  46. data/lib/dex/test_log.rb +62 -4
  47. data/lib/dex/trace.rb +291 -0
  48. data/lib/dex/type_coercion.rb +4 -1
  49. data/lib/dex/version.rb +1 -1
  50. data/lib/dexkit.rb +9 -5
  51. metadata +16 -5
  52. data/lib/dex/test_helpers/assertions.rb +0 -333
  53. data/lib/dex/test_helpers/execution.rb +0 -28
  54. data/lib/dex/test_helpers/stubbing.rb +0 -59
  55. /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
- attribute :first_name, :string
16
- attribute :last_name, :string
17
- attribute :email, :string
18
- attribute :department, :string
19
- attribute :start_date, :date
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, presence: true, uniqueness: true
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
- attribute :street, :string
29
- attribute :city, :string
30
- attribute :postal_code, :string
31
- attribute :country, :string
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
- attribute :document_type, :string
38
- attribute :document_number, :string
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
- ## Defining Forms
47
+ ## Declaring Fields
48
48
 
49
- Forms use ActiveModel under the hood. Attributes are declared with `attribute` (same as ActiveModel::Attributes).
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
- class ProfileForm < Dex::Form
53
- attribute :name, :string
54
- attribute :age, :integer
55
- attribute :bio, :string
56
- attribute :active, :boolean, default: true
57
- attribute :born_on, :date
58
- end
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, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
100
- validates :name, presence: true, length: { minimum: 2, maximum: 100 }
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
- attribute :street, :string
133
- attribute :city, :string
134
- validates :street, :city, presence: true
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
- attribute :document_type, :string
156
- attribute :document_number, :string
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
- attribute :street, :string
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 attribute declarations are the whitelist. Just `require` the top-level key:
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
- attribute :first_name, :string
409
- attribute :last_name, :string
410
- attribute :email, :string
411
- attribute :department, :string
412
- attribute :position, :string
413
- attribute :start_date, :date
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, presence: true, uniqueness: { model: User }
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
- attribute :street, :string
423
- attribute :city, :string
424
- attribute :postal_code, :string
425
- attribute :country, :string
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
- attribute :document_type, :string
432
- attribute :document_number, :string
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 test_validates_required_fields
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
  ```
@@ -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 by default. All changes roll back on error. Nested operations share the outer transaction.
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 :mongoid # adapter override (default: auto-detect AR → Mongoid)
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). Missing columns silently skipped.
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 → guard → callback. The once check runs before locking and recording, so duplicate calls short-circuit early.
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 table must have `once_key` and `once_key_expires_at` columns (see Recording schema above)
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")