dexkit 0.1.0 → 0.2.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: 3adbbfa21a62c17b74b7ac6807ed7a9abdca2b02c85e06bb2d542bdbf7d39677
4
+ data.tar.gz: cead77e688d4c30a7256c05f73d509fefd44c7c6b6bfc52d9b0bdbfd8675954e
5
5
  SHA512:
6
- metadata.gz: 800756f2f2f22dc4e487794fc129ccafc57ee815b3e26aea67c1f8eef9195654106cd7ab7b91525c4e5325da1696e94069ffc78e2d1302dc1f7a5d6f6eff8e82
7
- data.tar.gz: 7bf8212783439db36adb9ae04854f981d2e2e6d9db8ca7bf9cd0768faa566aec98addc56f12640b7d0c284e3a7f4ca9d8afa05ba1f90568241970214bd8307d5
6
+ metadata.gz: b58aec14261efd1c679bb662b163df64f96d86239e5efe8897d7b8d007aef4cd19864a8fabc03ee4acf139379c1185e56f8a5f6e488a1f5dec00442a24223dee
7
+ data.tar.gz: 6009bd9567cf1fd4adacf21cfb5c6627cfce8d1d399498923ae9ff8a11c3f2b31df884549a8b3bdb0c45bede9951b672c6d50dbf2199a691e45a79c37b5c1497
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-03-02
4
+
5
+ ### Added
6
+
7
+ - **Event system** — typed, immutable event value objects with publish/subscribe
8
+ - Typed properties via `prop`/`prop?` DSL (same as Operation)
9
+ - Sync and async publishing (`event.publish`, `Event.publish(sync: true)`)
10
+ - Handler DSL: `on` for subscription, `retries` with exponential/fixed/custom backoff
11
+ - Async dispatch via ActiveJob (`Dex::Event::Processor`, lazy-loaded)
12
+ - Causality tracing: `event.trace { ... }` and `caused_by:` link events into chains with shared `trace_id`
13
+ - Block-scoped suppression: `Dex::Event.suppress(SomeEvent) { ... }`
14
+ - Optional persistence via `event_store` configuration
15
+ - Context capture and restoration across async boundaries (`event_context`, `restore_event_context`)
16
+ - **Event test helpers** — `Dex::Event::TestHelpers` module
17
+ - `capture_events` block for inspecting published events without dispatching
18
+ - `assert_event_published`, `refute_event_published`, `assert_event_count`
19
+ - `assert_event_trace`, `assert_same_trace` for causality assertions
20
+
21
+ ### Changed
22
+
23
+ - Extracted shared `validate_options!` helper for DSL option validation
24
+ - Added `Dex.warn` for unified warning logging
25
+ - Added `Dex::Concern` to reduce module inclusion boilerplate
26
+ - Simplified internal method names in standalone classes (AsyncProxy, Pipeline, Jobs, Processor)
27
+
28
+ ### Fixed
29
+
30
+ - `_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)
31
+
32
+ ### Changed
33
+
34
+ - 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.
35
+ - 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.
36
+
3
37
  ## [0.1.0] - 2026-03-01
4
38
 
5
39
  - Initial public release
data/README.md CHANGED
@@ -84,6 +84,60 @@ class CreateUserTest < Minitest::Test
84
84
  end
85
85
  ```
86
86
 
87
+ ## Events
88
+
89
+ Typed, immutable event objects with publish/subscribe, async dispatch, and causality tracing.
90
+
91
+ ```ruby
92
+ class OrderPlaced < Dex::Event
93
+ prop :order_id, Integer
94
+ prop :total, BigDecimal
95
+ prop? :coupon_code, String
96
+ end
97
+
98
+ class NotifyWarehouse < Dex::Event::Handler
99
+ on OrderPlaced
100
+ retries 3
101
+
102
+ def perform
103
+ WarehouseApi.notify(event.order_id)
104
+ end
105
+ end
106
+
107
+ OrderPlaced.publish(order_id: 1, total: 99.99)
108
+ ```
109
+
110
+ ### What you get out of the box
111
+
112
+ **Zero-config pub/sub** — define events and handlers, publish. No bus setup needed.
113
+
114
+ **Async by default** — handlers dispatched via ActiveJob. `sync: true` for inline.
115
+
116
+ **Causality tracing** — link events in chains with shared `trace_id`:
117
+
118
+ ```ruby
119
+ order_placed.trace do
120
+ InventoryReserved.publish(order_id: 1)
121
+ end
122
+ ```
123
+
124
+ **Suppression**, optional **persistence**, **context capture**, and **retries** with exponential backoff.
125
+
126
+ ### Testing
127
+
128
+ ```ruby
129
+ class CreateOrderTest < Minitest::Test
130
+ include Dex::Event::TestHelpers
131
+
132
+ def test_publishes_order_placed
133
+ capture_events do
134
+ CreateOrder.call(item_id: 1)
135
+ assert_event_published(OrderPlaced, order_id: 1)
136
+ end
137
+ end
138
+ end
139
+ ```
140
+
87
141
  ## Installation
88
142
 
89
143
  ```ruby
@@ -100,7 +154,7 @@ Dexkit ships LLM-optimized guides. Copy them into your project so AI agents auto
100
154
 
101
155
  ```bash
102
156
  cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
103
- cp $(bundle show dexkit)/guides/llm/TESTING.md test/CLAUDE.md
157
+ cp $(bundle show dexkit)/guides/llm/EVENT.md app/event_handlers/CLAUDE.md
104
158
  ```
105
159
 
106
160
  ## 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.**
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module Concern
5
+ def included(base)
6
+ base.extend(self::ClassMethods) if const_defined?(:ClassMethods, false)
7
+ super
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ class Bus
6
+ @_subscribers = {}
7
+ @_mutex = Mutex.new
8
+
9
+ class << self
10
+ def subscribe(event_class, handler_class)
11
+ Event.validate_event_class!(event_class)
12
+ unless handler_class.is_a?(Class) && handler_class < Dex::Event::Handler
13
+ raise ArgumentError, "#{handler_class.inspect} is not a Dex::Event::Handler subclass"
14
+ end
15
+
16
+ @_mutex.synchronize do
17
+ @_subscribers[event_class] ||= []
18
+ @_subscribers[event_class] |= [handler_class]
19
+ end
20
+ end
21
+
22
+ def unsubscribe(event_class, handler_class)
23
+ @_mutex.synchronize do
24
+ list = @_subscribers[event_class]
25
+ return unless list
26
+
27
+ list.delete(handler_class)
28
+ @_subscribers.delete(event_class) if list.empty?
29
+ end
30
+ end
31
+
32
+ def subscribers_for(event_class)
33
+ @_mutex.synchronize do
34
+ @_subscribers.each_with_object([]) do |(subscribed_class, handlers), result|
35
+ result.concat(handlers) if event_class <= subscribed_class
36
+ end.uniq
37
+ end
38
+ end
39
+
40
+ def subscribers
41
+ @_mutex.synchronize { @_subscribers.transform_values(&:dup) }
42
+ end
43
+
44
+ def publish(event, sync:)
45
+ return if Suppression.suppressed?(event.class)
46
+
47
+ persist(event)
48
+ handlers = subscribers_for(event.class)
49
+ return if handlers.empty?
50
+
51
+ event_frame = event.trace_frame
52
+
53
+ handlers.each do |handler_class|
54
+ if sync
55
+ Trace.restore(event_frame) do
56
+ handler_class._event_handle(event)
57
+ end
58
+ else
59
+ enqueue(handler_class, event, event_frame)
60
+ end
61
+ end
62
+ end
63
+
64
+ def clear!
65
+ @_mutex.synchronize { @_subscribers.clear }
66
+ end
67
+
68
+ private
69
+
70
+ def persist(event)
71
+ store = Dex.configuration.event_store
72
+ return unless store
73
+
74
+ store.create!(
75
+ event_type: event.class.name,
76
+ payload: event._props_as_json,
77
+ metadata: event.metadata.as_json
78
+ )
79
+ rescue => e
80
+ Event._warn("Failed to persist event: #{e.message}")
81
+ end
82
+
83
+ def enqueue(handler_class, event, trace_data)
84
+ ctx = event.context
85
+
86
+ Dex::Event::Processor.perform_later(
87
+ handler_class: handler_class.name,
88
+ event_class: event.class.name,
89
+ payload: event._props_as_json,
90
+ metadata: event.metadata.as_json,
91
+ trace: trace_data,
92
+ context: ctx
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ module ExecutionState
6
+ private
7
+
8
+ def _execution_state
9
+ if defined?(ActiveSupport::IsolatedExecutionState)
10
+ ActiveSupport::IsolatedExecutionState
11
+ else
12
+ Thread.current
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ class Handler
6
+ attr_reader :event
7
+
8
+ def self.on(*event_classes)
9
+ event_classes.each do |ec|
10
+ Event.validate_event_class!(ec)
11
+ Bus.subscribe(ec, self)
12
+ end
13
+ end
14
+
15
+ def self.retries(count, **opts)
16
+ raise ArgumentError, "retries count must be a positive Integer" unless count.is_a?(Integer) && count > 0
17
+
18
+ if opts.key?(:wait)
19
+ wait = opts[:wait]
20
+ unless wait.is_a?(Numeric) || wait.is_a?(Proc)
21
+ raise ArgumentError, "wait: must be Numeric or Proc"
22
+ end
23
+ end
24
+
25
+ @_event_handler_retries = { count: count, **opts }
26
+ end
27
+
28
+ def self._event_handler_retry_config
29
+ if defined?(@_event_handler_retries)
30
+ @_event_handler_retries
31
+ elsif superclass.respond_to?(:_event_handler_retry_config)
32
+ superclass._event_handler_retry_config
33
+ end
34
+ end
35
+
36
+ def self._event_handle(event)
37
+ instance = new
38
+ instance.instance_variable_set(:@event, event)
39
+ instance.perform
40
+ end
41
+
42
+ def self._event_handle_from_payload(event_class_name, payload, metadata_hash)
43
+ event_class = Object.const_get(event_class_name)
44
+ event = _event_reconstruct(event_class, payload, metadata_hash)
45
+ _event_handle(event)
46
+ end
47
+
48
+ class << self
49
+ private
50
+
51
+ def _event_reconstruct(event_class, payload, metadata_hash)
52
+ coerced = event_class.send(:_coerce_serialized_hash, payload)
53
+ instance = event_class.allocate
54
+
55
+ event_class.literal_properties.each do |prop|
56
+ instance.instance_variable_set(:"@#{prop.name}", coerced[prop.name])
57
+ end
58
+
59
+ metadata = Event::Metadata.new(
60
+ id: metadata_hash["id"],
61
+ timestamp: Time.parse(metadata_hash["timestamp"]),
62
+ trace_id: metadata_hash["trace_id"],
63
+ caused_by_id: metadata_hash["caused_by_id"],
64
+ context: metadata_hash["context"]
65
+ )
66
+ instance.instance_variable_set(:@metadata, metadata)
67
+ instance.freeze
68
+ instance
69
+ end
70
+ end
71
+
72
+ def perform
73
+ raise NotImplementedError, "#{self.class.name} must implement #perform"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Dex
6
+ class Event
7
+ class Metadata
8
+ attr_reader :id, :timestamp, :trace_id, :caused_by_id, :context
9
+
10
+ def initialize(id:, timestamp:, trace_id:, caused_by_id:, context:)
11
+ @id = id
12
+ @timestamp = timestamp
13
+ @trace_id = trace_id
14
+ @caused_by_id = caused_by_id
15
+ @context = context
16
+ freeze
17
+ end
18
+
19
+ def self.build(caused_by_id: nil)
20
+ id = SecureRandom.uuid
21
+ trace_id = Trace.current_trace_id || id
22
+ caused = caused_by_id || Trace.current_event_id
23
+
24
+ ctx = if Dex.configuration.event_context
25
+ begin
26
+ Dex.configuration.event_context.call
27
+ rescue => e
28
+ Event._warn("event_context failed: #{e.message}")
29
+ nil
30
+ end
31
+ end
32
+
33
+ new(
34
+ id: id,
35
+ timestamp: Time.now.utc,
36
+ trace_id: trace_id,
37
+ caused_by_id: caused,
38
+ context: ctx
39
+ )
40
+ end
41
+
42
+ def as_json
43
+ h = {
44
+ "id" => @id,
45
+ "timestamp" => @timestamp.iso8601(6),
46
+ "trace_id" => @trace_id
47
+ }
48
+ h["caused_by_id"] = @caused_by_id if @caused_by_id
49
+ h["context"] = @context if @context
50
+ h
51
+ end
52
+ end
53
+ end
54
+ end