dexkit 0.1.0 → 0.3.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: 7be5c6e58f2741692419cf25f9a8e76df8003036dd17139073561fe5141e3bdc
4
- data.tar.gz: 28f77abedf2552c3a2f319abd175f61855f956517295e1ea435a3dbf88327735
3
+ metadata.gz: ec8b561633fbdf9989f8bc2476fe84ad2cd0c32177990d03380d63f59a8368a9
4
+ data.tar.gz: a348e6b1090374bfda6281e6a58fe16af3e285bb009fad38be60a3ba2c510720
5
5
  SHA512:
6
- metadata.gz: 800756f2f2f22dc4e487794fc129ccafc57ee815b3e26aea67c1f8eef9195654106cd7ab7b91525c4e5325da1696e94069ffc78e2d1302dc1f7a5d6f6eff8e82
7
- data.tar.gz: 7bf8212783439db36adb9ae04854f981d2e2e6d9db8ca7bf9cd0768faa566aec98addc56f12640b7d0c284e3a7f4ca9d8afa05ba1f90568241970214bd8307d5
6
+ metadata.gz: 49d44b87657f65927b88c7fc1296ccab5d5246637a1887a126b0949e11bd452ecc0deb5fea90ef7f3329051b7bc13895f8f0ef73286705de44f7d21dccc0802a
7
+ data.tar.gz: 43182a4b6b6c904afe87fb385cff3a42057975363b5fd53e10744264f3af8cd5d48306b0ade26fd195b0d4fca696cbd85ac6c06e4434f48c280fb42a959beb80
data/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-03-03
4
+
5
+ ### Added
6
+
7
+ - **Form objects** — `Dex::Form` base class for user-facing input handling
8
+ - Typed attributes via ActiveModel (`attribute :name, :string`)
9
+ - Normalization on assignment (`normalizes :email, with: -> { _1&.strip&.downcase }`)
10
+ - Full ActiveModel validation support, including custom `uniqueness` validator with scope, case-insensitive matching, conditions, and record exclusion
11
+ - `model` DSL for binding a form to an ActiveRecord model class (drives `model_name`, `persisted?`, `to_key`, `to_param`)
12
+ - `record` reader and `with_record` chainable method for edit/update forms — record is excluded from form attributes and protected from mass assignment
13
+ - `nested_one` / `nested_many` DSL for nested form objects with auto-generated constants, `build_` methods, and `_attributes=` setters
14
+ - Hash coercion, Rails numbered hash format, and `_destroy` filtering in nested forms
15
+ - Validation propagation from nested forms with prefixed error keys (`address.street`, `documents[0].doc_type`)
16
+ - `ActionController::Parameters` support — strong parameters (`permit`) not required; the form's attribute declarations are the whitelist
17
+ - `to_h` / `to_hash` serialization including nested forms
18
+ - `ValidationError` for bang-style save patterns
19
+ - Full Rails `form_with` / `fields_for` compatibility
20
+ - **Form uniqueness validator** — `validates :email, uniqueness: true`
21
+ - Model resolution chain: explicit `model:` option, `model` DSL, or infer from class name (`UserForm` → `User`)
22
+ - Options: `scope`, `case_sensitive`, `conditions` (zero-arg or form-arg lambda), `attribute` mapping, `message`
23
+ - Excludes current record via model's `primary_key` (not hardcoded `id`)
24
+ - Declaration-time validation of `model:` and `conditions:` options
25
+ - Added `actionpack` as development dependency for testing Rails controller integration
26
+ - Added `activemodel >= 6.1` as runtime dependency
27
+
28
+ ### Changed
29
+
30
+ - `Dex::Match` is now included in `Dex::Operation` – `Ok`/`Err` are available without prefix inside operations. External contexts (controllers, POROs) can still use `Dex::Ok`/`Dex::Err` or `include Dex::Match`.
31
+
32
+ ## [0.2.0] - 2026-03-02
33
+
34
+ ### Added
35
+
36
+ - **Event system** — typed, immutable event value objects with publish/subscribe
37
+ - Typed properties via `prop`/`prop?` DSL (same as Operation)
38
+ - Sync and async publishing (`event.publish`, `Event.publish(sync: true)`)
39
+ - Handler DSL: `on` for subscription, `retries` with exponential/fixed/custom backoff
40
+ - Async dispatch via ActiveJob (`Dex::Event::Processor`, lazy-loaded)
41
+ - Causality tracing: `event.trace { ... }` and `caused_by:` link events into chains with shared `trace_id`
42
+ - Block-scoped suppression: `Dex::Event.suppress(SomeEvent) { ... }`
43
+ - Optional persistence via `event_store` configuration
44
+ - Context capture and restoration across async boundaries (`event_context`, `restore_event_context`)
45
+ - **Event test helpers** — `Dex::Event::TestHelpers` module
46
+ - `capture_events` block for inspecting published events without dispatching
47
+ - `assert_event_published`, `refute_event_published`, `assert_event_count`
48
+ - `assert_event_trace`, `assert_same_trace` for causality assertions
49
+
50
+ ### Changed
51
+
52
+ - Extracted shared `validate_options!` helper for DSL option validation
53
+ - Added `Dex.warn` for unified warning logging
54
+ - Added `Dex::Concern` to reduce module inclusion boilerplate
55
+ - Simplified internal method names in standalone classes (AsyncProxy, Pipeline, Jobs, Processor)
56
+
57
+ ### Fixed
58
+
59
+ - `_Array(_Ref(...))` props now serialize and deserialize correctly in both Operation and Event (array of model references was crashing on `value.id` called on Array)
60
+
61
+ ### Changed
62
+
63
+ - Extracted `Dex::TypeCoercion` — shared module for prop serialization and coercion, used by both Operation and Event. Eliminates duplication of `_serialized_coercions`, ref type detection, and value coercion logic.
64
+ - Extracted `Dex::PropsSetup` — shared `prop`/`prop?`/`_Ref` DSL wrapping Literal's with reserved name validation, public reader default, and automatic RefType coercion. Eliminates duplication between Operation and Event.
65
+
3
66
  ## [0.1.0] - 2026-03-01
4
67
 
5
68
  - Initial public release
data/README.md CHANGED
@@ -48,8 +48,6 @@ rescue_from Stripe::CardError, as: :card_declined
48
48
  **Ok / Err** – pattern match on operation outcomes with `.safe.call`:
49
49
 
50
50
  ```ruby
51
- include Dex::Match
52
-
53
51
  case CreateUser.new(email: email).safe.call
54
52
  in Ok(name:)
55
53
  puts "Welcome, #{name}!"
@@ -84,6 +82,120 @@ class CreateUserTest < Minitest::Test
84
82
  end
85
83
  ```
86
84
 
85
+ ## Events
86
+
87
+ Typed, immutable event objects with publish/subscribe, async dispatch, and causality tracing.
88
+
89
+ ```ruby
90
+ class OrderPlaced < Dex::Event
91
+ prop :order_id, Integer
92
+ prop :total, BigDecimal
93
+ prop? :coupon_code, String
94
+ end
95
+
96
+ class NotifyWarehouse < Dex::Event::Handler
97
+ on OrderPlaced
98
+ retries 3
99
+
100
+ def perform
101
+ WarehouseApi.notify(event.order_id)
102
+ end
103
+ end
104
+
105
+ OrderPlaced.publish(order_id: 1, total: 99.99)
106
+ ```
107
+
108
+ ### What you get out of the box
109
+
110
+ **Zero-config pub/sub** — define events and handlers, publish. No bus setup needed.
111
+
112
+ **Async by default** — handlers dispatched via ActiveJob. `sync: true` for inline.
113
+
114
+ **Causality tracing** — link events in chains with shared `trace_id`:
115
+
116
+ ```ruby
117
+ order_placed.trace do
118
+ InventoryReserved.publish(order_id: 1)
119
+ end
120
+ ```
121
+
122
+ **Suppression**, optional **persistence**, **context capture**, and **retries** with exponential backoff.
123
+
124
+ ### Testing
125
+
126
+ ```ruby
127
+ class CreateOrderTest < Minitest::Test
128
+ include Dex::Event::TestHelpers
129
+
130
+ def test_publishes_order_placed
131
+ capture_events do
132
+ CreateOrder.call(item_id: 1)
133
+ assert_event_published(OrderPlaced, order_id: 1)
134
+ end
135
+ end
136
+ end
137
+ ```
138
+
139
+ ## Forms
140
+
141
+ Form objects with typed attributes, normalization, nested forms, and Rails form builder compatibility.
142
+
143
+ ```ruby
144
+ class OnboardingForm < Dex::Form
145
+ model User
146
+
147
+ attribute :first_name, :string
148
+ attribute :last_name, :string
149
+ attribute :email, :string
150
+
151
+ normalizes :email, with: -> { _1&.strip&.downcase.presence }
152
+
153
+ validates :email, presence: true, uniqueness: true
154
+ validates :first_name, :last_name, presence: true
155
+
156
+ nested_one :address do
157
+ attribute :street, :string
158
+ attribute :city, :string
159
+ validates :street, :city, presence: true
160
+ end
161
+ end
162
+
163
+ form = OnboardingForm.new(email: " ALICE@EXAMPLE.COM ", first_name: "Alice", last_name: "Smith")
164
+ form.email # => "alice@example.com"
165
+ form.valid?
166
+ ```
167
+
168
+ ### What you get out of the box
169
+
170
+ **ActiveModel attributes** with type casting, normalization, and full Rails validation DSL.
171
+
172
+ **Nested forms** — `nested_one` and `nested_many` with automatic Hash coercion, `_destroy` support, and error propagation:
173
+
174
+ ```ruby
175
+ nested_many :documents do
176
+ attribute :document_type, :string
177
+ attribute :document_number, :string
178
+ validates :document_type, :document_number, presence: true
179
+ end
180
+ ```
181
+
182
+ **Rails form compatibility** — works with `form_with`, `fields_for`, and nested attributes out of the box.
183
+
184
+ **Uniqueness validation** against the database, with scope, case-sensitivity, and current-record exclusion.
185
+
186
+ **Multi-model forms** — when a form spans User, Employee, and Address, define a `.for` convention method to map records and a `#save` method that delegates to a `Dex::Operation`:
187
+
188
+ ```ruby
189
+ def save
190
+ return false unless valid?
191
+
192
+ case operation.safe.call
193
+ in Ok then true
194
+ in Err => e then errors.add(:base, e.message) and false
195
+ end
196
+ end
197
+ ```
198
+
87
199
  ## Installation
88
200
 
89
201
  ```ruby
@@ -100,7 +212,8 @@ Dexkit ships LLM-optimized guides. Copy them into your project so AI agents auto
100
212
 
101
213
  ```bash
102
214
  cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
103
- cp $(bundle show dexkit)/guides/llm/TESTING.md test/CLAUDE.md
215
+ cp $(bundle show dexkit)/guides/llm/EVENT.md app/event_handlers/CLAUDE.md
216
+ cp $(bundle show dexkit)/guides/llm/FORM.md app/forms/CLAUDE.md
104
217
  ```
105
218
 
106
219
  ## License
@@ -0,0 +1,300 @@
1
+ # Dex::Event — LLM Reference
2
+
3
+ Copy this to your app's event handlers directory (e.g., `app/event_handlers/AGENTS.md`) so coding agents know the full API when implementing and testing events.
4
+
5
+ ---
6
+
7
+ ## Reference Event
8
+
9
+ All examples below build on this event unless noted otherwise:
10
+
11
+ ```ruby
12
+ class OrderPlaced < Dex::Event
13
+ prop :order_id, Integer
14
+ prop :total, BigDecimal
15
+ prop? :coupon_code, String
16
+ end
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Defining Events
22
+
23
+ Typed, immutable value objects. Props defined with `prop` (required) and `prop?` (optional). Same type system as `Dex::Operation`.
24
+
25
+ ```ruby
26
+ class UserCreated < Dex::Event
27
+ prop :user_id, Integer
28
+ prop :email, String
29
+ prop? :referrer, String
30
+ end
31
+ ```
32
+
33
+ Reserved names: `id`, `timestamp`, `trace_id`, `caused_by_id`, `caused_by`, `context`, `publish`, `metadata`, `sync`.
34
+
35
+ Events are frozen after creation. Each gets auto-generated `id` (UUID), `timestamp` (UTC), `trace_id`, and optional `caused_by_id`.
36
+
37
+ ### Literal Types Cheatsheet
38
+
39
+ Same types as Operation — `String`, `Integer`, `_Integer(1..)`, `_Array(String)`, `_Union("a", "b")`, `_Nilable(String)`, `_Ref(Model)`, etc.
40
+
41
+ ---
42
+
43
+ ## Publishing
44
+
45
+ ```ruby
46
+ # Class-level (most common)
47
+ OrderPlaced.publish(order_id: 1, total: 99.99) # async (default)
48
+ OrderPlaced.publish(order_id: 1, total: 99.99, sync: true) # sync
49
+
50
+ # Instance-level
51
+ event = OrderPlaced.new(order_id: 1, total: 99.99)
52
+ event.publish # async
53
+ event.publish(sync: true) # sync
54
+
55
+ # With causality
56
+ OrderPlaced.publish(order_id: 1, total: 99.99, caused_by: parent_event)
57
+ ```
58
+
59
+ **Async** (default): handlers dispatched via ActiveJob. **Sync**: handlers called inline.
60
+
61
+ ---
62
+
63
+ ## Handling
64
+
65
+ Handlers are classes that subscribe to events. Subscription is automatic via `on`:
66
+
67
+ ```ruby
68
+ class NotifyWarehouse < Dex::Event::Handler
69
+ on OrderPlaced
70
+ on OrderUpdated # subscribe to multiple events
71
+
72
+ def perform
73
+ event # accessor — the event instance
74
+ event.order_id # typed props
75
+ event.id # UUID
76
+ event.timestamp # Time (UTC)
77
+ event.caused_by_id # parent event ID (if traced)
78
+ event.trace_id # shared trace ID across causal chain
79
+ end
80
+ end
81
+ ```
82
+
83
+ **Multi-event handlers**: A single handler can subscribe to multiple event types.
84
+
85
+ ### Loading Handlers (Rails)
86
+
87
+ Handlers must be loaded for `on` to register. Standard pattern:
88
+
89
+ ```ruby
90
+ # config/initializers/events.rb
91
+ Rails.application.config.to_prepare do
92
+ Dex::Event::Bus.clear!
93
+ Dir.glob(Rails.root.join("app/event_handlers/**/*.rb")).each { |e| require(e) }
94
+ end
95
+ ```
96
+
97
+ ### Retries
98
+
99
+ ```ruby
100
+ class ProcessPayment < Dex::Event::Handler
101
+ on PaymentReceived
102
+ retries 3 # exponential backoff (1s, 2s, 4s)
103
+ retries 3, wait: 10 # fixed 10s between retries
104
+ retries 3, wait: ->(attempt) { attempt * 5 } # custom delay
105
+
106
+ def perform
107
+ # ...
108
+ end
109
+ end
110
+ ```
111
+
112
+ When retries exhausted, exception propagates normally.
113
+
114
+ ---
115
+
116
+ ## Tracing (Causality)
117
+
118
+ Link events in a causal chain. All events in a chain share the same `trace_id`.
119
+
120
+ ```ruby
121
+ order_placed = OrderPlaced.new(order_id: 1, total: 99.99)
122
+
123
+ # Option 1: trace block
124
+ order_placed.trace do
125
+ InventoryReserved.publish(order_id: 1) # caused_by_id = order_placed.id
126
+ ShippingRequested.publish(order_id: 1) # same trace_id
127
+ end
128
+
129
+ # Option 2: caused_by keyword
130
+ InventoryReserved.publish(order_id: 1, caused_by: order_placed)
131
+ ```
132
+
133
+ Nesting works — each child gets the nearest parent's `id` as `caused_by_id`, and the root's `trace_id`.
134
+
135
+ ---
136
+
137
+ ## Suppression
138
+
139
+ Prevent events from being published (useful in tests, migrations, bulk ops):
140
+
141
+ ```ruby
142
+ Dex::Event.suppress { ... } # suppress all events
143
+ Dex::Event.suppress(OrderPlaced) { ... } # suppress specific class
144
+ Dex::Event.suppress(OrderPlaced, UserCreated) { ... } # suppress multiple
145
+ ```
146
+
147
+ Suppression is block-scoped and nestable. Child classes are suppressed when parent is.
148
+
149
+ ---
150
+
151
+ ## Persistence (Optional)
152
+
153
+ Store events to database when configured:
154
+
155
+ ```ruby
156
+ Dex.configure do |c|
157
+ c.event_store = EventRecord # any model with create!(event_type:, payload:, metadata:)
158
+ end
159
+ ```
160
+
161
+ ```ruby
162
+ create_table :event_records do |t|
163
+ t.string :event_type
164
+ t.jsonb :payload
165
+ t.jsonb :metadata
166
+ t.timestamps
167
+ end
168
+ ```
169
+
170
+ Persistence failures are silently rescued — they never halt event publishing.
171
+
172
+ ---
173
+
174
+ ## Context (Optional)
175
+
176
+ Capture ambient context (current user, tenant, etc.) at publish time:
177
+
178
+ ```ruby
179
+ Dex.configure do |c|
180
+ c.event_context = -> { { user_id: Current.user&.id, tenant: Current.tenant } }
181
+ c.restore_event_context = ->(ctx) {
182
+ Current.user = User.find(ctx["user_id"]) if ctx["user_id"]
183
+ Current.tenant = ctx["tenant"]
184
+ }
185
+ end
186
+ ```
187
+
188
+ Context is stored in event metadata and restored before async handler execution.
189
+
190
+ ---
191
+
192
+ ## Configuration
193
+
194
+ ```ruby
195
+ # config/initializers/dexkit.rb
196
+ Dex.configure do |config|
197
+ config.event_store = nil # model for persistence (default: nil)
198
+ config.event_context = nil # -> { Hash } lambda (default: nil)
199
+ config.restore_event_context = nil # ->(ctx) { ... } lambda (default: nil)
200
+ end
201
+ ```
202
+
203
+ Everything works without configuration. All three settings are optional.
204
+
205
+ ---
206
+
207
+ ## Testing
208
+
209
+ ```ruby
210
+ # test/test_helper.rb
211
+ require "dex/event_test_helpers"
212
+
213
+ class Minitest::Test
214
+ include Dex::Event::TestHelpers
215
+ end
216
+ ```
217
+
218
+ Not autoloaded — stays out of production.
219
+
220
+ ### Capturing Events
221
+
222
+ Captures events instead of dispatching handlers:
223
+
224
+ ```ruby
225
+ def test_publishes_order_placed
226
+ capture_events do
227
+ OrderPlaced.publish(order_id: 1, total: 99.99)
228
+
229
+ assert_event_published(OrderPlaced)
230
+ assert_event_published(OrderPlaced, order_id: 1)
231
+ assert_event_count(OrderPlaced, 1)
232
+ refute_event_published(OrderCancelled)
233
+ end
234
+ end
235
+ ```
236
+
237
+ Outside `capture_events`, events are dispatched synchronously (test safety).
238
+
239
+ ### Assertions
240
+
241
+ ```ruby
242
+ # Published
243
+ assert_event_published(EventClass) # any instance
244
+ assert_event_published(EventClass, prop: value) # with prop match
245
+ refute_event_published # nothing published
246
+ refute_event_published(EventClass) # specific class not published
247
+
248
+ # Count
249
+ assert_event_count(EventClass, 3)
250
+
251
+ # Tracing
252
+ assert_event_trace(parent_event, child_event) # caused_by_id match
253
+ assert_same_trace(event_a, event_b, event_c) # shared trace_id
254
+ ```
255
+
256
+ ### Suppression in Tests
257
+
258
+ ```ruby
259
+ def test_no_side_effects
260
+ Dex::Event.suppress do
261
+ CreateOrder.call(item_id: 1) # events suppressed
262
+ end
263
+ end
264
+ ```
265
+
266
+ ### Complete Test Example
267
+
268
+ ```ruby
269
+ class CreateOrderTest < Minitest::Test
270
+ include Dex::Event::TestHelpers
271
+
272
+ def test_publishes_order_placed
273
+ capture_events do
274
+ order = CreateOrder.call(item_id: 1, quantity: 2)
275
+
276
+ assert_event_published(OrderPlaced, order_id: order.id)
277
+ assert_event_count(OrderPlaced, 1)
278
+ refute_event_published(OrderCancelled)
279
+ end
280
+ end
281
+
282
+ def test_trace_chain
283
+ capture_events do
284
+ parent = OrderPlaced.new(order_id: 1, total: 99.99)
285
+
286
+ parent.trace do
287
+ InventoryReserved.publish(order_id: 1)
288
+ end
289
+
290
+ child = _dex_published_events.last
291
+ assert_event_trace(parent, child)
292
+ assert_same_trace(parent, child)
293
+ end
294
+ end
295
+ end
296
+ ```
297
+
298
+ ---
299
+
300
+ **End of reference.**