dexkit 0.7.0 → 0.8.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 +31 -0
- data/README.md +34 -5
- data/guides/llm/EVENT.md +43 -1
- data/guides/llm/FORM.md +1 -1
- data/guides/llm/OPERATION.md +106 -2
- data/guides/llm/QUERY.md +1 -1
- data/lib/dex/event/export.rb +56 -0
- data/lib/dex/event/handler.rb +33 -0
- data/lib/dex/event.rb +27 -0
- data/lib/dex/operation/explain.rb +204 -0
- data/lib/dex/operation/export.rb +144 -0
- data/lib/dex/operation/guard_wrapper.rb +15 -4
- data/lib/dex/operation/record_backend.rb +12 -0
- data/lib/dex/operation.rb +46 -2
- data/lib/dex/props_setup.rb +25 -2
- data/lib/dex/railtie.rb +84 -0
- data/lib/dex/registry.rb +63 -0
- data/lib/dex/tool.rb +115 -0
- data/lib/dex/type_serializer.rb +132 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +5 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1009624f8d508e4d6b61d76c8d54f32fc04a11f445163915c027ebc1bdd30b5e
|
|
4
|
+
data.tar.gz: 1b5a0755af0be468d67c3c5447f1322deff584364a493fb7665687d1417189b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '08552d04b1e9ddf5991f1454f9491fcabe80047d9606f0432be411f09d7b632a797435fabf55d8e9b190ddbc1a70daa5e73c19aa08c2bde88540559c3d35275e'
|
|
7
|
+
data.tar.gz: 33d58dd5b421a1e7f41d3ab133c1800787d2d1f6c22327e7db7faf9053222d62d087c4a563105fb39075fbefc1a73f04984b45511595a0e3976eda9833de0cdf
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.8.0] - 2026-03-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Registry** — `Dex::Operation.registry`, `Dex::Event.registry`, and `Dex::Event::Handler.registry` return frozen Sets of all named subclasses. Populated automatically via `inherited`; anonymous and stale (unreachable after code reload) classes are excluded. `deregister(klass)` removes entries. `clear!` empties the registry. Zeitwerk-compatible — registries reflect loaded classes; eager-load to get the full list
|
|
8
|
+
- **Description & prop descriptions** — `description "text"` class-level DSL for operations and events. `desc:` keyword on `prop`/`prop?` for per-property descriptions (validated as String). Both appear in `contract.to_h`, `to_json_schema`, and `explain` output. Optional — no error or warning when omitted
|
|
9
|
+
- **`contract.to_h` export** — serializes the full operation contract to a plain Ruby Hash: `name`, `description`, `params` (with typed strings and `desc`), `success`, `errors`, `guards`, `context`, `pipeline`, `settings`. Types are human-readable strings (`"String"`, `"Integer(1..)"`, `"Ref(Product)"`, `"Nilable(String)"`). Omits nil/empty fields
|
|
10
|
+
- **`contract.to_json_schema` export** — generates JSON Schema (Draft 2020-12) from the operation contract. Default section is `:params` (input schema for LLM tools, form generation, API validation). Also supports `:success`, `:errors`, and `:full` sections
|
|
11
|
+
- **Event export** — `Event.to_h` and `Event.to_json_schema` class methods for serializing event definitions. Same type serialization as operations
|
|
12
|
+
- **Handler export** — `Handler.to_h` returns name, events (array), retries, transaction, and pipeline metadata. `handled_events` returns all subscribed event classes
|
|
13
|
+
- **Bulk export** — `Dex::Operation.export(format: :hash|:json_schema)`, `Dex::Event.export(format: :hash|:json_schema)`, `Dex::Event::Handler.export(format: :hash)`. Returns arrays sorted by name — directly serializable with `JSON.generate`
|
|
14
|
+
- **`Dex::Tool` — ruby-llm integration** — bridges dexkit operations to [ruby-llm](https://rubyllm.com/) tools. `Dex::Tool.from(Op)` generates a `RubyLLM::Tool` from an operation's contract. `Dex::Tool.all` converts all registered operations. `Dex::Tool.from_namespace("Order")` filters by namespace. `Dex::Tool.explain_tool` provides a built-in preflight check tool. Lazy-loaded — ruby-llm is only required when you call `Dex::Tool`
|
|
15
|
+
- **`Dex::TypeSerializer`** — converts Literal types to human-readable strings and JSON Schema. Handles `String`, `Integer`, `Float`, `Boolean`, `Symbol`, `Hash`, `Date`, `Time`, `DateTime`, `BigDecimal`, `_Nilable`, `_Array`, `_Union`, `_Ref`, and range-constrained types (`_Integer(1..)`)
|
|
16
|
+
- **Rake task `dex:export`** — `rake dex:export` with `FORMAT=hash|json_schema`, `SECTION=operations|events|handlers`, `FILE=path` environment variables. Auto-loaded via Railtie in Rails apps
|
|
17
|
+
- **Rake task `dex:guides`** — `rake dex:guides` installs LLM-optimized guides as `AGENTS.md` files in app directories (`app/operations/`, `app/events/`, `app/event_handlers/`, `app/forms/`, `app/queries/`). Only writes to directories that exist. Stamps each file with the installed dexkit version. The event guide is installed to both `app/events/` and `app/event_handlers/` when either exists. Existing hand-written `AGENTS.md` files are detected and skipped (`FORCE=1` to overwrite). Override paths with `OPERATIONS_PATH`, `EVENTS_PATH`, `EVENT_HANDLERS_PATH`, `FORMS_PATH`, `QUERIES_PATH` environment variables
|
|
18
|
+
- **`explain` includes `description`** — `explain` output now contains `:description` when set on the operation
|
|
19
|
+
- **`explain` class method for operations** — `MyOp.explain(**kwargs)` returns a frozen Hash with the full preflight state: resolved props, context source tracking (`:explicit`/`:ambient`/`:default`), per-guard pass/fail results with messages, once key and status (`:fresh`/`:exists`/`:expired`/`:pending`/`:invalid`/`:misconfigured`/`:unavailable`), advisory lock key, record/transaction/rescue/callback settings, pipeline steps, and overall `callable` verdict (accounts for both guard failures and once blocking statuses). No side effects — `perform` is never called. Gracefully handles invalid props — returns partial results with `error` key instead of raising, class-level information always available. Respects pipeline customization — removed steps report inactive. Custom middleware can contribute via `_name_explain` class methods
|
|
20
|
+
|
|
21
|
+
### Breaking
|
|
22
|
+
|
|
23
|
+
- **`contract.to_h` returns rich format** — `contract.to_h` now returns a comprehensive serialized Hash with string-typed params, description, context, pipeline, and settings instead of the raw `Data#to_h` shape. Before: `contract.to_h[:success]` returned `String` (the class). After: it returns `"String"` (a string). Code doing type comparisons like `contract.to_h[:success] == String` must update to use `contract.success` (which still returns raw types) or compare against `"String"`. The raw Ruby types remain accessible via `contract.params`, `contract.success`, `contract.errors`, `contract.guards`
|
|
24
|
+
- **`_Ref` JSON Schema type changed from `"integer"` to `"string"`** — `_Ref(Model)` now serializes as `{ type: "string" }` in JSON Schema. IDs are treated as opaque strings to support Mongoid BSON::ObjectId, UUIDs, and other non-integer primary key formats. Code that relied on `type: "integer"` for Ref params must update
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **`Handler.deregister` now unsubscribes from Bus** — `Dex::Event::Handler.deregister(klass)` removes the handler from both the registry and the event Bus. Previously, deregistered handlers remained subscribed and would still fire on published events
|
|
29
|
+
- **Registry prunes stale entries** — `registry` now removes unreachable class references from the backing Set during each call, preventing memory leaks from code reload cycles
|
|
30
|
+
- **`description(false)` and `desc: false` now raise `ArgumentError`** — previously accepted as "missing" values due to falsey evaluation. Both DSL methods now validate with `!text.nil?` / `!desc.nil?` to enforce the String requirement, matching the library's fail-fast convention
|
|
31
|
+
- **`prop_descriptions` no longer leaks parent descriptions for redeclared props** — when a child class redefines a prop without `desc:`, the parent's description is cleared instead of being inherited. Providing a new `desc:` on the child works as before
|
|
32
|
+
- **Rake task validates handler format** — `rake dex:export SECTION=handlers FORMAT=json_schema` now raises a clear error instead of hitting `Handler.export`'s `ArgumentError`
|
|
33
|
+
|
|
3
34
|
## [0.7.0] - 2026-03-08
|
|
4
35
|
|
|
5
36
|
### Breaking
|
data/README.md
CHANGED
|
@@ -132,6 +132,30 @@ end
|
|
|
132
132
|
Order::Place.call(product: product, customer: customer)
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
+
**Explain** – full preflight check in one call. Context, guards, idempotency, locks, settings – everything the operation would do, without doing it:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
info = Order::Place.explain(product: product, customer: customer, quantity: 2)
|
|
139
|
+
info[:callable] # => true (all guards pass)
|
|
140
|
+
info[:once][:status] # => :fresh (would execute, not replay)
|
|
141
|
+
info[:context][:source] # => { customer: :ambient }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Registry & Export** — list all operations, export contracts as JSON or JSON Schema, and bridge to LLM function-calling via [ruby-llm](https://rubyllm.com/):
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# List all operations
|
|
148
|
+
Dex::Operation.registry # => #<Set: {Order::Place, Order::Cancel, ...}>
|
|
149
|
+
|
|
150
|
+
# Export contracts
|
|
151
|
+
Dex::Operation.export(format: :json_schema)
|
|
152
|
+
|
|
153
|
+
# LLM tools (requires ruby-llm gem)
|
|
154
|
+
chat = RubyLLM.chat
|
|
155
|
+
chat.with_tools(*Dex::Tool.all)
|
|
156
|
+
chat.ask("Place an order for 2 units of product #42")
|
|
157
|
+
```
|
|
158
|
+
|
|
135
159
|
**Transactions** on by default, **advisory locking**, **recording** to database, **callbacks**, and a customizable **pipeline** – all composable, all optional.
|
|
136
160
|
|
|
137
161
|
### Testing
|
|
@@ -326,13 +350,18 @@ Full documentation at **[dex.razorjack.net](https://dex.razorjack.net)**.
|
|
|
326
350
|
|
|
327
351
|
## AI Coding Assistant Setup
|
|
328
352
|
|
|
329
|
-
dexkit ships LLM-optimized guides.
|
|
353
|
+
dexkit ships LLM-optimized guides. Install them as `AGENTS.md` files in your app directories so AI coding agents automatically know the API:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
rake dex:guides
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
This copies guides into directories that exist (`app/operations/`, `app/events/`, `app/event_handlers/`, `app/forms/`, `app/queries/`), stamped with the installed dexkit version. Re-run after upgrading dexkit to sync. Existing hand-written `AGENTS.md` files are never overwritten (use `FORCE=1` to override).
|
|
360
|
+
|
|
361
|
+
Override paths for non-standard directory names:
|
|
330
362
|
|
|
331
363
|
```bash
|
|
332
|
-
|
|
333
|
-
cp $(bundle show dexkit)/guides/llm/EVENT.md app/event_handlers/CLAUDE.md
|
|
334
|
-
cp $(bundle show dexkit)/guides/llm/FORM.md app/forms/CLAUDE.md
|
|
335
|
-
cp $(bundle show dexkit)/guides/llm/QUERY.md app/queries/CLAUDE.md
|
|
364
|
+
rake dex:guides OPERATIONS_PATH=app/services
|
|
336
365
|
```
|
|
337
366
|
|
|
338
367
|
## License
|
data/guides/llm/EVENT.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Dex::Event — LLM Reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install with `rake dex:guides` or copy manually to `app/events/AGENTS.md`.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -383,4 +383,46 @@ end
|
|
|
383
383
|
|
|
384
384
|
---
|
|
385
385
|
|
|
386
|
+
## Registry, Export & Description
|
|
387
|
+
|
|
388
|
+
### Description
|
|
389
|
+
|
|
390
|
+
Events can declare a human-readable description. Props can include `desc:`:
|
|
391
|
+
|
|
392
|
+
```ruby
|
|
393
|
+
class Order::Placed < Dex::Event
|
|
394
|
+
description "Emitted after an order is successfully placed"
|
|
395
|
+
|
|
396
|
+
prop :order_id, Integer, desc: "The placed order"
|
|
397
|
+
prop :total, BigDecimal, desc: "Order total"
|
|
398
|
+
end
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Registry
|
|
402
|
+
|
|
403
|
+
```ruby
|
|
404
|
+
Dex::Event.registry # => #<Set: {Order::Placed, Order::Cancelled, ...}>
|
|
405
|
+
Dex::Event::Handler.registry # => #<Set: {NotifyWarehouse, SendConfirmation, ...}>
|
|
406
|
+
Dex::Event.deregister(klass)
|
|
407
|
+
Dex::Event::Handler.deregister(klass)
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Export
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
Order::Placed.to_h
|
|
414
|
+
# => { name: "Order::Placed", description: "...", props: { order_id: { type: "Integer", ... } } }
|
|
415
|
+
|
|
416
|
+
Order::Placed.to_json_schema # JSON Schema (Draft 2020-12)
|
|
417
|
+
|
|
418
|
+
NotifyWarehouse.to_h
|
|
419
|
+
# => { name: "NotifyWarehouse", events: ["Order::Placed"], retries: 3, ... }
|
|
420
|
+
|
|
421
|
+
Dex::Event.export # all events as hashes
|
|
422
|
+
Dex::Event.export(format: :json_schema) # all as JSON Schema
|
|
423
|
+
Dex::Event::Handler.export # all handlers as hashes
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
386
428
|
**End of reference.**
|
data/guides/llm/FORM.md
CHANGED
data/guides/llm/OPERATION.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Dex::Operation — LLM Reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install with `rake dex:guides` or copy manually to `app/operations/AGENTS.md`.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -95,7 +95,7 @@ prop? :note, String # optional (nilable, default: nil)
|
|
|
95
95
|
|
|
96
96
|
### _Ref(Model)
|
|
97
97
|
|
|
98
|
-
Accepts model instances or IDs, coerces IDs via `Model.find(id)`. With `lock: true`, uses `Model.lock.find(id)` (SELECT FOR UPDATE). Instances pass through without re-locking. In serialization (recording, async), stores model ID only.
|
|
98
|
+
Accepts model instances or IDs, coerces IDs via `Model.find(id)`. With `lock: true`, uses `Model.lock.find(id)` (SELECT FOR UPDATE). Instances pass through without re-locking. In serialization (recording, async), stores model ID only. IDs are treated as strings in JSON Schema – this supports integer PKs, UUIDs, and Mongoid BSON::ObjectId equally.
|
|
99
99
|
|
|
100
100
|
Outside the class body (e.g., in tests), use `Dex::RefType.new(Model)` instead of `_Ref(Model)`.
|
|
101
101
|
|
|
@@ -234,6 +234,51 @@ result.details # => [{ guard: :unauthorized, message: "..." }, ...]
|
|
|
234
234
|
|
|
235
235
|
---
|
|
236
236
|
|
|
237
|
+
## Explain
|
|
238
|
+
|
|
239
|
+
Full preflight check — resolves context, coerces props, evaluates guards, computes derived keys, reports settings. No side effects, `perform` never runs.
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
info = Order::Place.explain(product: product, customer: customer, quantity: 2)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Returns a frozen Hash:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
info = Order::Place.explain(product: product, customer: customer, quantity: 2)
|
|
249
|
+
# => {
|
|
250
|
+
# operation: "Order::Place",
|
|
251
|
+
# props: { product: #<Product>, customer: #<Customer>, quantity: 2 },
|
|
252
|
+
# context: {
|
|
253
|
+
# resolved: { customer: #<Customer> },
|
|
254
|
+
# mappings: { customer: :current_customer },
|
|
255
|
+
# source: { customer: :ambient } # :ambient, :explicit, or :default
|
|
256
|
+
# },
|
|
257
|
+
# guards: {
|
|
258
|
+
# passed: true,
|
|
259
|
+
# results: [{ name: :out_of_stock, passed: true }, ...]
|
|
260
|
+
# },
|
|
261
|
+
# once: { active: true, key: "Order::Place/product_id=7", status: :fresh, expires_in: nil },
|
|
262
|
+
# lock: { active: true, key: "order:7", timeout: nil },
|
|
263
|
+
# record: { enabled: true, params: true, result: true },
|
|
264
|
+
# transaction: { enabled: true },
|
|
265
|
+
# rescue_from: { "Stripe::CardError" => :card_declined },
|
|
266
|
+
# callbacks: { before: 1, after: 2, around: 0 },
|
|
267
|
+
# pipeline: [:result, :guard, :once, :lock, :record, :transaction, :rescue, :callback],
|
|
268
|
+
# callable: true
|
|
269
|
+
# }
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
- Invalid props (`Literal::TypeError`, `ArgumentError`) return a partial result with `info[:error]` — class-level info still available, instance-dependent sections degrade to empty/nil. Static lock keys preserved. Context source uses `:missing` for props without defaults. Other errors propagate normally
|
|
273
|
+
- `info[:callable]` is a full preflight verdict — checks guards AND once blocking statuses; always `false` when props are invalid
|
|
274
|
+
- Once status: `:fresh` (new), `:exists` (would replay), `:expired`, `:pending` (in-flight), `:invalid` (nil key), `:misconfigured` (anonymous op, missing record step, missing column), `:unavailable` (no backend)
|
|
275
|
+
- Guard results include `message:` on failures and `skipped: true` when a guard was skipped via `requires:` dependency
|
|
276
|
+
- Custom middleware can contribute via `_name_explain(instance, info)` class methods
|
|
277
|
+
|
|
278
|
+
**Use cases:** console debugging, admin tooling, LLM agent preflight, test assertions.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
237
282
|
## Flow Control
|
|
238
283
|
|
|
239
284
|
All three halt execution immediately via non-local exit (work from `perform`, helpers, and callbacks).
|
|
@@ -780,4 +825,63 @@ end
|
|
|
780
825
|
|
|
781
826
|
---
|
|
782
827
|
|
|
828
|
+
## Registry, Export & Description
|
|
829
|
+
|
|
830
|
+
### Description
|
|
831
|
+
|
|
832
|
+
Operations can declare a human-readable description. Props can include `desc:`:
|
|
833
|
+
|
|
834
|
+
```ruby
|
|
835
|
+
class Order::Place < Dex::Operation
|
|
836
|
+
description "Places a new order, charges payment, and schedules fulfillment"
|
|
837
|
+
|
|
838
|
+
prop :product, _Ref(Product), desc: "Product to order"
|
|
839
|
+
prop :quantity, _Integer(1..), desc: "Number of units"
|
|
840
|
+
end
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
Descriptions appear in `contract.to_h`, `to_json_schema`, `explain`, and LLM tool definitions.
|
|
844
|
+
|
|
845
|
+
### Registry
|
|
846
|
+
|
|
847
|
+
```ruby
|
|
848
|
+
Dex::Operation.registry # => #<Set: {Order::Place, Order::Cancel, ...}>
|
|
849
|
+
Dex::Operation.deregister(klass) # remove from registry (useful in tests)
|
|
850
|
+
Dex::Operation.clear! # empty the registry
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
Only named, reachable classes are included. Anonymous classes and stale objects from code reloads are excluded. Populates lazily via `inherited` — in Rails, `eager_load!` to get the full list.
|
|
854
|
+
|
|
855
|
+
### Export
|
|
856
|
+
|
|
857
|
+
```ruby
|
|
858
|
+
Order::Place.contract.to_h
|
|
859
|
+
# => { name: "Order::Place", description: "...", params: { product: { type: "Ref(Product)", required: true, desc: "..." } }, ... }
|
|
860
|
+
|
|
861
|
+
Order::Place.contract.to_json_schema # params input schema (default)
|
|
862
|
+
Order::Place.contract.to_json_schema(section: :success) # success return schema
|
|
863
|
+
Order::Place.contract.to_json_schema(section: :errors) # error catalog schema
|
|
864
|
+
Order::Place.contract.to_json_schema(section: :full) # everything
|
|
865
|
+
|
|
866
|
+
Dex::Operation.export # all operations as hashes
|
|
867
|
+
Dex::Operation.export(format: :json_schema) # all as JSON Schema
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
### LLM Tools (ruby-llm integration)
|
|
871
|
+
|
|
872
|
+
```ruby
|
|
873
|
+
chat = RubyLLM.chat
|
|
874
|
+
chat.with_tools(*Dex::Tool.all) # all operations as tools
|
|
875
|
+
chat.with_tools(*Dex::Tool.from_namespace("Order")) # namespace filter
|
|
876
|
+
chat.with_tools(Dex::Tool.explain_tool) # preflight check tool
|
|
877
|
+
|
|
878
|
+
Dex.with_context(current_user: user) do
|
|
879
|
+
chat.ask("Place an order for 2 units of product #42")
|
|
880
|
+
end
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
Requires `gem 'ruby_llm'` in your Gemfile. Lazy-loaded — ruby-llm is only required when you call `Dex::Tool`.
|
|
884
|
+
|
|
885
|
+
---
|
|
886
|
+
|
|
783
887
|
**End of reference.**
|
data/guides/llm/QUERY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Dex::Query — LLM Reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install with `rake dex:guides` or copy manually to `app/queries/AGENTS.md`.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Event
|
|
5
|
+
module Export
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build_hash(source)
|
|
9
|
+
h = {}
|
|
10
|
+
h[:name] = source.name if source.name
|
|
11
|
+
desc = source.description
|
|
12
|
+
h[:description] = desc if desc
|
|
13
|
+
h[:props] = _serialize_props(source)
|
|
14
|
+
h
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build_json_schema(source)
|
|
18
|
+
descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
19
|
+
properties = {}
|
|
20
|
+
required = []
|
|
21
|
+
|
|
22
|
+
if source.respond_to?(:literal_properties)
|
|
23
|
+
source.literal_properties.each do |prop|
|
|
24
|
+
prop_desc = descs[prop.name]
|
|
25
|
+
schema = TypeSerializer.to_json_schema(prop.type, desc: prop_desc)
|
|
26
|
+
properties[prop.name.to_s] = schema
|
|
27
|
+
required << prop.name.to_s if prop.required?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
32
|
+
result[:title] = source.name if source.name
|
|
33
|
+
desc = source.description
|
|
34
|
+
result[:description] = desc if desc
|
|
35
|
+
result[:type] = "object"
|
|
36
|
+
result[:properties] = properties unless properties.empty?
|
|
37
|
+
result[:required] = required unless required.empty?
|
|
38
|
+
result[:additionalProperties] = false
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def _serialize_props(source)
|
|
43
|
+
return {} unless source.respond_to?(:literal_properties)
|
|
44
|
+
|
|
45
|
+
descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
46
|
+
source.literal_properties.each_with_object({}) do |prop, hash|
|
|
47
|
+
entry = { type: TypeSerializer.to_string(prop.type), required: prop.required? }
|
|
48
|
+
entry[:desc] = descs[prop.name] if descs[prop.name]
|
|
49
|
+
hash[prop.name] = entry
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private_class_method :_serialize_props
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/dex/event/handler.rb
CHANGED
|
@@ -5,15 +5,48 @@ module Dex
|
|
|
5
5
|
class Handler
|
|
6
6
|
include Dex::Executable
|
|
7
7
|
|
|
8
|
+
extend Registry
|
|
9
|
+
|
|
10
|
+
def self.deregister(klass)
|
|
11
|
+
if klass.respond_to?(:handled_events)
|
|
12
|
+
klass.handled_events.each { |ec| Bus.unsubscribe(ec, klass) }
|
|
13
|
+
end
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
8
17
|
attr_reader :event
|
|
9
18
|
|
|
10
19
|
def self.on(*event_classes)
|
|
11
20
|
event_classes.each do |ec|
|
|
12
21
|
Event.validate_event_class!(ec)
|
|
13
22
|
Bus.subscribe(ec, self)
|
|
23
|
+
(@_handled_events ||= []) << ec
|
|
14
24
|
end
|
|
15
25
|
end
|
|
16
26
|
|
|
27
|
+
def self.handled_events
|
|
28
|
+
defined?(@_handled_events) ? @_handled_events.dup.freeze : [].freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.to_h
|
|
32
|
+
h = {}
|
|
33
|
+
h[:name] = name if name
|
|
34
|
+
event_names = handled_events.filter_map(&:name)
|
|
35
|
+
h[:events] = event_names unless event_names.empty?
|
|
36
|
+
retry_config = _event_handler_retry_config
|
|
37
|
+
h[:retries] = retry_config[:count] if retry_config
|
|
38
|
+
tx_s = settings_for(:transaction)
|
|
39
|
+
h[:transaction] = tx_s.fetch(:enabled, false)
|
|
40
|
+
h[:pipeline] = pipeline.steps.map(&:name)
|
|
41
|
+
h
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.export(format: :hash)
|
|
45
|
+
raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash" unless format == :hash
|
|
46
|
+
|
|
47
|
+
registry.sort_by(&:name).map(&:to_h)
|
|
48
|
+
end
|
|
49
|
+
|
|
17
50
|
def self.retries(count, **opts)
|
|
18
51
|
raise ArgumentError, "retries count must be a positive Integer" unless count.is_a?(Integer) && count > 0
|
|
19
52
|
|
data/lib/dex/event.rb
CHANGED
|
@@ -17,6 +17,32 @@ module Dex
|
|
|
17
17
|
include TypeCoercion
|
|
18
18
|
include ContextSetup
|
|
19
19
|
|
|
20
|
+
extend Registry
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def to_h
|
|
24
|
+
Export.build_hash(self)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_json_schema
|
|
28
|
+
Export.build_json_schema(self)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def export(format: :hash)
|
|
32
|
+
unless %i[hash json_schema].include?(format)
|
|
33
|
+
raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sorted = registry.sort_by(&:name)
|
|
37
|
+
sorted.map do |klass|
|
|
38
|
+
case format
|
|
39
|
+
when :hash then klass.to_h
|
|
40
|
+
when :json_schema then klass.to_json_schema
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
20
46
|
def self._warn(message)
|
|
21
47
|
Dex.warn("Event: #{message}")
|
|
22
48
|
end
|
|
@@ -84,3 +110,4 @@ end
|
|
|
84
110
|
require_relative "event/bus"
|
|
85
111
|
require_relative "event/handler"
|
|
86
112
|
require_relative "event/processor"
|
|
113
|
+
require_relative "event/export"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
module Explain
|
|
6
|
+
def explain(**kwargs)
|
|
7
|
+
error = nil
|
|
8
|
+
instance = begin
|
|
9
|
+
new(**kwargs)
|
|
10
|
+
rescue Literal::TypeError, ArgumentError => e
|
|
11
|
+
error = e
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
info = {}
|
|
16
|
+
active = pipeline.steps.map(&:name).to_set
|
|
17
|
+
|
|
18
|
+
info[:operation] = name || "(anonymous)"
|
|
19
|
+
desc = description
|
|
20
|
+
info[:description] = desc if desc
|
|
21
|
+
info[:error] = "#{error.class}: #{error.message}" if error
|
|
22
|
+
info[:props] = _explain_props(instance)
|
|
23
|
+
info[:context] = _explain_context(instance, kwargs)
|
|
24
|
+
info[:guards] = active.include?(:guard) ? _explain_guards(instance) : { passed: true, results: [] }
|
|
25
|
+
info[:once] = active.include?(:once) ? _explain_once(instance) : { active: false }
|
|
26
|
+
info[:lock] = active.include?(:lock) ? _explain_lock(instance) : { active: false }
|
|
27
|
+
info[:record] = active.include?(:record) ? _explain_record : { enabled: false }
|
|
28
|
+
info[:transaction] = active.include?(:transaction) ? _explain_transaction : { enabled: false }
|
|
29
|
+
info[:rescue_from] = active.include?(:rescue) ? _explain_rescue : {}
|
|
30
|
+
info[:callbacks] = active.include?(:callback) ? _explain_callbacks : { before: 0, after: 0, around: 0 }
|
|
31
|
+
|
|
32
|
+
if instance
|
|
33
|
+
pipeline.steps.each do |step|
|
|
34
|
+
method_name = :"_#{step.name}_explain"
|
|
35
|
+
send(method_name, instance, info) if respond_to?(method_name, true)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
info[:pipeline] = pipeline.steps.map(&:name)
|
|
40
|
+
info[:callable] = instance ? _explain_callable?(info) : false
|
|
41
|
+
info.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def _explain_callable?(info)
|
|
47
|
+
return false unless info[:guards][:passed]
|
|
48
|
+
|
|
49
|
+
if info[:once][:active]
|
|
50
|
+
return false if ONCE_BLOCKING_STATUSES.include?(info[:once][:status])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
ONCE_BLOCKING_STATUSES = %i[invalid pending misconfigured unavailable].freeze
|
|
57
|
+
|
|
58
|
+
def _explain_props(instance)
|
|
59
|
+
return {} unless respond_to?(:literal_properties)
|
|
60
|
+
return {} unless instance
|
|
61
|
+
|
|
62
|
+
literal_properties.each_with_object({}) do |prop, hash|
|
|
63
|
+
hash[prop.name] = instance.public_send(prop.name)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def _explain_context(instance, explicit_kwargs)
|
|
68
|
+
mappings = respond_to?(:context_mappings) ? context_mappings : {}
|
|
69
|
+
return { resolved: {}, mappings: {}, source: {} } if mappings.empty?
|
|
70
|
+
|
|
71
|
+
ambient = Dex.context
|
|
72
|
+
resolved = {}
|
|
73
|
+
source = {}
|
|
74
|
+
|
|
75
|
+
mappings.each do |prop_name, context_key|
|
|
76
|
+
resolved[prop_name] = instance.public_send(prop_name) if instance
|
|
77
|
+
source[prop_name] = if explicit_kwargs.key?(prop_name)
|
|
78
|
+
:explicit
|
|
79
|
+
elsif ambient.key?(context_key)
|
|
80
|
+
:ambient
|
|
81
|
+
elsif instance || _explain_prop_has_default?(prop_name)
|
|
82
|
+
:default
|
|
83
|
+
else
|
|
84
|
+
:missing
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
{ resolved: resolved, mappings: mappings, source: source }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def _explain_guards(instance)
|
|
92
|
+
return { passed: false, results: [] } unless instance
|
|
93
|
+
|
|
94
|
+
all_results = instance.send(:_guard_evaluate_all)
|
|
95
|
+
results = all_results.map do |r|
|
|
96
|
+
entry = { name: r[:name], passed: r[:passed] }
|
|
97
|
+
entry[:message] = r[:message] if r[:message]
|
|
98
|
+
entry[:skipped] = true if r[:skipped]
|
|
99
|
+
entry
|
|
100
|
+
end
|
|
101
|
+
{ passed: results.all? { |r| r[:passed] }, results: results }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def _explain_once(instance)
|
|
105
|
+
settings = settings_for(:once)
|
|
106
|
+
return { active: false } unless settings.fetch(:defined, false)
|
|
107
|
+
|
|
108
|
+
key = instance&.send(:_once_derive_key)
|
|
109
|
+
{
|
|
110
|
+
active: true,
|
|
111
|
+
key: key,
|
|
112
|
+
status: _explain_once_status(key),
|
|
113
|
+
expires_in: settings[:expires_in]
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def _explain_once_status(key)
|
|
118
|
+
return :invalid if key.nil?
|
|
119
|
+
return :misconfigured if name.nil?
|
|
120
|
+
return :misconfigured unless pipeline.steps.any? { |s| s.name == :record }
|
|
121
|
+
return :unavailable unless Dex.record_backend
|
|
122
|
+
return :misconfigured unless Dex.record_backend.has_field?("once_key")
|
|
123
|
+
|
|
124
|
+
settings = settings_for(:once)
|
|
125
|
+
if settings[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
|
|
126
|
+
return :misconfigured
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
existing = Dex.record_backend.find_by_once_key(key)
|
|
130
|
+
return :exists if existing
|
|
131
|
+
|
|
132
|
+
if Dex.record_backend.has_field?("once_key_expires_at")
|
|
133
|
+
expired = Dex.record_backend.find_expired_once_key(key)
|
|
134
|
+
return :expired if expired
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
pending = Dex.record_backend.find_pending_once_key(key)
|
|
138
|
+
return :pending if pending
|
|
139
|
+
|
|
140
|
+
:fresh
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def _explain_lock(instance)
|
|
144
|
+
settings = settings_for(:advisory_lock)
|
|
145
|
+
return { active: false } unless settings.fetch(:enabled, false)
|
|
146
|
+
|
|
147
|
+
key = if instance
|
|
148
|
+
instance.send(:_lock_key)
|
|
149
|
+
else
|
|
150
|
+
case settings[:key]
|
|
151
|
+
when String then settings[:key]
|
|
152
|
+
when nil then name
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
{ active: true, key: key, timeout: settings[:timeout] }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def _explain_prop_has_default?(prop_name)
|
|
160
|
+
return false unless respond_to?(:literal_properties)
|
|
161
|
+
|
|
162
|
+
literal_properties.any? { |p| p.name == prop_name && p.default? }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def _explain_record
|
|
166
|
+
settings = settings_for(:record)
|
|
167
|
+
enabled = settings.fetch(:enabled, true) && !!Dex.record_backend && !!name
|
|
168
|
+
return { enabled: false } unless enabled
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
enabled: true,
|
|
172
|
+
params: settings.fetch(:params, true),
|
|
173
|
+
result: settings.fetch(:result, true)
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def _explain_transaction
|
|
178
|
+
settings = settings_for(:transaction)
|
|
179
|
+
return { enabled: false } unless settings.fetch(:enabled, true)
|
|
180
|
+
|
|
181
|
+
adapter_name = settings.fetch(:adapter, Dex.transaction_adapter)
|
|
182
|
+
adapter = Operation::TransactionAdapter.for(adapter_name)
|
|
183
|
+
{ enabled: !adapter.nil? }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def _explain_rescue
|
|
187
|
+
handlers = respond_to?(:_rescue_handlers) ? _rescue_handlers : []
|
|
188
|
+
handlers.each_with_object({}) do |h, hash|
|
|
189
|
+
hash[h[:exception_class].name] = h[:code]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def _explain_callbacks
|
|
194
|
+
return { before: 0, after: 0, around: 0 } unless respond_to?(:_callback_list)
|
|
195
|
+
|
|
196
|
+
{
|
|
197
|
+
before: _callback_list(:before).size,
|
|
198
|
+
after: _callback_list(:after).size,
|
|
199
|
+
around: _callback_list(:around).size
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|