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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -1
- data/README.md +63 -254
- data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
- data/guides/llm/EVENT.md +25 -26
- data/guides/llm/FORM.md +200 -59
- data/guides/llm/OPERATION.md +115 -57
- data/guides/llm/QUERY.md +56 -0
- data/guides/llm/TOOL.md +308 -0
- data/lib/dex/context_dsl.rb +56 -0
- data/lib/dex/context_setup.rb +2 -33
- data/lib/dex/event/bus.rb +79 -11
- data/lib/dex/event/handler.rb +18 -1
- data/lib/dex/event/metadata.rb +15 -20
- data/lib/dex/event/processor.rb +2 -16
- data/lib/dex/event/test_helpers.rb +1 -1
- data/lib/dex/event.rb +3 -10
- data/lib/dex/form/context.rb +27 -0
- data/lib/dex/form/export.rb +128 -0
- data/lib/dex/form/nesting.rb +2 -0
- data/lib/dex/form.rb +119 -3
- data/lib/dex/id.rb +125 -0
- data/lib/dex/operation/async_proxy.rb +22 -4
- data/lib/dex/operation/guard_wrapper.rb +1 -1
- data/lib/dex/operation/jobs.rb +5 -4
- data/lib/dex/operation/once_wrapper.rb +1 -0
- data/lib/dex/operation/outcome.rb +14 -0
- data/lib/dex/operation/record_backend.rb +2 -1
- data/lib/dex/operation/record_wrapper.rb +14 -4
- data/lib/dex/operation/result_wrapper.rb +0 -12
- data/lib/dex/operation/test_helpers/assertions.rb +0 -88
- data/lib/dex/operation/test_helpers.rb +11 -1
- data/lib/dex/operation/ticket.rb +268 -0
- data/lib/dex/operation/trace_wrapper.rb +20 -0
- data/lib/dex/operation.rb +3 -0
- data/lib/dex/operation_failed.rb +14 -0
- data/lib/dex/query/export.rb +64 -0
- data/lib/dex/query.rb +41 -0
- data/lib/dex/test_log.rb +62 -4
- data/lib/dex/timeout.rb +14 -0
- data/lib/dex/tool.rb +388 -5
- data/lib/dex/trace.rb +291 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +22 -3
- metadata +12 -3
- data/lib/dex/event/trace.rb +0 -56
- 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`.
|
data/guides/llm/TOOL.md
ADDED
|
@@ -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
|
data/lib/dex/context_setup.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Dex
|
|
4
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
data/lib/dex/event/handler.rb
CHANGED
|
@@ -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
|
-
|
|
116
|
+
event_ancestry: metadata_hash["event_ancestry"] || []
|
|
100
117
|
)
|
|
101
118
|
instance.instance_variable_set(:@metadata, metadata)
|
|
102
119
|
instance.freeze
|