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 +4 -4
- data/CHANGELOG.md +63 -0
- data/README.md +116 -3
- data/guides/llm/EVENT.md +300 -0
- data/guides/llm/FORM.md +520 -0
- data/guides/llm/OPERATION.md +1 -1
- data/lib/dex/concern.rb +10 -0
- data/lib/dex/event/bus.rb +98 -0
- data/lib/dex/event/execution_state.rb +17 -0
- data/lib/dex/event/handler.rb +77 -0
- data/lib/dex/event/metadata.rb +54 -0
- data/lib/dex/event/processor.rb +61 -0
- data/lib/dex/event/suppression.rb +49 -0
- data/lib/dex/event/trace.rb +56 -0
- data/lib/dex/event.rb +87 -0
- data/lib/dex/event_test_helpers/assertions.rb +70 -0
- data/lib/dex/event_test_helpers.rb +88 -0
- data/lib/dex/form/nesting.rb +189 -0
- data/lib/dex/form/uniqueness_validator.rb +86 -0
- data/lib/dex/form.rb +142 -0
- data/lib/dex/operation/async_proxy.rb +30 -36
- data/lib/dex/operation/async_wrapper.rb +3 -19
- data/lib/dex/operation/callback_wrapper.rb +11 -15
- data/lib/dex/operation/jobs.rb +8 -14
- data/lib/dex/operation/lock_wrapper.rb +2 -11
- data/lib/dex/operation/pipeline.rb +5 -5
- data/lib/dex/operation/record_wrapper.rb +10 -38
- data/lib/dex/operation/rescue_wrapper.rb +1 -3
- data/lib/dex/operation/result_wrapper.rb +7 -14
- data/lib/dex/operation/settings.rb +10 -3
- data/lib/dex/operation/transaction_wrapper.rb +7 -20
- data/lib/dex/operation.rb +57 -105
- data/lib/dex/{operation/props_setup.rb → props_setup.rb} +12 -15
- data/lib/dex/test_helpers.rb +3 -1
- data/lib/dex/type_coercion.rb +96 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +15 -1
- metadata +49 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec8b561633fbdf9989f8bc2476fe84ad2cd0c32177990d03380d63f59a8368a9
|
|
4
|
+
data.tar.gz: a348e6b1090374bfda6281e6a58fe16af3e285bb009fad38be60a3ba2c510720
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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
|
data/guides/llm/EVENT.md
ADDED
|
@@ -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.**
|