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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34e5457c2e95efc96b2350babe7d7059854caa42ce6da92087e003f76acfb6d8
4
- data.tar.gz: cf0466311827f2706fe883167b88d27a2571029c029777a1a5f37e0f44ae080a
3
+ metadata.gz: 1009624f8d508e4d6b61d76c8d54f32fc04a11f445163915c027ebc1bdd30b5e
4
+ data.tar.gz: 1b5a0755af0be468d67c3c5447f1322deff584364a493fb7665687d1417189b3
5
5
  SHA512:
6
- metadata.gz: 3885a4464112b937aa88bd2661ad03aca35af5128ba9b292c7345fccd69bbd8c53a363d5f1805c0c2173c097ff06f8f98ec7fc657115fd530b84a910af9b5759
7
- data.tar.gz: 25ce509ae466f259b102b4eedb8d828eb27435d6223bd052c87682b60b43584a6ccaf223ed0bec3f2141988fadebaac4638a204c4025554870e70c389ac9d4e2
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. Copy them into your project so AI agents automatically know the API:
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
- cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
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
- 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.
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
@@ -1,6 +1,6 @@
1
1
  # Dex::Form — LLM Reference
2
2
 
3
- Copy this to your app's forms directory (e.g., `app/forms/AGENTS.md`) so coding agents know the full API when implementing and testing forms.
3
+ Install with `rake dex:guides` or copy manually to `app/forms/AGENTS.md`.
4
4
 
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Dex::Operation — LLM Reference
2
2
 
3
- Copy this to your app's operations directory (e.g., `app/operations/AGENTS.md`) so coding agents know the full API when implementing and testing operations.
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
- Copy this to your app's queries directory (e.g., `app/queries/AGENTS.md`) so coding agents know the full API when implementing and testing queries.
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
@@ -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