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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0201163b689acfc7c65709eb96174e81dacd9f090f573d4cc2ba2f15c6362c2b
|
|
4
|
+
data.tar.gz: cedf548f898ccc821262adec828613848572bfd02dbd0667001a1f9f1669c5bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 318363e15f2b2ebb3846125d5785562f114f3941e29a577e721b72ff6101c6d30171771c7998dac253e7e91f31a9727d5e318f62df172c78233a067a45d6413b
|
|
7
|
+
data.tar.gz: a982874d3377ebec37bfe9a9528b505150a31b83298594827cbb7d3d0e9d9679635a48b6068a02c4d0ed6e1ba53764471f1534d5f28209b8e2fadfc062a4f64d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,68 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### Breaking
|
|
4
|
+
|
|
5
|
+
- **`Dex::Event::Trace` removed** – the shim module was a pure pass-through to `Dex::Trace`. All callers should use `Dex::Trace` directly: `Dex::Trace.with_event_context(event)`, `Dex::Trace.current_event_id`, `Dex::Trace.trace_id`, `Dex::Trace.dump`, `Dex::Trace.restore(data)`, `Dex::Trace.clear!`
|
|
6
|
+
- **`event_context` / `restore_event_context` configuration removed** – the legacy dual context system for capturing arbitrary metadata at publish time and restoring ambient state in async handlers has been removed. Before: `config.event_context = -> { { user_id: Current.user&.id } }` captured an untyped hash into event metadata, and `config.restore_event_context` restored it before async handler execution. After: use `Dex.with_context` with typed event props via the `context` DSL instead — context values are captured as regular props at publish time, serialized automatically, and available on the event in handlers without ambient state restoration. `Metadata.context` field has been removed from event metadata and serialization
|
|
7
|
+
- **`event.context` instance method removed** – the metadata delegate that returned the `event_context` config output is gone. Context data should be declared as typed props with the `context` DSL
|
|
8
|
+
|
|
9
|
+
### Removed
|
|
10
|
+
|
|
11
|
+
- **`assert!` removed from operations** — `assert!(:code) { value }` and `assert!(value, :code)` are no longer available. Use `error!(:code) unless value` instead – it's equally concise and doesn't require learning a separate method
|
|
12
|
+
- **`assert_all_succeed` / `assert_all_fail` removed from test helpers** — use a simple loop with `assert_ok` / `assert_err` instead
|
|
13
|
+
- **`dex/event_test_helpers` shim removed** — use `require "dex/event/test_helpers"` directly
|
|
14
|
+
- **`assert_operation` / `assert_operation_error` removed from test helpers** — use `call_operation` + `assert_ok` / `assert_err` instead, which are more composable
|
|
15
|
+
- **`assert_trace_includes` / `assert_trace_actor` / `assert_trace_depth` removed from test helpers** — use `Dex::Trace.current`, `Dex::Trace.actor` directly with standard Minitest assertions
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`Dex::Id.parse`** – parse a Stripe-style ID back into prefix, timestamp, and random components. Returns `Dex::Id::Parsed` (a `Data.define` value object)
|
|
20
|
+
- **`Dex::Id.generate` now validates prefix format** – prefix must match `/\A[a-z][a-z0-9_]*_\z/` (lowercase alphanumeric with internal underscores, ending in underscore). Internal prefixes (`op_`, `ev_`, `tr_`, `hd_`) already comply
|
|
21
|
+
- **`Dex::Id.generate` accepts `random:` width option** – controls the number of random suffix characters (default 12, minimum 8). `Dex::Id.generate("ord_", random: 16)` produces a longer ID with more collision resistance
|
|
22
|
+
- **`Dex::Tool.from` accepts Query classes** – `Dex::Tool.from(Order::Query, scope: -> { ... }, serialize: -> { ... })` turns a `Dex::Query` into an LLM-callable tool with mandatory scope injection, serialization, and result limiting. Supports `limit:`, `only_filters:`, `except_filters:`, and `only_sorts:` for fine-grained control. Context-mapped and `_Ref` props are auto-excluded from the tool schema. Returns paginated results with `{ records:, total:, limit:, offset: }`. All options are validated at registration time with prescriptive error messages. Excluded filters are enforced at runtime — the agent cannot bypass filter restrictions even by sending unlisted params
|
|
23
|
+
- **`Dex::Operation::Ticket`** – `async.call` now returns a structured `Ticket` instead of a raw ActiveJob instance. The ticket exposes `record` (the operation record, if recording is enabled) and `job` (the enqueued job). Delegated accessors: `id`, `operation_name`, `status`, `error_code`, `error_message`, `error_details`. Predicate methods: `completed?`, `error?`, `failed?`, `pending?`, `running?`, `terminal?`, `recorded?`. `reload` refreshes from the database. `to_param` returns the ID for Rails path helpers. `as_json` provides a ready-made JSON representation for polling endpoints. `Ticket.from_record(record)` constructs a ticket from any operation record (async or sync) for status pages and admin dashboards
|
|
24
|
+
- **Outcome reconstruction** – `ticket.outcome` reconstructs `Ok` or `Err` from a terminal record's business outcome. `completed` records produce `Ok(result)` with deep-symbolized keys for pattern matching, `error` records produce `Err(Dex::Error)` with symbolized code and details, and non-terminal or `failed` records return `nil`. Result hashes wrapped in `_dex_value` are transparently unwrapped
|
|
25
|
+
- **`ticket.wait(timeout, interval:)` and `ticket.wait!(timeout, interval:)`** – speculative sync for async operations. `wait` polls the record until a business outcome is available or the timeout expires, returning `Ok`/`Err` or `nil` on timeout. `wait!` unwraps `Ok` and re-raises `Err`, raising `Dex::Timeout` on timeout. Both raise `Dex::OperationFailed` if the operation crashed (infrastructure failure). Interval accepts a fixed number or a callable for backoff strategies. Timeouts above 10 seconds emit a warning
|
|
26
|
+
- **`Dex::OperationFailed`** – new exception class (inherits `StandardError`, not `Dex::Error`) raised by `wait`/`wait!` when an async operation crashed with an infrastructure failure (status `"failed"`). Exposes `operation_name`, `exception_class`, and `exception_message`
|
|
27
|
+
- **`Dex::Timeout`** – new exception class (inherits `StandardError`, not `Dex::Error`) raised by `wait!` when the timeout expires without the operation reaching a terminal state. Exposes `timeout`, `ticket_id`, and `operation_name`
|
|
28
|
+
- **`Dex.actor`** – convenience reader for the current trace actor. Returns the actor hash in the same shape you passed to `Dex::Trace.start` (e.g. `{ type: "user", id: "42" }`), or `nil` when no actor is set. Symmetric with `Dex.context`
|
|
29
|
+
- **`Dex.system(name = nil)`** – convenience helper for building system actor hashes. `Dex.system` returns `{ type: :system }`, `Dex.system("payroll")` returns `{ type: :system, name: "payroll" }`. Use with `Dex::Trace.start(actor: Dex.system("nightly_cleanup"))`
|
|
30
|
+
- **`Ok#deconstruct` and `Err#deconstruct`** – array deconstruct support for pattern matching. `in Dex::Ok[value]` binds the raw value; `in Dex::Err[error]` binds the `Dex::Error` instance. Complements the existing hash deconstruct (`in Dex::Ok(key:)`, `in Dex::Err(code:)`)
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **`async.call` returns `Ticket` instead of ActiveJob instance** – callers that previously captured the return value of `async.call` to access the raw job must use `ticket.job` instead. The job is still accessible via the ticket. Code that ignores the return value (`op.async.call` without assignment) is unaffected
|
|
35
|
+
- **`safe` and `async` are non-composable** – `op.safe.async` raises `NoMethodError` with a prescriptive message guiding toward `wait`/`wait!`. `op.async.safe` raises the same. Previously both raised generic `NoMethodError`
|
|
36
|
+
|
|
37
|
+
## [0.10.0] - 2026-03-09
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **`field` / `field?` DSL for forms** – `field :name, :string` declares a required field with auto-presence validation; unconditional explicit presence validators deduplicate with it, while scoped/conditional validators still layer on top. `field? :notes, :string` declares an optional field (nil by default). Both support `desc:` for metadata and `default:` for defaults. Raw `attribute` remains available as an escape hatch
|
|
42
|
+
- **Form registry and description** – `Dex::Form` now extends `Registry`, giving forms `description`, `Dex::Form.registry`, and `deregister` – the same ecosystem as Operation, Event, and Handler
|
|
43
|
+
- **Form ambient context** – forms support the same `context` DSL as Operation and Event. `context :locale` auto-fills from `Dex.context` during initialization. Uses the same shared `ContextDSL` module with Form-specific injection
|
|
44
|
+
- **Form export** – `Form.to_h` (class-level schema), `Form.to_json_schema`, and `Dex::Form.export(format:)` for bulk export. Nested forms are recursively included in both formats, and bulk export returns top-level named forms without listing nested helper classes separately
|
|
45
|
+
- **Query registry, description, context, and export** – `Dex::Query` now extends `Registry` (giving `description`, `Dex::Query.registry`, `deregister`), includes `ContextSetup` (enabling `context :tenant` to auto-fill from `Dex.with_context`), and adds `Query.to_h`, `Query.to_json_schema`, `Dex::Query.export(format:)`. Query is now a full citizen alongside Operation, Event, and Form
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **Shared context DSL** – extracted `Dex::ContextDSL` as a shared module used by both `ContextSetup` (Operation/Event) and `Form::Context`. No behavior change for Operation or Event
|
|
50
|
+
|
|
3
51
|
## [0.9.0] - 2026-03-09
|
|
4
52
|
|
|
5
53
|
### Breaking
|
|
6
54
|
|
|
55
|
+
- **Unified execution tracing replaces event-only tracing** – `Dex::Trace` is a new fiber-local trace that spans operations, events, and handlers. Operations get `op_...` execution IDs, events get `ev_...` IDs (replacing UUIDs), handlers get `hd_...` IDs, and traces are correlated with `tr_...` IDs. `event.trace { }` is removed – use `caused_by:` for explicit event causality and `Dex::Trace.start(actor:)` at request/job boundaries
|
|
56
|
+
- **Operation record primary keys are now string IDs** – records use the operation's `op_...` execution ID as a string primary key instead of auto-increment integers. The recording schema adds `trace_id`, `actor_type`, `actor_id`, and `trace` columns. Existing tables need a migration to adopt the new schema
|
|
7
57
|
- **Mongoid transaction support removed** — `transaction :mongoid` and `config.transaction_adapter = :mongoid` are no longer valid. Dex no longer ships a Mongoid transaction adapter. Before: Mongoid transactions could be enabled via configuration or per-operation DSL. After: both forms raise `ArgumentError` immediately at declaration/configuration time. Mongoid-only apps continue to work — transactions are automatically disabled (no adapter detected), and `after_commit` fires immediately after success. If you need Mongoid multi-document transactions, call `Mongoid.transaction` directly inside `perform`
|
|
8
58
|
- **Recording backends now validate required attributes before use** — Dex no longer silently drops missing `params`, `result`, `status`, or `once` attributes from `record_class`. Before: partial ActiveRecord/Mongoid recording models could appear to work while losing status transitions, replay data, or async params. After: Dex raises `ArgumentError` naming the missing attributes required by core recording, async record jobs, or `once`. Apps using minimal recording models must add the required columns/fields or explicitly disable the features that need them
|
|
9
59
|
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **`Dex::Trace` API** – `start(actor:, trace_id:)`, `.trace_id`, `.current`, `.current_id`, `.actor`, `.to_s`, `.dump`, `.restore`. Fiber-local, auto-starts when no trace is active, serializes across async job boundaries
|
|
63
|
+
- **Trace persistence** – operation records and event stores persist `id`, `trace_id`, `actor_type`, `actor_id`, and `trace` when the columns exist. Event metadata includes `event_ancestry` for materialized-path tree queries
|
|
64
|
+
- **`Dex::Id`** – Stripe-style prefixed ID generation with embedded timestamps for sortability
|
|
65
|
+
|
|
10
66
|
### Fixed
|
|
11
67
|
|
|
12
68
|
- **Mongoid-only Rails compatibility** — Dex boots and runs cleanly in Mongoid-only Rails apps without `activerecord` loaded, with prescriptive `LoadError`s for unsupported paths such as `advisory_lock` and async event dispatch without `ActiveJob`
|
|
@@ -165,7 +221,7 @@
|
|
|
165
221
|
- Causality tracing: `event.trace { ... }` and `caused_by:` link events into chains with shared `trace_id`
|
|
166
222
|
- Block-scoped suppression: `Dex::Event.suppress(SomeEvent) { ... }`
|
|
167
223
|
- Optional persistence via `event_store` configuration
|
|
168
|
-
- Context capture
|
|
224
|
+
- Context capture across async boundaries via `Dex.with_context` and the `context` DSL
|
|
169
225
|
- **Event test helpers** — `Dex::Event::TestHelpers` module
|
|
170
226
|
- `capture_events` block for inspecting published events without dispatching
|
|
171
227
|
- `assert_event_published`, `refute_event_published`, `assert_event_count`
|
data/README.md
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# dexkit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Typed patterns for Rails, crafted for DX. Equip to gain +4 DEX.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**[Documentation](https://dex.razorjack.net)** · **[Design Philosophy](https://dex.razorjack.net/guide/philosophy)** · **[DX Meets AI](https://dex.razorjack.net/guide/ai)**
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
> **Pre-1.0.** Active development. The public API may change between minor versions.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Four base classes with contracts that enforce themselves:
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- **[Dex::Operation](https://dex.razorjack.net/operation/)** – typed service objects with structured errors, transactions, and async execution
|
|
12
|
+
- **[Dex::Event](https://dex.razorjack.net/event/)** – immutable domain events with pub/sub, async handlers, and causality tracing
|
|
13
|
+
- **[Dex::Query](https://dex.razorjack.net/query/)** – declarative filters and sorts for ActiveRecord and Mongoid scopes
|
|
14
|
+
- **[Dex::Form](https://dex.razorjack.net/form/)** – form objects with typed fields, nested forms, and Rails form builder compatibility
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
## Operations
|
|
14
17
|
|
|
15
18
|
```ruby
|
|
16
19
|
class Order::Place < Dex::Operation
|
|
@@ -34,29 +37,20 @@ class Order::Place < Dex::Operation
|
|
|
34
37
|
end
|
|
35
38
|
|
|
36
39
|
order = Order::Place.call(customer: 42, product: 7, quantity: 2)
|
|
37
|
-
order.id # => 1
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
**Typed properties** – powered by [literal](https://github.com/joeldrapper/literal). Plain classes, ranges, unions, arrays, nilable, and model references with auto-find:
|
|
42
|
+
Here's what you got:
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
prop
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
- **`_Ref(Customer)`** accepts a Customer instance or an ID – the record is auto-fetched
|
|
45
|
+
- **`_Integer(1..)`** guarantees a positive integer before `perform` runs
|
|
46
|
+
- **`prop?`** marks optional inputs (nil by default)
|
|
47
|
+
- **`success` / `error`** declare the contract – typos in error codes raise `ArgumentError`
|
|
48
|
+
- **`error!`** halts execution, rolls back the transaction, returns a structured error
|
|
49
|
+
- **`after_commit`** fires only after the transaction succeeds – safe for emails, webhooks, events
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
```ruby
|
|
54
|
-
product = assert!(:not_found) { Product.find_by(id: product_id) }
|
|
55
|
-
|
|
56
|
-
rescue_from Stripe::CardError, as: :payment_declined
|
|
57
|
-
```
|
|
51
|
+
### Pattern matching
|
|
58
52
|
|
|
59
|
-
|
|
53
|
+
`.safe.call` returns `Ok` or `Err` instead of raising:
|
|
60
54
|
|
|
61
55
|
```ruby
|
|
62
56
|
case Order::Place.new(customer: 42, product: 7, quantity: 2).safe.call
|
|
@@ -64,132 +58,50 @@ in Ok => result
|
|
|
64
58
|
redirect_to order_path(result.id)
|
|
65
59
|
in Err(code: :out_of_stock)
|
|
66
60
|
flash[:error] = "Product is out of stock"
|
|
61
|
+
render :new
|
|
67
62
|
end
|
|
68
63
|
```
|
|
69
64
|
|
|
70
|
-
|
|
65
|
+
### Guards
|
|
71
66
|
|
|
72
|
-
|
|
73
|
-
Order::Fulfill.new(order_id: 123).async(queue: "fulfillment").call
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
**Idempotency** with `once` — run an operation at most once for a given key. Results are replayed on duplicates:
|
|
67
|
+
Inline precondition checks with introspection — ask "can this run?" from views and controllers:
|
|
77
68
|
|
|
78
69
|
```ruby
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
prop :amount, Integer
|
|
82
|
-
|
|
83
|
-
once :order_id # key from prop
|
|
84
|
-
# once :order_id, :merchant_id # composite key
|
|
85
|
-
# once # all props as key
|
|
86
|
-
# once { "custom-#{order_id}" } # block-based key
|
|
87
|
-
# once :order_id, expires_in: 24.hours # expiring key
|
|
88
|
-
|
|
89
|
-
def perform
|
|
90
|
-
Gateway.charge!(order_id, amount)
|
|
91
|
-
end
|
|
70
|
+
guard :active_customer, "Customer account must be active" do
|
|
71
|
+
!customer.suspended?
|
|
92
72
|
end
|
|
93
73
|
|
|
94
|
-
# Call-site key (overrides class-level declaration)
|
|
95
|
-
Payment::Charge.new(order_id: 1, amount: 500).once("ext-key-123").call
|
|
96
|
-
|
|
97
|
-
# Bypass once guard for a single call
|
|
98
|
-
Payment::Charge.new(order_id: 1, amount: 500).once(nil).call
|
|
99
|
-
|
|
100
|
-
# Clear a stored key to allow re-execution
|
|
101
|
-
Payment::Charge.clear_once!(order_id: 1)
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
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).
|
|
105
|
-
|
|
106
|
-
**Guards** – inline precondition checks with introspection. Ask "can this operation run?" from views and controllers:
|
|
107
|
-
|
|
108
|
-
```ruby
|
|
109
|
-
guard :out_of_stock, "Product must be in stock" do
|
|
110
|
-
!product.in_stock?
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# In a view or controller:
|
|
114
74
|
Order::Place.callable?(customer: customer, product: product, quantity: 1)
|
|
75
|
+
# => true / false
|
|
115
76
|
```
|
|
116
77
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
```ruby
|
|
120
|
-
class Order::Place < Dex::Operation
|
|
121
|
-
prop :product, _Ref(Product)
|
|
122
|
-
prop :customer, _Ref(Customer)
|
|
123
|
-
context customer: :current_customer # filled from Dex.context[:current_customer]
|
|
124
|
-
|
|
125
|
-
def perform
|
|
126
|
-
Order.create!(product: product, customer: customer)
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# Controller
|
|
131
|
-
Dex.with_context(current_customer: current_customer) do
|
|
132
|
-
Order::Place.call(product: product) # customer auto-filled
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Tests – just pass it explicitly
|
|
136
|
-
Order::Place.call(product: product, customer: customer)
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
**Explain** – full preflight check in one call. Context, guards, idempotency, locks, settings – everything the operation would do, without doing it:
|
|
78
|
+
### Prescriptive errors
|
|
140
79
|
|
|
141
|
-
|
|
142
|
-
info = Order::Place.explain(product: product, customer: customer, quantity: 2)
|
|
143
|
-
info[:callable] # => true (all guards pass)
|
|
144
|
-
info[:once][:status] # => :fresh (would execute, not replay)
|
|
145
|
-
info[:context][:source] # => { customer: :ambient }
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
**Registry & Export** — list all operations, export contracts as JSON or JSON Schema, and bridge to LLM function-calling via [ruby-llm](https://rubyllm.com/):
|
|
80
|
+
Every mistake tells you what went wrong, why, and what to do instead:
|
|
149
81
|
|
|
150
82
|
```ruby
|
|
151
|
-
|
|
152
|
-
|
|
83
|
+
error!(:not_found)
|
|
84
|
+
# => ArgumentError: Order::Place declares unknown error code :not_found.
|
|
85
|
+
# Declared codes: [:out_of_stock]
|
|
153
86
|
|
|
154
|
-
|
|
155
|
-
|
|
87
|
+
prop :email, 123
|
|
88
|
+
# => Literal::TypeError: expected a type, got 123 (Integer)
|
|
156
89
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
chat.ask("Place an order for 2 units of product #42")
|
|
90
|
+
once :nonexistent_prop
|
|
91
|
+
# => ArgumentError: Order::Place.once references unknown prop :nonexistent_prop.
|
|
92
|
+
# Declared props: [:customer, :product, :quantity, :note]
|
|
161
93
|
```
|
|
162
94
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
### Testing
|
|
95
|
+
### And more
|
|
166
96
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
```ruby
|
|
170
|
-
class PlaceOrderTest < Minitest::Test
|
|
171
|
-
testing Order::Place
|
|
172
|
-
|
|
173
|
-
def test_places_order
|
|
174
|
-
assert_operation(customer: customer.id, product: product.id, quantity: 2)
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def test_rejects_out_of_stock
|
|
178
|
-
assert_operation_error(:out_of_stock, customer: customer.id,
|
|
179
|
-
product: out_of_stock_product.id, quantity: 1)
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
```
|
|
97
|
+
[Ambient context](https://dex.razorjack.net/operation/context), [unified tracing](https://dex.razorjack.net/operation/tracing) with [Stripe-style IDs](https://dex.razorjack.net/utilities/prefixed-ids), [idempotency](https://dex.razorjack.net/operation/once), [async execution](https://dex.razorjack.net/operation/async), [advisory locks](https://dex.razorjack.net/operation/advisory-lock), [DB recording](https://dex.razorjack.net/operation/recording), [explain](https://dex.razorjack.net/operation/explain) for preflight checks, [callbacks](https://dex.razorjack.net/operation/callbacks), a [customizable pipeline](https://dex.razorjack.net/operation/pipeline), [registry & export](https://dex.razorjack.net/tooling/registry), and [LLM tool integration](https://dex.razorjack.net/tool/) for operations and queries.
|
|
183
98
|
|
|
184
99
|
## Events
|
|
185
100
|
|
|
186
|
-
Typed, immutable event objects with publish/subscribe, async dispatch, and causality tracing.
|
|
187
|
-
|
|
188
101
|
```ruby
|
|
189
102
|
class Order::Placed < Dex::Event
|
|
190
103
|
prop :order_id, Integer
|
|
191
104
|
prop :total, BigDecimal
|
|
192
|
-
prop? :coupon_code, String
|
|
193
105
|
end
|
|
194
106
|
|
|
195
107
|
class NotifyWarehouse < Dex::Event::Handler
|
|
@@ -197,175 +109,72 @@ class NotifyWarehouse < Dex::Event::Handler
|
|
|
197
109
|
retries 3
|
|
198
110
|
|
|
199
111
|
def perform
|
|
200
|
-
WarehouseApi.
|
|
112
|
+
WarehouseApi.reserve(event.order_id)
|
|
201
113
|
end
|
|
202
114
|
end
|
|
203
115
|
|
|
204
116
|
Order::Placed.publish(order_id: 1, total: 99.99)
|
|
205
117
|
```
|
|
206
118
|
|
|
207
|
-
### What you get out of the box
|
|
208
|
-
|
|
209
|
-
**Zero-config pub/sub** — define events and handlers, publish. No bus setup needed.
|
|
210
|
-
|
|
211
|
-
**Async by default** — handlers dispatched via ActiveJob. `sync: true` for inline. If ActiveJob is not loaded, async publish raises `LoadError`.
|
|
212
|
-
|
|
213
|
-
**Causality tracing** — link events in chains with shared `trace_id`:
|
|
214
|
-
|
|
215
|
-
```ruby
|
|
216
|
-
order_placed.trace do
|
|
217
|
-
Shipment::Reserved.publish(order_id: 1)
|
|
218
|
-
end
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
**Callbacks** — `before`, `after`, `around` hooks on handlers, same DSL as operations.
|
|
222
|
-
|
|
223
|
-
**Transactions** — opt-in `transaction` and `after_commit` for handlers that write to the database.
|
|
224
|
-
|
|
225
|
-
**Suppression**, optional **persistence**, **context capture**, and **retries** with exponential backoff.
|
|
226
|
-
|
|
227
|
-
### Testing
|
|
228
|
-
|
|
229
|
-
```ruby
|
|
230
|
-
class PlaceOrderTest < Minitest::Test
|
|
231
|
-
include Dex::Event::TestHelpers
|
|
232
|
-
|
|
233
|
-
def test_publishes_order_placed
|
|
234
|
-
capture_events do
|
|
235
|
-
Order::Place.call(customer: customer.id, product: product.id, quantity: 2)
|
|
236
|
-
assert_event_published(Order::Placed)
|
|
237
|
-
end
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
## Forms
|
|
243
|
-
|
|
244
|
-
Form objects with typed attributes, normalization, nested forms, and Rails form builder compatibility.
|
|
245
|
-
|
|
246
|
-
```ruby
|
|
247
|
-
class Employee::Form < Dex::Form
|
|
248
|
-
model Employee
|
|
249
|
-
|
|
250
|
-
attribute :first_name, :string
|
|
251
|
-
attribute :last_name, :string
|
|
252
|
-
attribute :email, :string
|
|
253
|
-
|
|
254
|
-
normalizes :email, with: -> { _1&.strip&.downcase.presence }
|
|
255
|
-
|
|
256
|
-
validates :email, presence: true, uniqueness: true
|
|
257
|
-
validates :first_name, :last_name, presence: true
|
|
258
|
-
|
|
259
|
-
nested_one :address do
|
|
260
|
-
attribute :street, :string
|
|
261
|
-
attribute :city, :string
|
|
262
|
-
validates :street, :city, presence: true
|
|
263
|
-
end
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
form = Employee::Form.new(email: " ALICE@EXAMPLE.COM ", first_name: "Alice", last_name: "Smith")
|
|
267
|
-
form.email # => "alice@example.com"
|
|
268
|
-
form.valid?
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
### What you get out of the box
|
|
272
|
-
|
|
273
|
-
**ActiveModel attributes** with type casting, normalization, and full Rails validation DSL.
|
|
274
|
-
|
|
275
|
-
**Nested forms** — `nested_one` and `nested_many` with automatic Hash coercion, `_destroy` support, and error propagation:
|
|
276
|
-
|
|
277
|
-
```ruby
|
|
278
|
-
nested_many :emergency_contacts do
|
|
279
|
-
attribute :name, :string
|
|
280
|
-
attribute :phone, :string
|
|
281
|
-
validates :name, :phone, presence: true
|
|
282
|
-
end
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
**Rails form compatibility** — works with `form_with`, `fields_for`, and nested attributes out of the box.
|
|
286
|
-
|
|
287
|
-
**Uniqueness validation** against the database, with scope, case-sensitivity, and current-record exclusion.
|
|
288
|
-
|
|
289
|
-
**Multi-model forms** — when a form spans Employee, Department, and Address, define a `.for` convention method to map records and a `#save` method that delegates to a `Dex::Operation`:
|
|
290
|
-
|
|
291
|
-
```ruby
|
|
292
|
-
def save
|
|
293
|
-
return false unless valid?
|
|
294
|
-
|
|
295
|
-
case operation.safe.call
|
|
296
|
-
in Ok then true
|
|
297
|
-
in Err => e then errors.add(:base, e.message) and false
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
|
-
```
|
|
301
|
-
|
|
302
119
|
## Queries
|
|
303
120
|
|
|
304
|
-
Declarative query objects for filtering and sorting ActiveRecord and Mongoid scopes.
|
|
305
|
-
|
|
306
121
|
```ruby
|
|
307
122
|
class Order::Query < Dex::Query
|
|
308
123
|
scope { Order.all }
|
|
309
124
|
|
|
310
125
|
prop? :status, String
|
|
311
|
-
prop? :customer, _Ref(Customer)
|
|
312
126
|
prop? :total_min, Integer
|
|
313
127
|
|
|
314
128
|
filter :status
|
|
315
|
-
filter :customer
|
|
316
129
|
filter :total_min, :gte, column: :total
|
|
317
130
|
|
|
318
131
|
sort :created_at, :total, default: "-created_at"
|
|
319
132
|
end
|
|
320
133
|
|
|
321
|
-
|
|
134
|
+
Order::Query.call(status: "pending", sort: "-total")
|
|
322
135
|
```
|
|
323
136
|
|
|
324
|
-
|
|
137
|
+
## Forms
|
|
325
138
|
|
|
326
|
-
|
|
139
|
+
```ruby
|
|
140
|
+
class Order::Form < Dex::Form
|
|
141
|
+
field :customer_email, :string
|
|
142
|
+
field? :note, :string
|
|
327
143
|
|
|
328
|
-
|
|
144
|
+
nested_many :line_items do
|
|
145
|
+
field :product_id, :integer
|
|
146
|
+
field :quantity, :integer, default: 1
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
```
|
|
329
150
|
|
|
330
|
-
|
|
151
|
+
## Testing
|
|
331
152
|
|
|
332
153
|
```ruby
|
|
333
|
-
class
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
154
|
+
class PlaceOrderTest < Minitest::Test
|
|
155
|
+
testing Order::Place
|
|
156
|
+
|
|
157
|
+
def test_places_order
|
|
158
|
+
result = call_operation(customer: customer.id, product: product.id, quantity: 2)
|
|
159
|
+
assert_ok result
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def test_rejects_out_of_stock
|
|
163
|
+
result = call_operation(customer: customer.id,
|
|
164
|
+
product: out_of_stock_product.id, quantity: 1)
|
|
165
|
+
assert_err result, :out_of_stock
|
|
337
166
|
end
|
|
338
167
|
end
|
|
339
168
|
```
|
|
340
169
|
|
|
341
|
-
**Form binding** — works with `form_with` for search forms. Queries respond to `model_name`, `param_key`, `persisted?`, and `to_params`.
|
|
342
|
-
|
|
343
|
-
**Scope injection** — narrow the base scope at call time without modifying the query class.
|
|
344
|
-
|
|
345
170
|
## Installation
|
|
346
171
|
|
|
347
172
|
```ruby
|
|
348
173
|
gem "dexkit"
|
|
349
174
|
```
|
|
350
175
|
|
|
351
|
-
## Documentation
|
|
352
|
-
|
|
353
|
-
Full documentation at **[dex.razorjack.net](https://dex.razorjack.net)**.
|
|
354
|
-
|
|
355
|
-
## AI Coding Assistant Setup
|
|
356
|
-
|
|
357
|
-
dexkit ships LLM-optimized guides. Install them as `AGENTS.md` files in your app directories so AI coding agents automatically know the API:
|
|
358
|
-
|
|
359
|
-
```bash
|
|
360
|
-
rake dex:guides
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
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).
|
|
364
|
-
|
|
365
|
-
Override paths for non-standard directory names:
|
|
366
|
-
|
|
367
176
|
```bash
|
|
368
|
-
rake dex:guides
|
|
177
|
+
rake dex:guides # install LLM-optimized guides as AGENTS.md in your app directories
|
|
369
178
|
```
|
|
370
179
|
|
|
371
180
|
## License
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
dexkit (0.
|
|
4
|
+
dexkit (0.11.0)
|
|
5
5
|
activemodel (>= 6.1)
|
|
6
6
|
literal (~> 1.9)
|
|
7
7
|
zeitwerk (~> 2.6)
|
|
@@ -180,7 +180,7 @@ CHECKSUMS
|
|
|
180
180
|
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
|
181
181
|
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
|
|
182
182
|
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
|
|
183
|
-
dexkit (0.
|
|
183
|
+
dexkit (0.11.0)
|
|
184
184
|
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
|
185
185
|
erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
|
|
186
186
|
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
|
data/guides/llm/EVENT.md
CHANGED
|
@@ -30,9 +30,9 @@ class UserCreated < Dex::Event
|
|
|
30
30
|
end
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Reserved names: `id`, `timestamp`, `trace_id`, `caused_by_id`, `caused_by`, `context`, `publish`, `metadata`, `sync`.
|
|
33
|
+
Reserved names: `id`, `timestamp`, `trace_id`, `caused_by_id`, `caused_by`, `event_ancestry`, `context`, `publish`, `metadata`, `sync`.
|
|
34
34
|
|
|
35
|
-
Events are frozen after creation. Each gets auto-generated `id` (
|
|
35
|
+
Events are frozen after creation. Each gets auto-generated `id` (`ev_...`), `timestamp` (UTC), `trace_id` (`tr_...` by default), optional `caused_by_id`, and `event_ancestry` (ordered ancestor event IDs).
|
|
36
36
|
|
|
37
37
|
### Literal Types Cheatsheet
|
|
38
38
|
|
|
@@ -72,10 +72,11 @@ class NotifyWarehouse < Dex::Event::Handler
|
|
|
72
72
|
def perform
|
|
73
73
|
event # accessor — the event instance
|
|
74
74
|
event.order_id # typed props
|
|
75
|
-
event.id #
|
|
75
|
+
event.id # prefixed event ID (ev_...)
|
|
76
76
|
event.timestamp # Time (UTC)
|
|
77
77
|
event.caused_by_id # parent event ID (if traced)
|
|
78
|
-
event.trace_id # shared trace
|
|
78
|
+
event.trace_id # shared trace / correlation ID
|
|
79
|
+
event.event_ancestry # ordered ancestor event IDs
|
|
79
80
|
end
|
|
80
81
|
end
|
|
81
82
|
```
|
|
@@ -179,22 +180,20 @@ Default handler pipeline: `[:transaction, :callback]`.
|
|
|
179
180
|
|
|
180
181
|
## Tracing (Causality)
|
|
181
182
|
|
|
182
|
-
|
|
183
|
+
Events participate in the unified `Dex::Trace` used by operations and handlers. All events in a trace share the same `trace_id`.
|
|
183
184
|
|
|
184
185
|
```ruby
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
order_placed.trace do
|
|
189
|
-
InventoryReserved.publish(order_id: 1) # caused_by_id = order_placed.id
|
|
190
|
-
ShippingRequested.publish(order_id: 1) # same trace_id
|
|
186
|
+
# Start a trace at the request/job boundary
|
|
187
|
+
Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
|
|
188
|
+
OrderPlaced.publish(order_id: 1, total: 99.99)
|
|
191
189
|
end
|
|
192
190
|
|
|
193
|
-
#
|
|
191
|
+
# Explicit event-to-event causality
|
|
192
|
+
order_placed = OrderPlaced.new(order_id: 1, total: 99.99)
|
|
194
193
|
InventoryReserved.publish(order_id: 1, caused_by: order_placed)
|
|
195
194
|
```
|
|
196
195
|
|
|
197
|
-
|
|
196
|
+
When an event is published inside a handler, the handler's event becomes the cause automatically. Explicit `caused_by:` sets `caused_by_id` and appends to `event_ancestry`.
|
|
198
197
|
|
|
199
198
|
---
|
|
200
199
|
|
|
@@ -218,12 +217,16 @@ Store events to database when configured:
|
|
|
218
217
|
|
|
219
218
|
```ruby
|
|
220
219
|
Dex.configure do |c|
|
|
221
|
-
c.event_store = EventRecord #
|
|
220
|
+
c.event_store = EventRecord # Dex passes trace fields when the model supports them
|
|
222
221
|
end
|
|
223
222
|
```
|
|
224
223
|
|
|
225
224
|
```ruby
|
|
226
|
-
create_table :event_records do |t|
|
|
225
|
+
create_table :event_records, id: :string do |t|
|
|
226
|
+
t.string :trace_id
|
|
227
|
+
t.string :actor_type
|
|
228
|
+
t.string :actor_id
|
|
229
|
+
t.jsonb :trace
|
|
227
230
|
t.string :event_type
|
|
228
231
|
t.jsonb :payload
|
|
229
232
|
t.jsonb :metadata
|
|
@@ -238,6 +241,11 @@ class EventRecord
|
|
|
238
241
|
include Mongoid::Document
|
|
239
242
|
include Mongoid::Timestamps
|
|
240
243
|
|
|
244
|
+
field :_id, type: String
|
|
245
|
+
field :trace_id, type: String
|
|
246
|
+
field :actor_type, type: String
|
|
247
|
+
field :actor_id, type: String
|
|
248
|
+
field :trace, type: Array
|
|
241
249
|
field :event_type, type: String
|
|
242
250
|
field :payload, type: Hash
|
|
243
251
|
field :metadata, type: Hash
|
|
@@ -282,10 +290,6 @@ end
|
|
|
282
290
|
|
|
283
291
|
**Introspection:** `MyEvent.context_mappings` returns the mapping hash.
|
|
284
292
|
|
|
285
|
-
### Legacy Context (Metadata)
|
|
286
|
-
|
|
287
|
-
The older `event_context` / `restore_event_context` configuration captures arbitrary metadata at publish time and restores it before async handler execution. Both mechanisms coexist.
|
|
288
|
-
|
|
289
293
|
---
|
|
290
294
|
|
|
291
295
|
## Configuration
|
|
@@ -294,12 +298,10 @@ The older `event_context` / `restore_event_context` configuration captures arbit
|
|
|
294
298
|
# config/initializers/dexkit.rb
|
|
295
299
|
Dex.configure do |config|
|
|
296
300
|
config.event_store = nil # model for persistence (default: nil)
|
|
297
|
-
config.event_context = nil # -> { Hash } lambda (default: nil)
|
|
298
|
-
config.restore_event_context = nil # ->(ctx) { ... } lambda (default: nil)
|
|
299
301
|
end
|
|
300
302
|
```
|
|
301
303
|
|
|
302
|
-
Everything works without configuration.
|
|
304
|
+
Everything works without configuration.
|
|
303
305
|
|
|
304
306
|
---
|
|
305
307
|
|
|
@@ -381,10 +383,7 @@ class CreateOrderTest < Minitest::Test
|
|
|
381
383
|
def test_trace_chain
|
|
382
384
|
capture_events do
|
|
383
385
|
parent = OrderPlaced.new(order_id: 1, total: 99.99)
|
|
384
|
-
|
|
385
|
-
parent.trace do
|
|
386
|
-
InventoryReserved.publish(order_id: 1)
|
|
387
|
-
end
|
|
386
|
+
InventoryReserved.publish(order_id: 1, caused_by: parent)
|
|
388
387
|
|
|
389
388
|
child = _dex_published_events.last
|
|
390
389
|
assert_event_trace(parent, child)
|