dexkit 0.6.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: f8d7274727e727937b55704faa81e4da8a54bb3af7758b11e9144e59fb2dba14
4
- data.tar.gz: cb899ee88b5e83ec57a913f0173036ac228f672c7850d88e95209f4804218f96
3
+ metadata.gz: 1009624f8d508e4d6b61d76c8d54f32fc04a11f445163915c027ebc1bdd30b5e
4
+ data.tar.gz: 1b5a0755af0be468d67c3c5447f1322deff584364a493fb7665687d1417189b3
5
5
  SHA512:
6
- metadata.gz: 7758fe2c0d5b9cdd2bcf62aa510c4146ef29898335ddffb118abb35409367d5f7cbd1aeba6365f865bb36d4011b1030f136cfb9b6eee27fb3aa3d06d11eeb21e
7
- data.tar.gz: e437a4af9b4aa0c121245de966775cd3be0bad5ceb5918f139cfaf059545159631797b630a9b21f3add8855308c832f5846efb78f97f36141042f40d28b32341
6
+ metadata.gz: '08552d04b1e9ddf5991f1454f9491fcabe80047d9606f0432be411f09d7b632a797435fabf55d8e9b190ddbc1a70daa5e73c19aa08c2bde88540559c3d35275e'
7
+ data.tar.gz: 33d58dd5b421a1e7f41d3ab133c1800787d2d1f6c22327e7db7faf9053222d62d087c4a563105fb39075fbefc1a73f04984b45511595a0e3976eda9833de0cdf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
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
+
34
+ ## [0.7.0] - 2026-03-08
35
+
36
+ ### Breaking
37
+
38
+ - **Operation record schema refactored** — the `response` column is renamed to `result`, the `error` column is split into `error_code`, `error_message`, and `error_details`, `params` no longer has `default: {}` (nil means "not captured"), and `status` is now `null: false`. The `record response: false` DSL option is now `record result: false`. Status value `done` is renamed to `completed`, and a new `error` status represents business errors via `error!`
39
+ - **All outcomes now recorded** — previously, only successful operations were recorded in the sync path. Now business errors (`error!`) record with status `error` and populate `error_code`/`error_message`/`error_details`, and unhandled exceptions record with status `failed`
40
+ - **Recording moved outside transaction** — operation records are now persisted outside the database transaction, so error and failure records survive rollbacks. Previously, records were created inside the transaction and would be rolled back alongside the operation's side effects
41
+ - **Pipeline order changed** — `RecordWrapper` now runs before `TransactionWrapper` (was after). The pipeline order is now: result → once → lock → record → transaction → rescue → guard → callback
42
+ - **`Operation.contract` shape changed** — `Contract` gains a fourth field `:guards`. Code using positional destructuring (`in Contract[params, success, errors]`) must be updated to include the new field. Keyword-based access (`.params`, `.errors`, etc.) is unaffected
43
+
44
+ ### Added
45
+
46
+ - **Ambient context** — `Dex.with_context(current_user: user) { ... }` sets fiber-local ambient state. The `context` DSL on operations and events maps props to ambient keys, auto-filling them when not passed explicitly. Explicit kwargs always win. Works with guards (`callable?`), events (captured at publish time), and nested operations. Introspection via `context_mappings`
47
+ - **`guard` DSL for precondition checks** — named, inline preconditions that detect threats (conditions under which the operation should not proceed). Guards auto-declare error codes, support dependencies (`requires:`), collect all independent failures, and skip dependent guards when a dependency fails. `callable?` and `callable` class methods check guards without running `perform` – useful for UI show/hide, disabled buttons with reasons, and API pre-validation. Contract introspection via `contract.guards`. Test helpers: `assert_callable`, `refute_callable`
48
+ - **`once` DSL for operation idempotency** — ensures an operation executes at most once for a given key, replaying stored results on subsequent calls. Supports prop-based keys (`once :order_id`), composite keys, block-based custom keys, call-site keys (`.once("key")`), optional expiry (`expires_in:`), and `clear_once!` for key management. Business errors are replayed; exceptions release the key for retry. Works with `.safe.call` and `.async.call`
49
+ - **`error_code`, `error_message`, `error_details` columns** — structured error recording replaces the single `error` string column
50
+ - **Recommended indexes** — `name`, `status`, `[:name, :status]` composite index, and unique partial index on `once_key` in the migration schema
51
+
3
52
  ## [0.6.0] - 2026-03-07
4
53
 
5
54
  ### Added
data/README.md CHANGED
@@ -69,6 +69,93 @@ end
69
69
  Order::Fulfill.new(order_id: 123).async(queue: "fulfillment").call
70
70
  ```
71
71
 
72
+ **Idempotency** with `once` — run an operation at most once for a given key. Results are replayed on duplicates:
73
+
74
+ ```ruby
75
+ class Payment::Charge < Dex::Operation
76
+ prop :order_id, Integer
77
+ prop :amount, Integer
78
+
79
+ once :order_id # key from prop
80
+ # once :order_id, :merchant_id # composite key
81
+ # once # all props as key
82
+ # once { "custom-#{order_id}" } # block-based key
83
+ # once :order_id, expires_in: 24.hours # expiring key
84
+
85
+ def perform
86
+ Gateway.charge!(order_id, amount)
87
+ end
88
+ end
89
+
90
+ # Call-site key (overrides class-level declaration)
91
+ Payment::Charge.new(order_id: 1, amount: 500).once("ext-key-123").call
92
+
93
+ # Bypass once guard for a single call
94
+ Payment::Charge.new(order_id: 1, amount: 500).once(nil).call
95
+
96
+ # Clear a stored key to allow re-execution
97
+ Payment::Charge.clear_once!(order_id: 1)
98
+ ```
99
+
100
+ Business errors are replayed; exceptions release the key so the operation can be retried. Requires the record backend (recording is enabled by default when `record_class` is configured).
101
+
102
+ **Guards** – inline precondition checks with introspection. Ask "can this operation run?" from views and controllers:
103
+
104
+ ```ruby
105
+ guard :out_of_stock, "Product must be in stock" do
106
+ !product.in_stock?
107
+ end
108
+
109
+ # In a view or controller:
110
+ Order::Place.callable?(customer: customer, product: product, quantity: 1)
111
+ ```
112
+
113
+ **Ambient context** – declare which props come from ambient state. Set once in a controller, auto-fill everywhere:
114
+
115
+ ```ruby
116
+ class Order::Place < Dex::Operation
117
+ prop :product, _Ref(Product)
118
+ prop :customer, _Ref(Customer)
119
+ context customer: :current_customer # filled from Dex.context[:current_customer]
120
+
121
+ def perform
122
+ Order.create!(product: product, customer: customer)
123
+ end
124
+ end
125
+
126
+ # Controller
127
+ Dex.with_context(current_customer: current_customer) do
128
+ Order::Place.call(product: product) # customer auto-filled
129
+ end
130
+
131
+ # Tests – just pass it explicitly
132
+ Order::Place.call(product: product, customer: customer)
133
+ ```
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
+
72
159
  **Transactions** on by default, **advisory locking**, **recording** to database, **callbacks**, and a customizable **pipeline** – all composable, all optional.
73
160
 
74
161
  ### Testing
@@ -263,13 +350,18 @@ Full documentation at **[dex.razorjack.net](https://dex.razorjack.net)**.
263
350
 
264
351
  ## AI Coding Assistant Setup
265
352
 
266
- 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:
267
362
 
268
363
  ```bash
269
- cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
270
- cp $(bundle show dexkit)/guides/llm/EVENT.md app/event_handlers/CLAUDE.md
271
- cp $(bundle show dexkit)/guides/llm/FORM.md app/forms/CLAUDE.md
272
- cp $(bundle show dexkit)/guides/llm/QUERY.md app/queries/CLAUDE.md
364
+ rake dex:guides OPERATIONS_PATH=app/services
273
365
  ```
274
366
 
275
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
 
@@ -235,21 +235,43 @@ Persistence failures are silently rescued — they never halt event publishing.
235
235
 
236
236
  ---
237
237
 
238
- ## Context (Optional)
238
+ ## Ambient Context
239
239
 
240
- Capture ambient context (current user, tenant, etc.) at publish time:
240
+ Events use the same `context` DSL as operations. Context-mapped props are captured at **publish time** and stored as regular props on the event — handlers don't need ambient context, they read from the event.
241
241
 
242
242
  ```ruby
243
- Dex.configure do |c|
244
- c.event_context = -> { { user_id: Current.user&.id, tenant: Current.tenant } }
245
- c.restore_event_context = ->(ctx) {
246
- Current.user = User.find(ctx["user_id"]) if ctx["user_id"]
247
- Current.tenant = ctx["tenant"]
248
- }
243
+ class Order::Placed < Dex::Event
244
+ prop :order_id, Integer
245
+ prop :customer, _Ref(Customer)
246
+ context customer: :current_customer # resolved at publish time
249
247
  end
248
+
249
+ # In a controller with Dex.with_context(current_customer: customer):
250
+ Order::Placed.publish(order_id: 1) # customer auto-filled from context
251
+
252
+ # Or pass explicitly:
253
+ Order::Placed.publish(order_id: 1, customer: customer)
250
254
  ```
251
255
 
252
- Context is stored in event metadata and restored before async handler execution.
256
+ Handlers receive the event with everything already set no `context` needed on handlers:
257
+
258
+ ```ruby
259
+ class AuditTrail < Dex::Event::Handler
260
+ on Order::Placed
261
+
262
+ def perform
263
+ AuditLog.create!(customer: event.customer, action: "placed", order_id: event.order_id)
264
+ end
265
+ end
266
+ ```
267
+
268
+ **Resolution order:** explicit kwarg → ambient context → prop default → TypeError.
269
+
270
+ **Introspection:** `MyEvent.context_mappings` returns the mapping hash.
271
+
272
+ ### Legacy Context (Metadata)
273
+
274
+ The older `event_context` / `restore_event_context` configuration captures arbitrary metadata at publish time and restores it before async handler execution. Both mechanisms coexist.
253
275
 
254
276
  ---
255
277
 
@@ -361,4 +383,46 @@ end
361
383
 
362
384
  ---
363
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
+
364
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