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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +63 -254
  4. data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
  5. data/guides/llm/EVENT.md +25 -26
  6. data/guides/llm/FORM.md +200 -59
  7. data/guides/llm/OPERATION.md +115 -57
  8. data/guides/llm/QUERY.md +56 -0
  9. data/guides/llm/TOOL.md +308 -0
  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 +79 -11
  13. data/lib/dex/event/handler.rb +18 -1
  14. data/lib/dex/event/metadata.rb +15 -20
  15. data/lib/dex/event/processor.rb +2 -16
  16. data/lib/dex/event/test_helpers.rb +1 -1
  17. data/lib/dex/event.rb +3 -10
  18. data/lib/dex/form/context.rb +27 -0
  19. data/lib/dex/form/export.rb +128 -0
  20. data/lib/dex/form/nesting.rb +2 -0
  21. data/lib/dex/form.rb +119 -3
  22. data/lib/dex/id.rb +125 -0
  23. data/lib/dex/operation/async_proxy.rb +22 -4
  24. data/lib/dex/operation/guard_wrapper.rb +1 -1
  25. data/lib/dex/operation/jobs.rb +5 -4
  26. data/lib/dex/operation/once_wrapper.rb +1 -0
  27. data/lib/dex/operation/outcome.rb +14 -0
  28. data/lib/dex/operation/record_backend.rb +2 -1
  29. data/lib/dex/operation/record_wrapper.rb +14 -4
  30. data/lib/dex/operation/result_wrapper.rb +0 -12
  31. data/lib/dex/operation/test_helpers/assertions.rb +0 -88
  32. data/lib/dex/operation/test_helpers.rb +11 -1
  33. data/lib/dex/operation/ticket.rb +268 -0
  34. data/lib/dex/operation/trace_wrapper.rb +20 -0
  35. data/lib/dex/operation.rb +3 -0
  36. data/lib/dex/operation_failed.rb +14 -0
  37. data/lib/dex/query/export.rb +64 -0
  38. data/lib/dex/query.rb +41 -0
  39. data/lib/dex/test_log.rb +62 -4
  40. data/lib/dex/timeout.rb +14 -0
  41. data/lib/dex/tool.rb +388 -5
  42. data/lib/dex/trace.rb +291 -0
  43. data/lib/dex/version.rb +1 -1
  44. data/lib/dexkit.rb +22 -3
  45. metadata +12 -3
  46. data/lib/dex/event/trace.rb +0 -56
  47. data/lib/dex/event_test_helpers.rb +0 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41c8e4455fb4a4cca73b1d12da366b53bdbbada2cbae773917a12d344a150dd4
4
- data.tar.gz: 86c4e76004df8b968c094b7b252a988eafe2f7aee035505ddb7bbeae1f25e04a
3
+ metadata.gz: 0201163b689acfc7c65709eb96174e81dacd9f090f573d4cc2ba2f15c6362c2b
4
+ data.tar.gz: cedf548f898ccc821262adec828613848572bfd02dbd0667001a1f9f1669c5bb
5
5
  SHA512:
6
- metadata.gz: 89a9f6075f3955300dbe3944a9adc9b80ab56c3b4dc60d9458ea370f68c5c83ab5b3f2daad26c6f852d6f423a498ccf068914853e9866c05442765539ac6ac91
7
- data.tar.gz: cd541ee35700cf9aa877b3630d66f0b9580263d0fddc7f381200afe32c638f832d941112387f646fa59500c26bb552461ad5bf0f0ec79dcf7bb97d76ebcbfb86
6
+ metadata.gz: 318363e15f2b2ebb3846125d5785562f114f3941e29a577e721b72ff6101c6d30171771c7998dac253e7e91f31a9727d5e318f62df172c78233a067a45d6413b
7
+ data.tar.gz: a982874d3377ebec37bfe9a9528b505150a31b83298594827cbb7d3d0e9d9679635a48b6068a02c4d0ed6e1ba53764471f1534d5f28209b8e2fadfc062a4f64d
data/CHANGELOG.md CHANGED
@@ -1,12 +1,68 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Breaking
4
+
5
+ - **`Dex::Event::Trace` removed** – the shim module was a pure pass-through to `Dex::Trace`. All callers should use `Dex::Trace` directly: `Dex::Trace.with_event_context(event)`, `Dex::Trace.current_event_id`, `Dex::Trace.trace_id`, `Dex::Trace.dump`, `Dex::Trace.restore(data)`, `Dex::Trace.clear!`
6
+ - **`event_context` / `restore_event_context` configuration removed** – the legacy dual context system for capturing arbitrary metadata at publish time and restoring ambient state in async handlers has been removed. Before: `config.event_context = -> { { user_id: Current.user&.id } }` captured an untyped hash into event metadata, and `config.restore_event_context` restored it before async handler execution. After: use `Dex.with_context` with typed event props via the `context` DSL instead — context values are captured as regular props at publish time, serialized automatically, and available on the event in handlers without ambient state restoration. `Metadata.context` field has been removed from event metadata and serialization
7
+ - **`event.context` instance method removed** – the metadata delegate that returned the `event_context` config output is gone. Context data should be declared as typed props with the `context` DSL
8
+
9
+ ### Removed
10
+
11
+ - **`assert!` removed from operations** — `assert!(:code) { value }` and `assert!(value, :code)` are no longer available. Use `error!(:code) unless value` instead – it's equally concise and doesn't require learning a separate method
12
+ - **`assert_all_succeed` / `assert_all_fail` removed from test helpers** — use a simple loop with `assert_ok` / `assert_err` instead
13
+ - **`dex/event_test_helpers` shim removed** — use `require "dex/event/test_helpers"` directly
14
+ - **`assert_operation` / `assert_operation_error` removed from test helpers** — use `call_operation` + `assert_ok` / `assert_err` instead, which are more composable
15
+ - **`assert_trace_includes` / `assert_trace_actor` / `assert_trace_depth` removed from test helpers** — use `Dex::Trace.current`, `Dex::Trace.actor` directly with standard Minitest assertions
16
+
17
+ ### Added
18
+
19
+ - **`Dex::Id.parse`** – parse a Stripe-style ID back into prefix, timestamp, and random components. Returns `Dex::Id::Parsed` (a `Data.define` value object)
20
+ - **`Dex::Id.generate` now validates prefix format** – prefix must match `/\A[a-z][a-z0-9_]*_\z/` (lowercase alphanumeric with internal underscores, ending in underscore). Internal prefixes (`op_`, `ev_`, `tr_`, `hd_`) already comply
21
+ - **`Dex::Id.generate` accepts `random:` width option** – controls the number of random suffix characters (default 12, minimum 8). `Dex::Id.generate("ord_", random: 16)` produces a longer ID with more collision resistance
22
+ - **`Dex::Tool.from` accepts Query classes** – `Dex::Tool.from(Order::Query, scope: -> { ... }, serialize: -> { ... })` turns a `Dex::Query` into an LLM-callable tool with mandatory scope injection, serialization, and result limiting. Supports `limit:`, `only_filters:`, `except_filters:`, and `only_sorts:` for fine-grained control. Context-mapped and `_Ref` props are auto-excluded from the tool schema. Returns paginated results with `{ records:, total:, limit:, offset: }`. All options are validated at registration time with prescriptive error messages. Excluded filters are enforced at runtime — the agent cannot bypass filter restrictions even by sending unlisted params
23
+ - **`Dex::Operation::Ticket`** – `async.call` now returns a structured `Ticket` instead of a raw ActiveJob instance. The ticket exposes `record` (the operation record, if recording is enabled) and `job` (the enqueued job). Delegated accessors: `id`, `operation_name`, `status`, `error_code`, `error_message`, `error_details`. Predicate methods: `completed?`, `error?`, `failed?`, `pending?`, `running?`, `terminal?`, `recorded?`. `reload` refreshes from the database. `to_param` returns the ID for Rails path helpers. `as_json` provides a ready-made JSON representation for polling endpoints. `Ticket.from_record(record)` constructs a ticket from any operation record (async or sync) for status pages and admin dashboards
24
+ - **Outcome reconstruction** – `ticket.outcome` reconstructs `Ok` or `Err` from a terminal record's business outcome. `completed` records produce `Ok(result)` with deep-symbolized keys for pattern matching, `error` records produce `Err(Dex::Error)` with symbolized code and details, and non-terminal or `failed` records return `nil`. Result hashes wrapped in `_dex_value` are transparently unwrapped
25
+ - **`ticket.wait(timeout, interval:)` and `ticket.wait!(timeout, interval:)`** – speculative sync for async operations. `wait` polls the record until a business outcome is available or the timeout expires, returning `Ok`/`Err` or `nil` on timeout. `wait!` unwraps `Ok` and re-raises `Err`, raising `Dex::Timeout` on timeout. Both raise `Dex::OperationFailed` if the operation crashed (infrastructure failure). Interval accepts a fixed number or a callable for backoff strategies. Timeouts above 10 seconds emit a warning
26
+ - **`Dex::OperationFailed`** – new exception class (inherits `StandardError`, not `Dex::Error`) raised by `wait`/`wait!` when an async operation crashed with an infrastructure failure (status `"failed"`). Exposes `operation_name`, `exception_class`, and `exception_message`
27
+ - **`Dex::Timeout`** – new exception class (inherits `StandardError`, not `Dex::Error`) raised by `wait!` when the timeout expires without the operation reaching a terminal state. Exposes `timeout`, `ticket_id`, and `operation_name`
28
+ - **`Dex.actor`** – convenience reader for the current trace actor. Returns the actor hash in the same shape you passed to `Dex::Trace.start` (e.g. `{ type: "user", id: "42" }`), or `nil` when no actor is set. Symmetric with `Dex.context`
29
+ - **`Dex.system(name = nil)`** – convenience helper for building system actor hashes. `Dex.system` returns `{ type: :system }`, `Dex.system("payroll")` returns `{ type: :system, name: "payroll" }`. Use with `Dex::Trace.start(actor: Dex.system("nightly_cleanup"))`
30
+ - **`Ok#deconstruct` and `Err#deconstruct`** – array deconstruct support for pattern matching. `in Dex::Ok[value]` binds the raw value; `in Dex::Err[error]` binds the `Dex::Error` instance. Complements the existing hash deconstruct (`in Dex::Ok(key:)`, `in Dex::Err(code:)`)
31
+
32
+ ### Changed
33
+
34
+ - **`async.call` returns `Ticket` instead of ActiveJob instance** – callers that previously captured the return value of `async.call` to access the raw job must use `ticket.job` instead. The job is still accessible via the ticket. Code that ignores the return value (`op.async.call` without assignment) is unaffected
35
+ - **`safe` and `async` are non-composable** – `op.safe.async` raises `NoMethodError` with a prescriptive message guiding toward `wait`/`wait!`. `op.async.safe` raises the same. Previously both raised generic `NoMethodError`
36
+
37
+ ## [0.10.0] - 2026-03-09
38
+
39
+ ### Added
40
+
41
+ - **`field` / `field?` DSL for forms** – `field :name, :string` declares a required field with auto-presence validation; unconditional explicit presence validators deduplicate with it, while scoped/conditional validators still layer on top. `field? :notes, :string` declares an optional field (nil by default). Both support `desc:` for metadata and `default:` for defaults. Raw `attribute` remains available as an escape hatch
42
+ - **Form registry and description** – `Dex::Form` now extends `Registry`, giving forms `description`, `Dex::Form.registry`, and `deregister` – the same ecosystem as Operation, Event, and Handler
43
+ - **Form ambient context** – forms support the same `context` DSL as Operation and Event. `context :locale` auto-fills from `Dex.context` during initialization. Uses the same shared `ContextDSL` module with Form-specific injection
44
+ - **Form export** – `Form.to_h` (class-level schema), `Form.to_json_schema`, and `Dex::Form.export(format:)` for bulk export. Nested forms are recursively included in both formats, and bulk export returns top-level named forms without listing nested helper classes separately
45
+ - **Query registry, description, context, and export** – `Dex::Query` now extends `Registry` (giving `description`, `Dex::Query.registry`, `deregister`), includes `ContextSetup` (enabling `context :tenant` to auto-fill from `Dex.with_context`), and adds `Query.to_h`, `Query.to_json_schema`, `Dex::Query.export(format:)`. Query is now a full citizen alongside Operation, Event, and Form
46
+
47
+ ### Changed
48
+
49
+ - **Shared context DSL** – extracted `Dex::ContextDSL` as a shared module used by both `ContextSetup` (Operation/Event) and `Form::Context`. No behavior change for Operation or Event
50
+
3
51
  ## [0.9.0] - 2026-03-09
4
52
 
5
53
  ### Breaking
6
54
 
55
+ - **Unified execution tracing replaces event-only tracing** – `Dex::Trace` is a new fiber-local trace that spans operations, events, and handlers. Operations get `op_...` execution IDs, events get `ev_...` IDs (replacing UUIDs), handlers get `hd_...` IDs, and traces are correlated with `tr_...` IDs. `event.trace { }` is removed – use `caused_by:` for explicit event causality and `Dex::Trace.start(actor:)` at request/job boundaries
56
+ - **Operation record primary keys are now string IDs** – records use the operation's `op_...` execution ID as a string primary key instead of auto-increment integers. The recording schema adds `trace_id`, `actor_type`, `actor_id`, and `trace` columns. Existing tables need a migration to adopt the new schema
7
57
  - **Mongoid transaction support removed** — `transaction :mongoid` and `config.transaction_adapter = :mongoid` are no longer valid. Dex no longer ships a Mongoid transaction adapter. Before: Mongoid transactions could be enabled via configuration or per-operation DSL. After: both forms raise `ArgumentError` immediately at declaration/configuration time. Mongoid-only apps continue to work — transactions are automatically disabled (no adapter detected), and `after_commit` fires immediately after success. If you need Mongoid multi-document transactions, call `Mongoid.transaction` directly inside `perform`
8
58
  - **Recording backends now validate required attributes before use** — Dex no longer silently drops missing `params`, `result`, `status`, or `once` attributes from `record_class`. Before: partial ActiveRecord/Mongoid recording models could appear to work while losing status transitions, replay data, or async params. After: Dex raises `ArgumentError` naming the missing attributes required by core recording, async record jobs, or `once`. Apps using minimal recording models must add the required columns/fields or explicitly disable the features that need them
9
59
 
60
+ ### Added
61
+
62
+ - **`Dex::Trace` API** – `start(actor:, trace_id:)`, `.trace_id`, `.current`, `.current_id`, `.actor`, `.to_s`, `.dump`, `.restore`. Fiber-local, auto-starts when no trace is active, serializes across async job boundaries
63
+ - **Trace persistence** – operation records and event stores persist `id`, `trace_id`, `actor_type`, `actor_id`, and `trace` when the columns exist. Event metadata includes `event_ancestry` for materialized-path tree queries
64
+ - **`Dex::Id`** – Stripe-style prefixed ID generation with embedded timestamps for sortability
65
+
10
66
  ### Fixed
11
67
 
12
68
  - **Mongoid-only Rails compatibility** — Dex boots and runs cleanly in Mongoid-only Rails apps without `activerecord` loaded, with prescriptive `LoadError`s for unsupported paths such as `advisory_lock` and async event dispatch without `ActiveJob`
@@ -165,7 +221,7 @@
165
221
  - Causality tracing: `event.trace { ... }` and `caused_by:` link events into chains with shared `trace_id`
166
222
  - Block-scoped suppression: `Dex::Event.suppress(SomeEvent) { ... }`
167
223
  - Optional persistence via `event_store` configuration
168
- - Context capture and restoration across async boundaries (`event_context`, `restore_event_context`)
224
+ - Context capture across async boundaries via `Dex.with_context` and the `context` DSL
169
225
  - **Event test helpers** — `Dex::Event::TestHelpers` module
170
226
  - `capture_events` block for inspecting published events without dispatching
171
227
  - `assert_event_published`, `refute_event_published`, `assert_event_count`
data/README.md CHANGED
@@ -1,16 +1,19 @@
1
1
  # dexkit
2
2
 
3
- Rails patterns toolbelt. Equip to gain +4 DEX.
3
+ Typed patterns for Rails, crafted for DX. Equip to gain +4 DEX.
4
4
 
5
- > **Active development.** dexkit is pre-1.0 and evolving rapidly. The public API may change between minor versions as the library matures.
5
+ **[Documentation](https://dex.razorjack.net)** · **[Design Philosophy](https://dex.razorjack.net/guide/philosophy)** · **[DX Meets AI](https://dex.razorjack.net/guide/ai)**
6
6
 
7
- **[Documentation](https://dex.razorjack.net)**
7
+ > **Pre-1.0.** Active development. The public API may change between minor versions.
8
8
 
9
- ## Operations
9
+ Four base classes with contracts that enforce themselves:
10
10
 
11
- Service objects with typed properties, transactions, error handling, and more.
11
+ - **[Dex::Operation](https://dex.razorjack.net/operation/)** – typed service objects with structured errors, transactions, and async execution
12
+ - **[Dex::Event](https://dex.razorjack.net/event/)** – immutable domain events with pub/sub, async handlers, and causality tracing
13
+ - **[Dex::Query](https://dex.razorjack.net/query/)** – declarative filters and sorts for ActiveRecord and Mongoid scopes
14
+ - **[Dex::Form](https://dex.razorjack.net/form/)** – form objects with typed fields, nested forms, and Rails form builder compatibility
12
15
 
13
- Mongoid-only Rails apps work too – queries, recording, events, and forms all adapt automatically. Transactions are ActiveRecord-only (Mongoid users who need transactions can call `Mongoid.transaction` inside `perform`); `advisory_lock` is also ActiveRecord-only. Operation/event store models can be Mongoid documents; recording models must define the fields required by the enabled recording features.
16
+ ## Operations
14
17
 
15
18
  ```ruby
16
19
  class Order::Place < Dex::Operation
@@ -34,29 +37,20 @@ class Order::Place < Dex::Operation
34
37
  end
35
38
 
36
39
  order = Order::Place.call(customer: 42, product: 7, quantity: 2)
37
- order.id # => 1
38
40
  ```
39
41
 
40
- ### What you get out of the box
41
-
42
- **Typed properties** – powered by [literal](https://github.com/joeldrapper/literal). Plain classes, ranges, unions, arrays, nilable, and model references with auto-find:
42
+ Here's what you got:
43
43
 
44
- ```ruby
45
- prop :quantity, _Integer(1..)
46
- prop :currency, _Union("USD", "EUR", "GBP")
47
- prop :customer, _Ref(Customer) # accepts Customer instance or ID
48
- prop? :note, String # optional (nil by default)
49
- ```
44
+ - **`_Ref(Customer)`** accepts a Customer instance or an ID – the record is auto-fetched
45
+ - **`_Integer(1..)`** guarantees a positive integer before `perform` runs
46
+ - **`prop?`** marks optional inputs (nil by default)
47
+ - **`success` / `error`** declare the contract – typos in error codes raise `ArgumentError`
48
+ - **`error!`** halts execution, rolls back the transaction, returns a structured error
49
+ - **`after_commit`** fires only after the transaction succeeds – safe for emails, webhooks, events
50
50
 
51
- **Structured errors** with `error!`, `assert!`, and `rescue_from`:
52
-
53
- ```ruby
54
- product = assert!(:not_found) { Product.find_by(id: product_id) }
55
-
56
- rescue_from Stripe::CardError, as: :payment_declined
57
- ```
51
+ ### Pattern matching
58
52
 
59
- **Ok / Err** pattern match on operation outcomes with `.safe.call`:
53
+ `.safe.call` returns `Ok` or `Err` instead of raising:
60
54
 
61
55
  ```ruby
62
56
  case Order::Place.new(customer: 42, product: 7, quantity: 2).safe.call
@@ -64,132 +58,50 @@ in Ok => result
64
58
  redirect_to order_path(result.id)
65
59
  in Err(code: :out_of_stock)
66
60
  flash[:error] = "Product is out of stock"
61
+ render :new
67
62
  end
68
63
  ```
69
64
 
70
- **Async execution** via ActiveJob:
65
+ ### Guards
71
66
 
72
- ```ruby
73
- Order::Fulfill.new(order_id: 123).async(queue: "fulfillment").call
74
- ```
75
-
76
- **Idempotency** with `once` — run an operation at most once for a given key. Results are replayed on duplicates:
67
+ Inline precondition checks with introspection — ask "can this run?" from views and controllers:
77
68
 
78
69
  ```ruby
79
- class Payment::Charge < Dex::Operation
80
- prop :order_id, Integer
81
- prop :amount, Integer
82
-
83
- once :order_id # key from prop
84
- # once :order_id, :merchant_id # composite key
85
- # once # all props as key
86
- # once { "custom-#{order_id}" } # block-based key
87
- # once :order_id, expires_in: 24.hours # expiring key
88
-
89
- def perform
90
- Gateway.charge!(order_id, amount)
91
- end
70
+ guard :active_customer, "Customer account must be active" do
71
+ !customer.suspended?
92
72
  end
93
73
 
94
- # Call-site key (overrides class-level declaration)
95
- Payment::Charge.new(order_id: 1, amount: 500).once("ext-key-123").call
96
-
97
- # Bypass once guard for a single call
98
- Payment::Charge.new(order_id: 1, amount: 500).once(nil).call
99
-
100
- # Clear a stored key to allow re-execution
101
- Payment::Charge.clear_once!(order_id: 1)
102
- ```
103
-
104
- Business errors are replayed; exceptions release the key so the operation can be retried. Requires the record backend (recording is enabled by default when `record_class` is configured).
105
-
106
- **Guards** – inline precondition checks with introspection. Ask "can this operation run?" from views and controllers:
107
-
108
- ```ruby
109
- guard :out_of_stock, "Product must be in stock" do
110
- !product.in_stock?
111
- end
112
-
113
- # In a view or controller:
114
74
  Order::Place.callable?(customer: customer, product: product, quantity: 1)
75
+ # => true / false
115
76
  ```
116
77
 
117
- **Ambient context** – declare which props come from ambient state. Set once in a controller, auto-fill everywhere:
118
-
119
- ```ruby
120
- class Order::Place < Dex::Operation
121
- prop :product, _Ref(Product)
122
- prop :customer, _Ref(Customer)
123
- context customer: :current_customer # filled from Dex.context[:current_customer]
124
-
125
- def perform
126
- Order.create!(product: product, customer: customer)
127
- end
128
- end
129
-
130
- # Controller
131
- Dex.with_context(current_customer: current_customer) do
132
- Order::Place.call(product: product) # customer auto-filled
133
- end
134
-
135
- # Tests – just pass it explicitly
136
- Order::Place.call(product: product, customer: customer)
137
- ```
138
-
139
- **Explain** – full preflight check in one call. Context, guards, idempotency, locks, settings – everything the operation would do, without doing it:
78
+ ### Prescriptive errors
140
79
 
141
- ```ruby
142
- info = Order::Place.explain(product: product, customer: customer, quantity: 2)
143
- info[:callable] # => true (all guards pass)
144
- info[:once][:status] # => :fresh (would execute, not replay)
145
- info[:context][:source] # => { customer: :ambient }
146
- ```
147
-
148
- **Registry & Export** — list all operations, export contracts as JSON or JSON Schema, and bridge to LLM function-calling via [ruby-llm](https://rubyllm.com/):
80
+ Every mistake tells you what went wrong, why, and what to do instead:
149
81
 
150
82
  ```ruby
151
- # List all operations
152
- Dex::Operation.registry # => #<Set: {Order::Place, Order::Cancel, ...}>
83
+ error!(:not_found)
84
+ # => ArgumentError: Order::Place declares unknown error code :not_found.
85
+ # Declared codes: [:out_of_stock]
153
86
 
154
- # Export contracts
155
- Dex::Operation.export(format: :json_schema)
87
+ prop :email, 123
88
+ # => Literal::TypeError: expected a type, got 123 (Integer)
156
89
 
157
- # LLM tools (requires ruby-llm gem)
158
- chat = RubyLLM.chat
159
- chat.with_tools(*Dex::Tool.all)
160
- chat.ask("Place an order for 2 units of product #42")
90
+ once :nonexistent_prop
91
+ # => ArgumentError: Order::Place.once references unknown prop :nonexistent_prop.
92
+ # Declared props: [:customer, :product, :quantity, :note]
161
93
  ```
162
94
 
163
- **Transactions** on by default, **advisory locking**, **recording** to database, **callbacks**, and a customizable **pipeline** – all composable, all optional.
164
-
165
- ### Testing
95
+ ### And more
166
96
 
167
- First-class test helpers for Minitest:
168
-
169
- ```ruby
170
- class PlaceOrderTest < Minitest::Test
171
- testing Order::Place
172
-
173
- def test_places_order
174
- assert_operation(customer: customer.id, product: product.id, quantity: 2)
175
- end
176
-
177
- def test_rejects_out_of_stock
178
- assert_operation_error(:out_of_stock, customer: customer.id,
179
- product: out_of_stock_product.id, quantity: 1)
180
- end
181
- end
182
- ```
97
+ [Ambient context](https://dex.razorjack.net/operation/context), [unified tracing](https://dex.razorjack.net/operation/tracing) with [Stripe-style IDs](https://dex.razorjack.net/utilities/prefixed-ids), [idempotency](https://dex.razorjack.net/operation/once), [async execution](https://dex.razorjack.net/operation/async), [advisory locks](https://dex.razorjack.net/operation/advisory-lock), [DB recording](https://dex.razorjack.net/operation/recording), [explain](https://dex.razorjack.net/operation/explain) for preflight checks, [callbacks](https://dex.razorjack.net/operation/callbacks), a [customizable pipeline](https://dex.razorjack.net/operation/pipeline), [registry & export](https://dex.razorjack.net/tooling/registry), and [LLM tool integration](https://dex.razorjack.net/tool/) for operations and queries.
183
98
 
184
99
  ## Events
185
100
 
186
- Typed, immutable event objects with publish/subscribe, async dispatch, and causality tracing.
187
-
188
101
  ```ruby
189
102
  class Order::Placed < Dex::Event
190
103
  prop :order_id, Integer
191
104
  prop :total, BigDecimal
192
- prop? :coupon_code, String
193
105
  end
194
106
 
195
107
  class NotifyWarehouse < Dex::Event::Handler
@@ -197,175 +109,72 @@ class NotifyWarehouse < Dex::Event::Handler
197
109
  retries 3
198
110
 
199
111
  def perform
200
- WarehouseApi.notify(event.order_id)
112
+ WarehouseApi.reserve(event.order_id)
201
113
  end
202
114
  end
203
115
 
204
116
  Order::Placed.publish(order_id: 1, total: 99.99)
205
117
  ```
206
118
 
207
- ### What you get out of the box
208
-
209
- **Zero-config pub/sub** — define events and handlers, publish. No bus setup needed.
210
-
211
- **Async by default** — handlers dispatched via ActiveJob. `sync: true` for inline. If ActiveJob is not loaded, async publish raises `LoadError`.
212
-
213
- **Causality tracing** — link events in chains with shared `trace_id`:
214
-
215
- ```ruby
216
- order_placed.trace do
217
- Shipment::Reserved.publish(order_id: 1)
218
- end
219
- ```
220
-
221
- **Callbacks** — `before`, `after`, `around` hooks on handlers, same DSL as operations.
222
-
223
- **Transactions** — opt-in `transaction` and `after_commit` for handlers that write to the database.
224
-
225
- **Suppression**, optional **persistence**, **context capture**, and **retries** with exponential backoff.
226
-
227
- ### Testing
228
-
229
- ```ruby
230
- class PlaceOrderTest < Minitest::Test
231
- include Dex::Event::TestHelpers
232
-
233
- def test_publishes_order_placed
234
- capture_events do
235
- Order::Place.call(customer: customer.id, product: product.id, quantity: 2)
236
- assert_event_published(Order::Placed)
237
- end
238
- end
239
- end
240
- ```
241
-
242
- ## Forms
243
-
244
- Form objects with typed attributes, normalization, nested forms, and Rails form builder compatibility.
245
-
246
- ```ruby
247
- class Employee::Form < Dex::Form
248
- model Employee
249
-
250
- attribute :first_name, :string
251
- attribute :last_name, :string
252
- attribute :email, :string
253
-
254
- normalizes :email, with: -> { _1&.strip&.downcase.presence }
255
-
256
- validates :email, presence: true, uniqueness: true
257
- validates :first_name, :last_name, presence: true
258
-
259
- nested_one :address do
260
- attribute :street, :string
261
- attribute :city, :string
262
- validates :street, :city, presence: true
263
- end
264
- end
265
-
266
- form = Employee::Form.new(email: " ALICE@EXAMPLE.COM ", first_name: "Alice", last_name: "Smith")
267
- form.email # => "alice@example.com"
268
- form.valid?
269
- ```
270
-
271
- ### What you get out of the box
272
-
273
- **ActiveModel attributes** with type casting, normalization, and full Rails validation DSL.
274
-
275
- **Nested forms** — `nested_one` and `nested_many` with automatic Hash coercion, `_destroy` support, and error propagation:
276
-
277
- ```ruby
278
- nested_many :emergency_contacts do
279
- attribute :name, :string
280
- attribute :phone, :string
281
- validates :name, :phone, presence: true
282
- end
283
- ```
284
-
285
- **Rails form compatibility** — works with `form_with`, `fields_for`, and nested attributes out of the box.
286
-
287
- **Uniqueness validation** against the database, with scope, case-sensitivity, and current-record exclusion.
288
-
289
- **Multi-model forms** — when a form spans Employee, Department, and Address, define a `.for` convention method to map records and a `#save` method that delegates to a `Dex::Operation`:
290
-
291
- ```ruby
292
- def save
293
- return false unless valid?
294
-
295
- case operation.safe.call
296
- in Ok then true
297
- in Err => e then errors.add(:base, e.message) and false
298
- end
299
- end
300
- ```
301
-
302
119
  ## Queries
303
120
 
304
- Declarative query objects for filtering and sorting ActiveRecord and Mongoid scopes.
305
-
306
121
  ```ruby
307
122
  class Order::Query < Dex::Query
308
123
  scope { Order.all }
309
124
 
310
125
  prop? :status, String
311
- prop? :customer, _Ref(Customer)
312
126
  prop? :total_min, Integer
313
127
 
314
128
  filter :status
315
- filter :customer
316
129
  filter :total_min, :gte, column: :total
317
130
 
318
131
  sort :created_at, :total, default: "-created_at"
319
132
  end
320
133
 
321
- orders = Order::Query.call(status: "pending", sort: "-total")
134
+ Order::Query.call(status: "pending", sort: "-total")
322
135
  ```
323
136
 
324
- ### What you get out of the box
137
+ ## Forms
325
138
 
326
- **11 built-in filter strategies** — `:eq`, `:not_eq`, `:contains`, `:starts_with`, `:ends_with`, `:gt`, `:gte`, `:lt`, `:lte`, `:in`, `:not_in`. Custom blocks for complex logic.
139
+ ```ruby
140
+ class Order::Form < Dex::Form
141
+ field :customer_email, :string
142
+ field? :note, :string
327
143
 
328
- **Sorting** with ascending/descending column sorts, custom sort blocks, and defaults.
144
+ nested_many :line_items do
145
+ field :product_id, :integer
146
+ field :quantity, :integer, default: 1
147
+ end
148
+ end
149
+ ```
329
150
 
330
- **`from_params`** — HTTP boundary handling with automatic coercion, blank stripping, and invalid value fallback:
151
+ ## Testing
331
152
 
332
153
  ```ruby
333
- class OrdersController < ApplicationController
334
- def index
335
- query = Order::Query.from_params(params, scope: policy_scope(Order))
336
- @orders = pagy(query.resolve)
154
+ class PlaceOrderTest < Minitest::Test
155
+ testing Order::Place
156
+
157
+ def test_places_order
158
+ result = call_operation(customer: customer.id, product: product.id, quantity: 2)
159
+ assert_ok result
160
+ end
161
+
162
+ def test_rejects_out_of_stock
163
+ result = call_operation(customer: customer.id,
164
+ product: out_of_stock_product.id, quantity: 1)
165
+ assert_err result, :out_of_stock
337
166
  end
338
167
  end
339
168
  ```
340
169
 
341
- **Form binding** — works with `form_with` for search forms. Queries respond to `model_name`, `param_key`, `persisted?`, and `to_params`.
342
-
343
- **Scope injection** — narrow the base scope at call time without modifying the query class.
344
-
345
170
  ## Installation
346
171
 
347
172
  ```ruby
348
173
  gem "dexkit"
349
174
  ```
350
175
 
351
- ## Documentation
352
-
353
- Full documentation at **[dex.razorjack.net](https://dex.razorjack.net)**.
354
-
355
- ## AI Coding Assistant Setup
356
-
357
- dexkit ships LLM-optimized guides. Install them as `AGENTS.md` files in your app directories so AI coding agents automatically know the API:
358
-
359
- ```bash
360
- rake dex:guides
361
- ```
362
-
363
- This copies guides into directories that exist (`app/operations/`, `app/events/`, `app/event_handlers/`, `app/forms/`, `app/queries/`), stamped with the installed dexkit version. Re-run after upgrading dexkit to sync. Existing hand-written `AGENTS.md` files are never overwritten (use `FORCE=1` to override).
364
-
365
- Override paths for non-standard directory names:
366
-
367
176
  ```bash
368
- rake dex:guides OPERATIONS_PATH=app/services
177
+ rake dex:guides # install LLM-optimized guides as AGENTS.md in your app directories
369
178
  ```
370
179
 
371
180
  ## License
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- dexkit (0.8.0)
4
+ dexkit (0.11.0)
5
5
  activemodel (>= 6.1)
6
6
  literal (~> 1.9)
7
7
  zeitwerk (~> 2.6)
@@ -180,7 +180,7 @@ CHECKSUMS
180
180
  connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
181
181
  crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
182
182
  date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
183
- dexkit (0.8.0)
183
+ dexkit (0.11.0)
184
184
  drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
185
185
  erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
186
186
  erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
data/guides/llm/EVENT.md CHANGED
@@ -30,9 +30,9 @@ class UserCreated < Dex::Event
30
30
  end
31
31
  ```
32
32
 
33
- Reserved names: `id`, `timestamp`, `trace_id`, `caused_by_id`, `caused_by`, `context`, `publish`, `metadata`, `sync`.
33
+ Reserved names: `id`, `timestamp`, `trace_id`, `caused_by_id`, `caused_by`, `event_ancestry`, `context`, `publish`, `metadata`, `sync`.
34
34
 
35
- Events are frozen after creation. Each gets auto-generated `id` (UUID), `timestamp` (UTC), `trace_id`, and optional `caused_by_id`.
35
+ Events are frozen after creation. Each gets auto-generated `id` (`ev_...`), `timestamp` (UTC), `trace_id` (`tr_...` by default), optional `caused_by_id`, and `event_ancestry` (ordered ancestor event IDs).
36
36
 
37
37
  ### Literal Types Cheatsheet
38
38
 
@@ -72,10 +72,11 @@ class NotifyWarehouse < Dex::Event::Handler
72
72
  def perform
73
73
  event # accessor — the event instance
74
74
  event.order_id # typed props
75
- event.id # UUID
75
+ event.id # prefixed event ID (ev_...)
76
76
  event.timestamp # Time (UTC)
77
77
  event.caused_by_id # parent event ID (if traced)
78
- event.trace_id # shared trace ID across causal chain
78
+ event.trace_id # shared trace / correlation ID
79
+ event.event_ancestry # ordered ancestor event IDs
79
80
  end
80
81
  end
81
82
  ```
@@ -179,22 +180,20 @@ Default handler pipeline: `[:transaction, :callback]`.
179
180
 
180
181
  ## Tracing (Causality)
181
182
 
182
- Link events in a causal chain. All events in a chain share the same `trace_id`.
183
+ Events participate in the unified `Dex::Trace` used by operations and handlers. All events in a trace share the same `trace_id`.
183
184
 
184
185
  ```ruby
185
- order_placed = OrderPlaced.new(order_id: 1, total: 99.99)
186
-
187
- # Option 1: trace block
188
- order_placed.trace do
189
- InventoryReserved.publish(order_id: 1) # caused_by_id = order_placed.id
190
- ShippingRequested.publish(order_id: 1) # same trace_id
186
+ # Start a trace at the request/job boundary
187
+ Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
188
+ OrderPlaced.publish(order_id: 1, total: 99.99)
191
189
  end
192
190
 
193
- # Option 2: caused_by keyword
191
+ # Explicit event-to-event causality
192
+ order_placed = OrderPlaced.new(order_id: 1, total: 99.99)
194
193
  InventoryReserved.publish(order_id: 1, caused_by: order_placed)
195
194
  ```
196
195
 
197
- Nesting works each child gets the nearest parent's `id` as `caused_by_id`, and the root's `trace_id`.
196
+ When an event is published inside a handler, the handler's event becomes the cause automatically. Explicit `caused_by:` sets `caused_by_id` and appends to `event_ancestry`.
198
197
 
199
198
  ---
200
199
 
@@ -218,12 +217,16 @@ Store events to database when configured:
218
217
 
219
218
  ```ruby
220
219
  Dex.configure do |c|
221
- c.event_store = EventRecord # any model with create!(event_type:, payload:, metadata:)
220
+ c.event_store = EventRecord # Dex passes trace fields when the model supports them
222
221
  end
223
222
  ```
224
223
 
225
224
  ```ruby
226
- create_table :event_records do |t|
225
+ create_table :event_records, id: :string do |t|
226
+ t.string :trace_id
227
+ t.string :actor_type
228
+ t.string :actor_id
229
+ t.jsonb :trace
227
230
  t.string :event_type
228
231
  t.jsonb :payload
229
232
  t.jsonb :metadata
@@ -238,6 +241,11 @@ class EventRecord
238
241
  include Mongoid::Document
239
242
  include Mongoid::Timestamps
240
243
 
244
+ field :_id, type: String
245
+ field :trace_id, type: String
246
+ field :actor_type, type: String
247
+ field :actor_id, type: String
248
+ field :trace, type: Array
241
249
  field :event_type, type: String
242
250
  field :payload, type: Hash
243
251
  field :metadata, type: Hash
@@ -282,10 +290,6 @@ end
282
290
 
283
291
  **Introspection:** `MyEvent.context_mappings` returns the mapping hash.
284
292
 
285
- ### Legacy Context (Metadata)
286
-
287
- The older `event_context` / `restore_event_context` configuration captures arbitrary metadata at publish time and restores it before async handler execution. Both mechanisms coexist.
288
-
289
293
  ---
290
294
 
291
295
  ## Configuration
@@ -294,12 +298,10 @@ The older `event_context` / `restore_event_context` configuration captures arbit
294
298
  # config/initializers/dexkit.rb
295
299
  Dex.configure do |config|
296
300
  config.event_store = nil # model for persistence (default: nil)
297
- config.event_context = nil # -> { Hash } lambda (default: nil)
298
- config.restore_event_context = nil # ->(ctx) { ... } lambda (default: nil)
299
301
  end
300
302
  ```
301
303
 
302
- Everything works without configuration. All three settings are optional.
304
+ Everything works without configuration.
303
305
 
304
306
  ---
305
307
 
@@ -381,10 +383,7 @@ class CreateOrderTest < Minitest::Test
381
383
  def test_trace_chain
382
384
  capture_events do
383
385
  parent = OrderPlaced.new(order_id: 1, total: 99.99)
384
-
385
- parent.trace do
386
- InventoryReserved.publish(order_id: 1)
387
- end
386
+ InventoryReserved.publish(order_id: 1, caused_by: parent)
388
387
 
389
388
  child = _dex_published_events.last
390
389
  assert_event_trace(parent, child)