dexkit 0.9.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +63 -254
  4. data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
  5. data/guides/llm/EVENT.md +25 -26
  6. data/guides/llm/FORM.md +200 -59
  7. data/guides/llm/OPERATION.md +115 -57
  8. data/guides/llm/QUERY.md +56 -0
  9. data/guides/llm/TOOL.md +308 -0
  10. data/lib/dex/context_dsl.rb +56 -0
  11. data/lib/dex/context_setup.rb +2 -33
  12. data/lib/dex/event/bus.rb +79 -11
  13. data/lib/dex/event/handler.rb +18 -1
  14. data/lib/dex/event/metadata.rb +15 -20
  15. data/lib/dex/event/processor.rb +2 -16
  16. data/lib/dex/event/test_helpers.rb +1 -1
  17. data/lib/dex/event.rb +3 -10
  18. data/lib/dex/form/context.rb +27 -0
  19. data/lib/dex/form/export.rb +128 -0
  20. data/lib/dex/form/nesting.rb +2 -0
  21. data/lib/dex/form.rb +119 -3
  22. data/lib/dex/id.rb +125 -0
  23. data/lib/dex/operation/async_proxy.rb +22 -4
  24. data/lib/dex/operation/guard_wrapper.rb +1 -1
  25. data/lib/dex/operation/jobs.rb +5 -4
  26. data/lib/dex/operation/once_wrapper.rb +1 -0
  27. data/lib/dex/operation/outcome.rb +14 -0
  28. data/lib/dex/operation/record_backend.rb +2 -1
  29. data/lib/dex/operation/record_wrapper.rb +14 -4
  30. data/lib/dex/operation/result_wrapper.rb +0 -12
  31. data/lib/dex/operation/test_helpers/assertions.rb +0 -88
  32. data/lib/dex/operation/test_helpers.rb +11 -1
  33. data/lib/dex/operation/ticket.rb +268 -0
  34. data/lib/dex/operation/trace_wrapper.rb +20 -0
  35. data/lib/dex/operation.rb +3 -0
  36. data/lib/dex/operation_failed.rb +14 -0
  37. data/lib/dex/query/export.rb +64 -0
  38. data/lib/dex/query.rb +41 -0
  39. data/lib/dex/test_log.rb +62 -4
  40. data/lib/dex/timeout.rb +14 -0
  41. data/lib/dex/tool.rb +388 -5
  42. data/lib/dex/trace.rb +291 -0
  43. data/lib/dex/version.rb +1 -1
  44. data/lib/dexkit.rb +22 -3
  45. metadata +12 -3
  46. data/lib/dex/event/trace.rb +0 -56
  47. data/lib/dex/event_test_helpers.rb +0 -3
data/guides/llm/QUERY.md CHANGED
@@ -10,12 +10,17 @@ All examples below build on this query unless noted otherwise:
10
10
 
11
11
  ```ruby
12
12
  class UserSearch < Dex::Query
13
+ description "Search and filter users"
14
+
13
15
  scope { User.all }
14
16
 
15
17
  prop? :name, String
16
18
  prop? :role, _Array(String)
17
19
  prop? :age_min, Integer
18
20
  prop? :status, String
21
+ prop? :tenant, String
22
+
23
+ context tenant: :current_tenant
19
24
 
20
25
  filter :name, :contains
21
26
  filter :role, :in
@@ -71,6 +76,51 @@ prop? :age_min, Integer # optional integer
71
76
 
72
77
  Reserved prop names: `scope`, `sort`, `resolve`, `call`, `from_params`, `to_params`, `param_key`.
73
78
 
79
+ ### Description
80
+
81
+ ```ruby
82
+ class UserSearch < Dex::Query
83
+ description "Search and filter users"
84
+ end
85
+ ```
86
+
87
+ ### Context
88
+
89
+ Same `context` DSL as Operation and Event. Auto-fills props from `Dex.with_context`:
90
+
91
+ ```ruby
92
+ class UserSearch < Dex::Query
93
+ prop? :tenant, String
94
+ context tenant: :current_tenant
95
+ end
96
+
97
+ # In a controller around_action:
98
+ Dex.with_context(current_tenant: current_tenant) do
99
+ UserSearch.call(name: "ali") # tenant auto-filled
100
+ end
101
+ ```
102
+
103
+ Identity shorthand: `context :locale` maps prop `:locale` to context key `:locale`.
104
+
105
+ Explicit values always win over ambient context. Inheritance merges parent + child mappings.
106
+
107
+ ---
108
+
109
+ ## Registry & Export
110
+
111
+ Queries extend `Registry` — same API as Operation, Event, and Form:
112
+
113
+ ```ruby
114
+ Dex::Query.registry # => Set of all named Query subclasses
115
+ UserSearch.description # => "Search and filter users"
116
+
117
+ UserSearch.to_h # => { name:, description:, props:, filters:, sorts:, context: }
118
+ UserSearch.to_json_schema # => JSON Schema (Draft 2020-12)
119
+
120
+ Dex::Query.export(format: :hash) # => sorted array of all query to_h
121
+ Dex::Query.export(format: :json_schema) # => sorted array of all query to_json_schema
122
+ ```
123
+
74
124
  ---
75
125
 
76
126
  ## Filters
@@ -346,3 +396,9 @@ class UserSearchTest < Minitest::Test
346
396
  end
347
397
  end
348
398
  ```
399
+
400
+ ---
401
+
402
+ ## LLM Tools
403
+
404
+ To expose a query as an LLM-callable tool, see `TOOL.md`.
@@ -0,0 +1,308 @@
1
+ # Dex::Tool — LLM Reference
2
+
3
+ Install with `rake dex:guides` or copy manually to `AGENTS.md`.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ `Dex::Tool` bridges Dex primitives to LLM tool calling via ruby-llm. It accepts Operation or Query classes and returns `RubyLLM::Tool` instances ready for `chat.with_tools(...)`.
10
+
11
+ Requires `gem "ruby_llm"` in your Gemfile. Lazy-loaded — ruby-llm is only required when you call `Dex::Tool`.
12
+
13
+ ---
14
+
15
+ ## Operation Tools
16
+
17
+ ### Creating
18
+
19
+ ```ruby
20
+ tool = Dex::Tool.from(Orders::Place) # single operation
21
+ tools = Dex::Tool.all # all registered operations
22
+ tools = Dex::Tool.from_namespace("Orders") # operations under Orders::
23
+ ```
24
+
25
+ `from` accepts no options for Operation classes. `all` and `from_namespace` return sorted arrays.
26
+
27
+ ### Tool Schema
28
+
29
+ The tool name is derived from the class name: `Orders::Place` becomes `dex_orders_place`.
30
+
31
+ The description includes:
32
+ - The operation's `description` (or class name if none)
33
+ - Guard preconditions (if any)
34
+ - Declared error codes (if any)
35
+
36
+ The params schema is the operation's `contract.to_json_schema`.
37
+
38
+ ### Execution Flow
39
+
40
+ 1. Params are symbolized
41
+ 2. Operation is instantiated with params
42
+ 3. Called via `.safe.call` (returns `Ok` or `Err`, never raises)
43
+ 4. `Ok` — returns `value.as_json` (or raw value if no `as_json`)
44
+ 5. `Err` — returns `{ error:, message:, details: }`
45
+
46
+ ### Example
47
+
48
+ ```ruby
49
+ class Orders::Place < Dex::Operation
50
+ description "Place a new order for a customer"
51
+
52
+ prop :customer_id, _Ref(Customer)
53
+ prop :product_id, _Ref(Product)
54
+ prop? :quantity, Integer, default: 1
55
+
56
+ error :out_of_stock, :invalid_quantity
57
+
58
+ guard :sufficient_stock, "Product must be in stock" do
59
+ Product.find(product_id).stock >= quantity
60
+ end
61
+
62
+ def perform
63
+ error!(:invalid_quantity) if quantity <= 0
64
+ Order.create!(customer_id: customer_id, product_id: product_id, quantity: quantity)
65
+ end
66
+ end
67
+
68
+ tool = Dex::Tool.from(Orders::Place)
69
+ chat = RubyLLM.chat.with_tools(tool)
70
+ chat.ask("Place an order for customer #12, product #42, quantity 3")
71
+ ```
72
+
73
+ ### Error Shape
74
+
75
+ When an operation returns `Err`, the tool returns:
76
+
77
+ ```ruby
78
+ { error: :out_of_stock, message: "out_of_stock", details: nil }
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Explain Tool
84
+
85
+ A meta-tool that checks whether an operation can execute with given params, without running it:
86
+
87
+ ```ruby
88
+ tool = Dex::Tool.explain_tool
89
+ chat = RubyLLM.chat.with_tools(tool)
90
+ chat.ask("Can I place an order for product #42?")
91
+ ```
92
+
93
+ The LLM calls it with `{ operation: "Orders::Place", params: { product_id: 42 } }`. Returns:
94
+
95
+ ```ruby
96
+ { callable: true, guards: [{ name: :sufficient_stock, passed: true }], once: nil, lock: nil }
97
+ ```
98
+
99
+ If the operation is not in the registry: `{ error: "unknown_operation", message: "..." }`.
100
+
101
+ ---
102
+
103
+ ## Query Tools
104
+
105
+ ### Creating
106
+
107
+ ```ruby
108
+ tool = Dex::Tool.from(Product::Query,
109
+ scope: -> { Current.user.products },
110
+ serialize: ->(record) { record.as_json(only: %i[id name price stock]) })
111
+ ```
112
+
113
+ ### Required Options
114
+
115
+ Both `scope:` and `serialize:` are mandatory for Query tools.
116
+
117
+ **`scope:`** — a lambda returning the base relation. Called at execution time:
118
+
119
+ ```ruby
120
+ Dex::Tool.from(Order::Query,
121
+ scope: -> { Current.user.orders },
122
+ serialize: ->(r) { r.as_json })
123
+
124
+ Dex::Tool.from(Product::Query,
125
+ scope: -> { Product.where(active: true) },
126
+ serialize: ->(r) { r.as_json })
127
+ ```
128
+
129
+ **`serialize:`** — a lambda converting each record to a hash:
130
+
131
+ ```ruby
132
+ Dex::Tool.from(Product::Query,
133
+ scope: -> { Product.all },
134
+ serialize: ->(r) { r.as_json(only: %i[id name price]) })
135
+
136
+ Dex::Tool.from(Order::Query,
137
+ scope: -> { Current.user.orders },
138
+ serialize: ->(r) { { id: r.id, total: r.total, status: r.status } })
139
+ ```
140
+
141
+ ### Optional Restrictions
142
+
143
+ **`limit:`** — max results per page (default: 50). The LLM can request fewer but never more:
144
+
145
+ ```ruby
146
+ Dex::Tool.from(Product::Query, scope: -> { Product.all }, serialize: ->(r) { r.as_json }, limit: 25)
147
+ ```
148
+
149
+ **`only_filters:`** — allowlist of filters exposed to the LLM:
150
+
151
+ ```ruby
152
+ Dex::Tool.from(Product::Query,
153
+ scope: -> { Product.all },
154
+ serialize: ->(r) { r.as_json },
155
+ only_filters: %i[name category])
156
+ ```
157
+
158
+ **`except_filters:`** — denylist of filters hidden from the LLM (mutually exclusive with `only_filters:`):
159
+
160
+ ```ruby
161
+ Dex::Tool.from(Product::Query,
162
+ scope: -> { Product.all },
163
+ serialize: ->(r) { r.as_json },
164
+ except_filters: %i[internal_code])
165
+ ```
166
+
167
+ **`only_sorts:`** — allowlist of sort columns. Must include the query's default sort if one exists:
168
+
169
+ ```ruby
170
+ Dex::Tool.from(Product::Query,
171
+ scope: -> { Product.all },
172
+ serialize: ->(r) { r.as_json },
173
+ only_sorts: %i[name price created_at])
174
+ ```
175
+
176
+ ### Auto-Exclusions
177
+
178
+ These props are automatically excluded from the tool schema (the LLM never sees them):
179
+
180
+ - Props mapped via `context` (filled from ambient context)
181
+ - `_Ref` typed props (model references)
182
+ - Props for hidden filters (via `only_filters:` / `except_filters:`)
183
+
184
+ If an auto-excluded prop is required with no default and no context mapping, `from` raises `ArgumentError` at build time.
185
+
186
+ ### Tool Schema
187
+
188
+ The tool name is `dex_query_{class_name}` (lowercased, `::` replaced with `_`).
189
+
190
+ The description includes:
191
+ - The query's `description` (or class name)
192
+ - Available filters with type hints and enum values
193
+ - Available sorts with default indicator
194
+ - Max results per page
195
+
196
+ The params schema includes visible filter props plus:
197
+
198
+ - `sort` — enum of allowed sort values (prefix with `-` for descending; custom sorts have no `-` variant)
199
+ - `limit` — integer, max results
200
+ - `offset` — integer, skip N results (for pagination)
201
+
202
+ ### Execution Flow
203
+
204
+ 1. Extract `limit`, `offset`, `sort` from params
205
+ 2. Clamp `limit` to max (default 50); zero or negative resets to max
206
+ 3. Floor `offset` at 0
207
+ 4. Validate sort value against allowed sorts; drop invalid (falls back to query default)
208
+ 5. Custom sorts reject `-` prefix (direction is baked into the block)
209
+ 6. Strip context-mapped and excluded filter params
210
+ 7. Inject scope from `scope:` lambda
211
+ 8. Build query via `from_params` (coercion, blank stripping, validation)
212
+ 9. Resolve, count total, apply offset/limit
213
+ 10. Serialize each record via `serialize:` lambda
214
+
215
+ ### Return Shape
216
+
217
+ ```json
218
+ {
219
+ "records": [{ "id": 1, "name": "Widget", "price": 9.99 }],
220
+ "total": 142,
221
+ "limit": 50,
222
+ "offset": 0
223
+ }
224
+ ```
225
+
226
+ `total` is `nil` if the count query fails (e.g., complex GROUP BY).
227
+
228
+ ### Error Handling
229
+
230
+ Invalid params or type errors:
231
+
232
+ ```ruby
233
+ { error: "invalid_params", message: "..." }
234
+ ```
235
+
236
+ Any other error:
237
+
238
+ ```ruby
239
+ { error: "query_failed", message: "..." }
240
+ ```
241
+
242
+ ---
243
+
244
+ ## Context
245
+
246
+ `Dex.with_context` provides ambient values to both Operation and Query tools. Props with `context` mappings are auto-filled and hidden from the LLM:
247
+
248
+ ```ruby
249
+ class Orders::Place < Dex::Operation
250
+ prop :customer_id, _Ref(Customer)
251
+ context customer_id: :current_customer_id
252
+ # ...
253
+ end
254
+
255
+ class Order::Query < Dex::Query
256
+ scope { Order.all }
257
+ prop? :customer_id, _Ref(Customer)
258
+ context customer_id: :current_customer_id
259
+ filter :customer_id
260
+ # ...
261
+ end
262
+
263
+ Dex.with_context(current_customer_id: current_user.customer_id) do
264
+ chat.ask("Show me my recent orders")
265
+ end
266
+ ```
267
+
268
+ The LLM never sees `customer_id` in either tool's schema — it is injected from context.
269
+
270
+ ---
271
+
272
+ ## Security Model
273
+
274
+ Five layers protect against misuse:
275
+
276
+ 1. **Scope lambda** — called at execution time, applies authorization (`Current.user.orders`, `policy_scope(...)`)
277
+ 2. **Context injection** — security-sensitive props (tenant, user) are filled from ambient context, invisible to the LLM
278
+ 3. **Filter restrictions** — `only_filters:` / `except_filters:` control what the LLM can search on
279
+ 4. **Sort restrictions** — `only_sorts:` limits available sort columns
280
+ 5. **Limit cap** — `limit:` sets a hard ceiling on results per page; the LLM cannot exceed it
281
+
282
+ ---
283
+
284
+ ## Combining Operation + Query Tools
285
+
286
+ ```ruby
287
+ order_tools = Dex::Tool.from_namespace("Orders")
288
+
289
+ search_tool = Dex::Tool.from(Product::Query,
290
+ scope: -> { Current.user.products },
291
+ serialize: ->(r) { r.as_json(only: %i[id name price stock]) },
292
+ limit: 20,
293
+ only_filters: %i[name category],
294
+ only_sorts: %i[name price])
295
+
296
+ explain = Dex::Tool.explain_tool
297
+
298
+ chat = RubyLLM.chat
299
+ chat.with_tools(*order_tools, search_tool, explain)
300
+
301
+ Dex.with_context(current_customer_id: current_user.customer_id) do
302
+ chat.ask("Find products under $50, then place an order for the cheapest one")
303
+ end
304
+ ```
305
+
306
+ ---
307
+
308
+ **End of reference.**
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ # Shared context DSL extracted from ContextSetup.
5
+ #
6
+ # Provides the `context` class method, `context_mappings` inheritance,
7
+ # and `_context_own` storage. Includers must implement:
8
+ # _context_prop_declared?(name) → true/false
9
+ # and may override:
10
+ # _context_field_label → "prop" | "field" (used in error messages)
11
+ module ContextDSL
12
+ def context(*names, **mappings)
13
+ names.each do |name|
14
+ unless name.is_a?(Symbol)
15
+ raise ArgumentError, "context shorthand must be a Symbol, got: #{name.inspect}"
16
+ end
17
+ mappings[name] = name
18
+ end
19
+
20
+ raise ArgumentError, "context requires at least one mapping" if mappings.empty?
21
+
22
+ label = _context_field_label
23
+ mappings.each do |prop_name, context_key|
24
+ unless _context_prop_declared?(prop_name)
25
+ raise ArgumentError,
26
+ "context references undeclared #{label} :#{prop_name}. Declare the #{label} before calling context."
27
+ end
28
+ unless context_key.is_a?(Symbol)
29
+ raise ArgumentError,
30
+ "context key must be a Symbol, got: #{context_key.inspect} for #{label} :#{prop_name}"
31
+ end
32
+ end
33
+
34
+ _context_own.merge!(mappings)
35
+ end
36
+
37
+ def context_mappings
38
+ parent = superclass.respond_to?(:context_mappings) ? superclass.context_mappings : {}
39
+ parent.merge(_context_own)
40
+ end
41
+
42
+ private
43
+
44
+ def _context_own
45
+ @_context_own_mappings ||= {}
46
+ end
47
+
48
+ def _context_prop_declared?(_name)
49
+ raise NotImplementedError, "#{self} must implement _context_prop_declared?"
50
+ end
51
+
52
+ def _context_field_label
53
+ "prop"
54
+ end
55
+ end
56
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dex
4
- # Shared context DSL for Operation and Event.
4
+ # Context DSL for Operation and Event (Literal::Properties-backed).
5
5
  #
6
6
  # Maps declared props to ambient context keys so they can be auto-filled
7
7
  # from Dex.context when not passed explicitly as kwargs.
@@ -9,34 +9,7 @@ module Dex
9
9
  extend Dex::Concern
10
10
 
11
11
  module ClassMethods
12
- def context(*names, **mappings)
13
- names.each do |name|
14
- unless name.is_a?(Symbol)
15
- raise ArgumentError, "context shorthand must be a Symbol, got: #{name.inspect}"
16
- end
17
- mappings[name] = name
18
- end
19
-
20
- raise ArgumentError, "context requires at least one mapping" if mappings.empty?
21
-
22
- mappings.each do |prop_name, context_key|
23
- unless _context_prop_declared?(prop_name)
24
- raise ArgumentError,
25
- "context references undeclared prop :#{prop_name}. Declare the prop before calling context."
26
- end
27
- unless context_key.is_a?(Symbol)
28
- raise ArgumentError,
29
- "context key must be a Symbol, got: #{context_key.inspect} for prop :#{prop_name}"
30
- end
31
- end
32
-
33
- _context_own.merge!(mappings)
34
- end
35
-
36
- def context_mappings
37
- parent = superclass.respond_to?(:context_mappings) ? superclass.context_mappings : {}
38
- parent.merge(_context_own)
39
- end
12
+ include ContextDSL
40
13
 
41
14
  def new(**kwargs)
42
15
  mappings = context_mappings
@@ -52,10 +25,6 @@ module Dex
52
25
 
53
26
  private
54
27
 
55
- def _context_own
56
- @_context_own_mappings ||= {}
57
- end
58
-
59
28
  def _context_prop_declared?(name)
60
29
  respond_to?(:literal_properties) && literal_properties.any? { |p| p.name == name }
61
30
  end
data/lib/dex/event/bus.rb CHANGED
@@ -44,19 +44,18 @@ module Dex
44
44
  def publish(event, sync:)
45
45
  return if Suppression.suppressed?(event.class)
46
46
 
47
- persist(event)
47
+ trace_data = trace_data_for(event)
48
+ persist(event, trace_data)
48
49
  handlers = subscribers_for(event.class)
49
50
  return if handlers.empty?
50
51
 
51
- event_frame = event.trace_frame
52
-
53
52
  handlers.each do |handler_class|
54
53
  if sync
55
- Trace.restore(event_frame) do
54
+ Dex::Trace.restore(trace_data) do
56
55
  handler_class._event_handle(event)
57
56
  end
58
57
  else
59
- enqueue(handler_class, event, event_frame)
58
+ enqueue(handler_class, event, trace_data)
60
59
  end
61
60
  end
62
61
  end
@@ -67,33 +66,102 @@ module Dex
67
66
 
68
67
  private
69
68
 
70
- def persist(event)
69
+ def persist(event, trace_data)
71
70
  store = Dex.configuration.event_store
72
71
  return unless store
73
72
 
74
- store.create!(
73
+ actor = actor_from_trace(trace_data[:frames])
74
+ attrs = safe_store_attributes(store, {
75
+ id: event.id,
76
+ trace_id: event.trace_id,
77
+ actor_type: actor&.dig(:actor_type),
78
+ actor_id: actor&.dig(:id),
79
+ trace: trace_data[:frames],
75
80
  event_type: event.class.name,
76
81
  payload: event._props_as_json,
77
82
  metadata: event.metadata.as_json
78
- )
83
+ })
84
+
85
+ store.create!(**attrs)
79
86
  rescue => e
80
87
  Event._warn("Failed to persist event: #{e.message}")
81
88
  end
82
89
 
83
90
  def enqueue(handler_class, event, trace_data)
84
91
  ensure_active_job_loaded!
85
- ctx = event.context
86
92
 
87
93
  Dex::Event::Processor.perform_later(
88
94
  handler_class: handler_class.name,
89
95
  event_class: event.class.name,
90
96
  payload: event._props_as_json,
91
97
  metadata: event.metadata.as_json,
92
- trace: trace_data,
93
- context: ctx
98
+ trace: trace_data
94
99
  )
95
100
  end
96
101
 
102
+ def trace_data_for(event)
103
+ ambient = Dex::Trace.dump
104
+ frames = if ambient && trace_matches_event?(ambient, event)
105
+ trace_frames(ambient)
106
+ elsif ambient
107
+ actor_frames(trace_frames(ambient))
108
+ else
109
+ []
110
+ end
111
+
112
+ {
113
+ trace_id: event.trace_id,
114
+ frames: frames,
115
+ event_context: event_context_for(event)
116
+ }
117
+ end
118
+
119
+ def actor_from_trace(frames)
120
+ Array(frames).find do |frame|
121
+ frame_type = frame[:type] || frame["type"]
122
+ frame_type && frame_type.to_sym == :actor
123
+ end
124
+ end
125
+
126
+ def actor_frames(frames)
127
+ Array(frames).select do |frame|
128
+ frame_type = frame[:type] || frame["type"]
129
+ frame_type && frame_type.to_sym == :actor
130
+ end
131
+ end
132
+
133
+ def trace_frames(trace_data)
134
+ Array(trace_data[:frames] || trace_data["frames"])
135
+ end
136
+
137
+ def trace_matches_event?(trace_data, event)
138
+ trace_id = trace_data[:trace_id] || trace_data["trace_id"]
139
+ trace_id.to_s == event.trace_id.to_s
140
+ end
141
+
142
+ def event_context_for(event)
143
+ {
144
+ id: event.id,
145
+ trace_id: event.trace_id,
146
+ event_class: event.class.name,
147
+ event_ancestry: event.event_ancestry
148
+ }
149
+ end
150
+
151
+ def safe_store_attributes(store, attributes)
152
+ if store.respond_to?(:column_names)
153
+ allowed = store.column_names.to_set
154
+ attributes.select { |key, _| allowed.include?(key.to_s) }
155
+ elsif store.respond_to?(:fields)
156
+ attributes.select do |key, _|
157
+ field_name = key.to_s
158
+ store.fields.key?(field_name) || (field_name == "id" && store.fields.key?("_id"))
159
+ end
160
+ else
161
+ attributes
162
+ end
163
+ end
164
+
97
165
  def ensure_active_job_loaded!
98
166
  return if defined?(ActiveJob::Base)
99
167
 
@@ -69,9 +69,26 @@ module Dex
69
69
  end
70
70
 
71
71
  def self._event_handle(event)
72
+ execution_id = Dex::Id.generate("hd_")
73
+ auto_started = Dex::Trace.ensure_started!(trace_id: event.trace_id)
74
+ pushed = false
75
+ Dex::Trace.push(
76
+ type: :handler,
77
+ id: execution_id,
78
+ class: name,
79
+ event_class: event.class.name,
80
+ event_id: event.id,
81
+ event_ancestry: event.metadata.event_ancestry
82
+ )
83
+ pushed = true
84
+
72
85
  instance = new
73
86
  instance.instance_variable_set(:@event, event)
87
+ instance.instance_variable_set(:@_dex_execution_id, execution_id)
74
88
  instance.send(:call)
89
+ ensure
90
+ Dex::Trace.pop if pushed
91
+ Dex::Trace.stop! if auto_started
75
92
  end
76
93
 
77
94
  def self._event_handle_from_payload(event_class_name, payload, metadata_hash)
@@ -96,7 +113,7 @@ module Dex
96
113
  timestamp: Time.parse(metadata_hash["timestamp"]),
97
114
  trace_id: metadata_hash["trace_id"],
98
115
  caused_by_id: metadata_hash["caused_by_id"],
99
- context: metadata_hash["context"]
116
+ event_ancestry: metadata_hash["event_ancestry"] || []
100
117
  )
101
118
  instance.instance_variable_set(:@metadata, metadata)
102
119
  instance.freeze