dexkit 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -2
- data/README.md +62 -281
- data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
- data/guides/llm/EVENT.md +1 -7
- data/guides/llm/OPERATION.md +88 -54
- data/guides/llm/QUERY.md +6 -0
- data/guides/llm/TOOL.md +308 -0
- data/lib/dex/event/bus.rb +1 -3
- data/lib/dex/event/handler.rb +1 -2
- data/lib/dex/event/metadata.rb +3 -15
- data/lib/dex/event/processor.rb +1 -15
- data/lib/dex/event.rb +1 -3
- data/lib/dex/id.rb +92 -5
- data/lib/dex/operation/async_proxy.rb +10 -2
- data/lib/dex/operation/guard_wrapper.rb +1 -1
- data/lib/dex/operation/outcome.rb +14 -0
- data/lib/dex/operation/result_wrapper.rb +0 -12
- data/lib/dex/operation/test_helpers/assertions.rb +0 -112
- data/lib/dex/operation/ticket.rb +268 -0
- data/lib/dex/operation.rb +1 -0
- data/lib/dex/operation_failed.rb +14 -0
- data/lib/dex/timeout.rb +14 -0
- data/lib/dex/tool.rb +388 -5
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +19 -3
- metadata +5 -3
- data/lib/dex/event/trace.rb +0 -43
- data/lib/dex/event_test_helpers.rb +0 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0201163b689acfc7c65709eb96174e81dacd9f090f573d4cc2ba2f15c6362c2b
|
|
4
|
+
data.tar.gz: cedf548f898ccc821262adec828613848572bfd02dbd0667001a1f9f1669c5bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 318363e15f2b2ebb3846125d5785562f114f3941e29a577e721b72ff6101c6d30171771c7998dac253e7e91f31a9727d5e318f62df172c78233a067a45d6413b
|
|
7
|
+
data.tar.gz: a982874d3377ebec37bfe9a9528b505150a31b83298594827cbb7d3d0e9d9679635a48b6068a02c4d0ed6e1ba53764471f1534d5f28209b8e2fadfc062a4f64d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
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
|
+
|
|
3
37
|
## [0.10.0] - 2026-03-09
|
|
4
38
|
|
|
5
39
|
### Added
|
|
@@ -18,7 +52,7 @@
|
|
|
18
52
|
|
|
19
53
|
### Breaking
|
|
20
54
|
|
|
21
|
-
- **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
|
|
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
|
|
22
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
|
|
23
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`
|
|
24
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
|
|
@@ -187,7 +221,7 @@
|
|
|
187
221
|
- Causality tracing: `event.trace { ... }` and `caused_by:` link events into chains with shared `trace_id`
|
|
188
222
|
- Block-scoped suppression: `Dex::Event.suppress(SomeEvent) { ... }`
|
|
189
223
|
- Optional persistence via `event_store` configuration
|
|
190
|
-
- Context capture
|
|
224
|
+
- Context capture across async boundaries via `Dex.with_context` and the `context` DSL
|
|
191
225
|
- **Event test helpers** — `Dex::Event::TestHelpers` module
|
|
192
226
|
- `capture_events` block for inspecting published events without dispatching
|
|
193
227
|
- `assert_event_published`, `refute_event_published`, `assert_event_count`
|
data/README.md
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# dexkit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Typed patterns for Rails, crafted for DX. Equip to gain +4 DEX.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
**
|
|
7
|
+
> **Pre-1.0.** Active development. The public API may change between minor versions.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Four base classes with contracts that enforce themselves:
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
prop
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,143 +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
|
-
|
|
71
|
-
|
|
72
|
-
```ruby
|
|
73
|
-
Order::Fulfill.new(order_id: 123).async(queue: "fulfillment").call
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
**Execution tracing** – every operation gets a prefixed ID and joins a unified trace across operations, events, and handlers:
|
|
77
|
-
|
|
78
|
-
```ruby
|
|
79
|
-
Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
|
|
80
|
-
Order::Place.call(customer: 42, product: 7, quantity: 2)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
Dex::Trace.trace_id # => "tr_..."
|
|
84
|
-
Dex::Trace.current # => [{ type: :actor, ... }, { type: :operation, ... }]
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
**Idempotency** with `once` — run an operation at most once for a given key. Results are replayed on duplicates:
|
|
88
|
-
|
|
89
|
-
```ruby
|
|
90
|
-
class Payment::Charge < Dex::Operation
|
|
91
|
-
prop :order_id, Integer
|
|
92
|
-
prop :amount, Integer
|
|
93
|
-
|
|
94
|
-
once :order_id # key from prop
|
|
95
|
-
# once :order_id, :merchant_id # composite key
|
|
96
|
-
# once # all props as key
|
|
97
|
-
# once { "custom-#{order_id}" } # block-based key
|
|
98
|
-
# once :order_id, expires_in: 24.hours # expiring key
|
|
99
|
-
|
|
100
|
-
def perform
|
|
101
|
-
Gateway.charge!(order_id, amount)
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Call-site key (overrides class-level declaration)
|
|
106
|
-
Payment::Charge.new(order_id: 1, amount: 500).once("ext-key-123").call
|
|
107
|
-
|
|
108
|
-
# Bypass once guard for a single call
|
|
109
|
-
Payment::Charge.new(order_id: 1, amount: 500).once(nil).call
|
|
110
|
-
|
|
111
|
-
# Clear a stored key to allow re-execution
|
|
112
|
-
Payment::Charge.clear_once!(order_id: 1)
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
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).
|
|
65
|
+
### Guards
|
|
116
66
|
|
|
117
|
-
|
|
67
|
+
Inline precondition checks with introspection — ask "can this run?" from views and controllers:
|
|
118
68
|
|
|
119
69
|
```ruby
|
|
120
|
-
guard :
|
|
121
|
-
!
|
|
70
|
+
guard :active_customer, "Customer account must be active" do
|
|
71
|
+
!customer.suspended?
|
|
122
72
|
end
|
|
123
73
|
|
|
124
|
-
# In a view or controller:
|
|
125
74
|
Order::Place.callable?(customer: customer, product: product, quantity: 1)
|
|
75
|
+
# => true / false
|
|
126
76
|
```
|
|
127
77
|
|
|
128
|
-
|
|
78
|
+
### Prescriptive errors
|
|
129
79
|
|
|
130
|
-
|
|
131
|
-
class Order::Place < Dex::Operation
|
|
132
|
-
prop :product, _Ref(Product)
|
|
133
|
-
prop :customer, _Ref(Customer)
|
|
134
|
-
context customer: :current_customer # filled from Dex.context[:current_customer]
|
|
135
|
-
|
|
136
|
-
def perform
|
|
137
|
-
Order.create!(product: product, customer: customer)
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
# Controller
|
|
142
|
-
Dex.with_context(current_customer: current_customer) do
|
|
143
|
-
Order::Place.call(product: product) # customer auto-filled
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# Tests – just pass it explicitly
|
|
147
|
-
Order::Place.call(product: product, customer: customer)
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
**Explain** – full preflight check in one call. Context, guards, idempotency, locks, settings – everything the operation would do, without doing it:
|
|
80
|
+
Every mistake tells you what went wrong, why, and what to do instead:
|
|
151
81
|
|
|
152
82
|
```ruby
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
info[:context][:source] # => { customer: :ambient }
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
**Registry & Export** — list all operations, export contracts as JSON or JSON Schema, and bridge to LLM function-calling via [ruby-llm](https://rubyllm.com/):
|
|
83
|
+
error!(:not_found)
|
|
84
|
+
# => ArgumentError: Order::Place declares unknown error code :not_found.
|
|
85
|
+
# Declared codes: [:out_of_stock]
|
|
160
86
|
|
|
161
|
-
|
|
162
|
-
#
|
|
163
|
-
Dex::Operation.registry # => #<Set: {Order::Place, Order::Cancel, ...}>
|
|
87
|
+
prop :email, 123
|
|
88
|
+
# => Literal::TypeError: expected a type, got 123 (Integer)
|
|
164
89
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
# LLM tools (requires ruby-llm gem)
|
|
169
|
-
chat = RubyLLM.chat
|
|
170
|
-
chat.with_tools(*Dex::Tool.all)
|
|
171
|
-
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]
|
|
172
93
|
```
|
|
173
94
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
### Testing
|
|
95
|
+
### And more
|
|
177
96
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
```ruby
|
|
181
|
-
class PlaceOrderTest < Minitest::Test
|
|
182
|
-
testing Order::Place
|
|
183
|
-
|
|
184
|
-
def test_places_order
|
|
185
|
-
assert_operation(customer: customer.id, product: product.id, quantity: 2)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def test_rejects_out_of_stock
|
|
189
|
-
assert_operation_error(:out_of_stock, customer: customer.id,
|
|
190
|
-
product: out_of_stock_product.id, quantity: 1)
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
```
|
|
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.
|
|
194
98
|
|
|
195
99
|
## Events
|
|
196
100
|
|
|
197
|
-
Typed, immutable event objects with publish/subscribe, async dispatch, and causality tracing.
|
|
198
|
-
|
|
199
101
|
```ruby
|
|
200
102
|
class Order::Placed < Dex::Event
|
|
201
103
|
prop :order_id, Integer
|
|
202
104
|
prop :total, BigDecimal
|
|
203
|
-
prop? :coupon_code, String
|
|
204
105
|
end
|
|
205
106
|
|
|
206
107
|
class NotifyWarehouse < Dex::Event::Handler
|
|
@@ -208,192 +109,72 @@ class NotifyWarehouse < Dex::Event::Handler
|
|
|
208
109
|
retries 3
|
|
209
110
|
|
|
210
111
|
def perform
|
|
211
|
-
WarehouseApi.
|
|
112
|
+
WarehouseApi.reserve(event.order_id)
|
|
212
113
|
end
|
|
213
114
|
end
|
|
214
115
|
|
|
215
116
|
Order::Placed.publish(order_id: 1, total: 99.99)
|
|
216
117
|
```
|
|
217
118
|
|
|
218
|
-
### What you get out of the box
|
|
219
|
-
|
|
220
|
-
**Zero-config pub/sub** — define events and handlers, publish. No bus setup needed.
|
|
221
|
-
|
|
222
|
-
**Async by default** — handlers dispatched via ActiveJob. `sync: true` for inline. If ActiveJob is not loaded, async publish raises `LoadError`.
|
|
223
|
-
|
|
224
|
-
**Causality tracing** – events join the unified execution trace, and child events link to their cause:
|
|
225
|
-
|
|
226
|
-
```ruby
|
|
227
|
-
Dex::Trace.start(actor: { type: :user, id: 42 }) do
|
|
228
|
-
order_placed = Order::Placed.new(order_id: 1, total: 99.99)
|
|
229
|
-
Shipment::Reserved.publish(order_id: 1, caused_by: order_placed)
|
|
230
|
-
end
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
**Callbacks** — `before`, `after`, `around` hooks on handlers, same DSL as operations.
|
|
234
|
-
|
|
235
|
-
**Transactions** — opt-in `transaction` and `after_commit` for handlers that write to the database.
|
|
236
|
-
|
|
237
|
-
**Suppression**, optional **persistence**, **context capture**, and **retries** with exponential backoff.
|
|
238
|
-
|
|
239
|
-
### Testing
|
|
240
|
-
|
|
241
|
-
```ruby
|
|
242
|
-
class PlaceOrderTest < Minitest::Test
|
|
243
|
-
include Dex::Event::TestHelpers
|
|
244
|
-
|
|
245
|
-
def test_publishes_order_placed
|
|
246
|
-
capture_events do
|
|
247
|
-
Order::Place.call(customer: customer.id, product: product.id, quantity: 2)
|
|
248
|
-
assert_event_published(Order::Placed)
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
## Forms
|
|
255
|
-
|
|
256
|
-
Form objects with typed fields, normalization, nested forms, ambient context, JSON Schema export, and Rails form builder compatibility.
|
|
257
|
-
|
|
258
|
-
```ruby
|
|
259
|
-
class Employee::Form < Dex::Form
|
|
260
|
-
description "Employee onboarding form"
|
|
261
|
-
model Employee
|
|
262
|
-
|
|
263
|
-
field :first_name, :string
|
|
264
|
-
field :last_name, :string
|
|
265
|
-
field :email, :string
|
|
266
|
-
field :locale, :string
|
|
267
|
-
field? :notes, :string
|
|
268
|
-
|
|
269
|
-
context :locale
|
|
270
|
-
|
|
271
|
-
normalizes :email, with: -> { _1&.strip&.downcase.presence }
|
|
272
|
-
|
|
273
|
-
validates :email, uniqueness: true
|
|
274
|
-
|
|
275
|
-
nested_one :address do
|
|
276
|
-
field :street, :string
|
|
277
|
-
field :city, :string
|
|
278
|
-
field? :apartment, :string
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
form = Employee::Form.new(email: " ALICE@EXAMPLE.COM ", first_name: "Alice", last_name: "Smith")
|
|
283
|
-
form.email # => "alice@example.com"
|
|
284
|
-
form.valid?
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
### What you get out of the box
|
|
288
|
-
|
|
289
|
-
**`field` / `field?`** — required and optional fields with auto-presence validation, `desc:` metadata, and defaults. Backed by ActiveModel attributes with type casting and normalization. Unconditional `validates :attr, presence: true` deduplicates with `field`; scoped validations still layer on top.
|
|
290
|
-
|
|
291
|
-
**Nested forms** — `nested_one` and `nested_many` with automatic Hash coercion, `_destroy` support, and error propagation:
|
|
292
|
-
|
|
293
|
-
```ruby
|
|
294
|
-
nested_many :emergency_contacts do
|
|
295
|
-
field :name, :string
|
|
296
|
-
field :phone, :string
|
|
297
|
-
end
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
**Ambient context** — auto-fill fields from `Dex.context`, same DSL as Operation and Event.
|
|
301
|
-
|
|
302
|
-
**Registry & Export** — `description`, `to_json_schema`, class-level `to_h`, and `Dex::Form.export` for schema introspection. Nested form schemas recurse in both export formats, and bulk export includes only top-level named forms.
|
|
303
|
-
|
|
304
|
-
**Rails form compatibility** — works with `form_with`, `fields_for`, and nested attributes out of the box.
|
|
305
|
-
|
|
306
|
-
**Uniqueness validation** against the database, with scope, case-sensitivity, and current-record exclusion.
|
|
307
|
-
|
|
308
|
-
**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`:
|
|
309
|
-
|
|
310
|
-
```ruby
|
|
311
|
-
def save
|
|
312
|
-
return false unless valid?
|
|
313
|
-
|
|
314
|
-
case operation.safe.call
|
|
315
|
-
in Ok then true
|
|
316
|
-
in Err => e then errors.add(:base, e.message) and false
|
|
317
|
-
end
|
|
318
|
-
end
|
|
319
|
-
```
|
|
320
|
-
|
|
321
119
|
## Queries
|
|
322
120
|
|
|
323
|
-
Declarative query objects for filtering and sorting ActiveRecord and Mongoid scopes.
|
|
324
|
-
|
|
325
121
|
```ruby
|
|
326
122
|
class Order::Query < Dex::Query
|
|
327
|
-
description "Search orders"
|
|
328
|
-
|
|
329
123
|
scope { Order.all }
|
|
330
124
|
|
|
331
125
|
prop? :status, String
|
|
332
|
-
prop? :customer, _Ref(Customer)
|
|
333
126
|
prop? :total_min, Integer
|
|
334
|
-
prop? :tenant, String
|
|
335
|
-
|
|
336
|
-
context tenant: :current_tenant
|
|
337
127
|
|
|
338
128
|
filter :status
|
|
339
|
-
filter :customer
|
|
340
129
|
filter :total_min, :gte, column: :total
|
|
341
130
|
|
|
342
131
|
sort :created_at, :total, default: "-created_at"
|
|
343
132
|
end
|
|
344
133
|
|
|
345
|
-
|
|
134
|
+
Order::Query.call(status: "pending", sort: "-total")
|
|
346
135
|
```
|
|
347
136
|
|
|
348
|
-
|
|
137
|
+
## Forms
|
|
349
138
|
|
|
350
|
-
|
|
139
|
+
```ruby
|
|
140
|
+
class Order::Form < Dex::Form
|
|
141
|
+
field :customer_email, :string
|
|
142
|
+
field? :note, :string
|
|
351
143
|
|
|
352
|
-
|
|
144
|
+
nested_many :line_items do
|
|
145
|
+
field :product_id, :integer
|
|
146
|
+
field :quantity, :integer, default: 1
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
```
|
|
353
150
|
|
|
354
|
-
|
|
151
|
+
## Testing
|
|
355
152
|
|
|
356
|
-
|
|
153
|
+
```ruby
|
|
154
|
+
class PlaceOrderTest < Minitest::Test
|
|
155
|
+
testing Order::Place
|
|
357
156
|
|
|
358
|
-
|
|
157
|
+
def test_places_order
|
|
158
|
+
result = call_operation(customer: customer.id, product: product.id, quantity: 2)
|
|
159
|
+
assert_ok result
|
|
160
|
+
end
|
|
359
161
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
@orders = pagy(query.resolve)
|
|
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
|
|
365
166
|
end
|
|
366
167
|
end
|
|
367
168
|
```
|
|
368
169
|
|
|
369
|
-
**Form binding** — works with `form_with` for search forms. Queries respond to `model_name`, `param_key`, `persisted?`, and `to_params`.
|
|
370
|
-
|
|
371
|
-
**Scope injection** — narrow the base scope at call time without modifying the query class.
|
|
372
|
-
|
|
373
170
|
## Installation
|
|
374
171
|
|
|
375
172
|
```ruby
|
|
376
173
|
gem "dexkit"
|
|
377
174
|
```
|
|
378
175
|
|
|
379
|
-
## Documentation
|
|
380
|
-
|
|
381
|
-
Full documentation at **[dex.razorjack.net](https://dex.razorjack.net)**.
|
|
382
|
-
|
|
383
|
-
## AI Coding Assistant Setup
|
|
384
|
-
|
|
385
|
-
dexkit ships LLM-optimized guides. Install them as `AGENTS.md` files in your app directories so AI coding agents automatically know the API:
|
|
386
|
-
|
|
387
|
-
```bash
|
|
388
|
-
rake dex:guides
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
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).
|
|
392
|
-
|
|
393
|
-
Override paths for non-standard directory names:
|
|
394
|
-
|
|
395
176
|
```bash
|
|
396
|
-
rake dex:guides
|
|
177
|
+
rake dex:guides # install LLM-optimized guides as AGENTS.md in your app directories
|
|
397
178
|
```
|
|
398
179
|
|
|
399
180
|
## License
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
dexkit (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.
|
|
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
|
@@ -290,10 +290,6 @@ end
|
|
|
290
290
|
|
|
291
291
|
**Introspection:** `MyEvent.context_mappings` returns the mapping hash.
|
|
292
292
|
|
|
293
|
-
### Legacy Context (Metadata)
|
|
294
|
-
|
|
295
|
-
The older `event_context` / `restore_event_context` configuration captures arbitrary metadata at publish time and restores it before async handler execution. Both mechanisms coexist.
|
|
296
|
-
|
|
297
293
|
---
|
|
298
294
|
|
|
299
295
|
## Configuration
|
|
@@ -302,12 +298,10 @@ The older `event_context` / `restore_event_context` configuration captures arbit
|
|
|
302
298
|
# config/initializers/dexkit.rb
|
|
303
299
|
Dex.configure do |config|
|
|
304
300
|
config.event_store = nil # model for persistence (default: nil)
|
|
305
|
-
config.event_context = nil # -> { Hash } lambda (default: nil)
|
|
306
|
-
config.restore_event_context = nil # ->(ctx) { ... } lambda (default: nil)
|
|
307
301
|
end
|
|
308
302
|
```
|
|
309
303
|
|
|
310
|
-
Everything works without configuration.
|
|
304
|
+
Everything works without configuration.
|
|
311
305
|
|
|
312
306
|
---
|
|
313
307
|
|