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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 493f1873cf71e1f8e2348f4f15e3e508962d9ae310b85f7b4aac8a756396de05
4
- data.tar.gz: ad50fd8349a45e4c4bc536f68bb2c74ee10b59e8b80856fca2823bb9f9a998cf
3
+ metadata.gz: 0201163b689acfc7c65709eb96174e81dacd9f090f573d4cc2ba2f15c6362c2b
4
+ data.tar.gz: cedf548f898ccc821262adec828613848572bfd02dbd0667001a1f9f1669c5bb
5
5
  SHA512:
6
- metadata.gz: 18ca385b0d66155e35c3f08164d3b558e85f3f29a586b492d664c72c24f746e6e70c0d6692faedb348e7c849096eb0f3ddfd98a5515e2de5b5ce05084cab3d5e
7
- data.tar.gz: 2e7c2e59443b2dff7fb3a9de4c3296ce1b71c4f870bf47d2ce503f5d5c23105a1cee5549618451741a21fb6d0c8fcb0eba53b94d4edcf8e999e1164e9eabf96f
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. `Dex::Event::Trace` remains as a thin delegation layer
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 and restoration across async boundaries (`event_context`, `restore_event_context`)
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
- 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,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
- **Async execution** via ActiveJob:
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
- **Guards** – inline precondition checks with introspection. Ask "can this operation run?" from views and controllers:
67
+ Inline precondition checks with introspection ask "can this run?" from views and controllers:
118
68
 
119
69
  ```ruby
120
- guard :out_of_stock, "Product must be in stock" do
121
- !product.in_stock?
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
- **Ambient context** – declare which props come from ambient state. Set once in a controller, auto-fill everywhere:
78
+ ### Prescriptive errors
129
79
 
130
- ```ruby
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
- info = Order::Place.explain(product: product, customer: customer, quantity: 2)
154
- info[:callable] # => true (all guards pass)
155
- info[:once][:status] # => :fresh (would execute, not replay)
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
- ```ruby
162
- # List all operations
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
- # Export contracts
166
- Dex::Operation.export(format: :json_schema)
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
- **Transactions** on by default, **advisory locking**, **recording** to database, **callbacks**, and a customizable **pipeline** – all composable, all optional.
175
-
176
- ### Testing
95
+ ### And more
177
96
 
178
- First-class test helpers for Minitest:
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.notify(event.order_id)
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
- orders = Order::Query.call(status: "pending", sort: "-total")
134
+ Order::Query.call(status: "pending", sort: "-total")
346
135
  ```
347
136
 
348
- ### What you get out of the box
137
+ ## Forms
349
138
 
350
- **Registry, description, and context** — same ecosystem as Operation, Event, and Form. `Dex::Query.registry` discovers all query classes, `description` documents intent, and `context` auto-fills props from `Dex.with_context`.
139
+ ```ruby
140
+ class Order::Form < Dex::Form
141
+ field :customer_email, :string
142
+ field? :note, :string
351
143
 
352
- **Export** — `Query.to_h`, `Query.to_json_schema`, `Dex::Query.export(format:)` for introspection and bulk export.
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
- **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.
151
+ ## Testing
355
152
 
356
- **Sorting** with ascending/descending column sorts, custom sort blocks, and defaults.
153
+ ```ruby
154
+ class PlaceOrderTest < Minitest::Test
155
+ testing Order::Place
357
156
 
358
- **`from_params`** — HTTP boundary handling with automatic coercion, blank stripping, and invalid value fallback:
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
- ```ruby
361
- class OrdersController < ApplicationController
362
- def index
363
- query = Order::Query.from_params(params, scope: policy_scope(Order))
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 OPERATIONS_PATH=app/services
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.10.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.10.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. All three settings are optional.
304
+ Everything works without configuration.
311
305
 
312
306
  ---
313
307