dexkit 0.5.0 → 0.6.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: 4a120052500fd0c1889117f87d0d4260b40eecbc82b31ad50b9e6fc97f99efd9
4
- data.tar.gz: f140a685dd0825fbd675f5575d1a44d5679598ad7a2d302a65a3f22bae268ce0
3
+ metadata.gz: f8d7274727e727937b55704faa81e4da8a54bb3af7758b11e9144e59fb2dba14
4
+ data.tar.gz: cb899ee88b5e83ec57a913f0173036ac228f672c7850d88e95209f4804218f96
5
5
  SHA512:
6
- metadata.gz: f2186829d67bb0c19d71e9a81021a94efc56cb90dc943d11ad62a9306833ed08183691eb3196fc8dec950dcd3e7a624be5e67eda00abb3e74a444404b7947267
7
- data.tar.gz: 5314488039b706abbbf68bc3ca7fb3e2445718286a2b21ce7346dfae60f8cdb5ca68f89369b3b54fe1a756826e95a76f22bac973d75494a10c4605e8210e1d94
6
+ metadata.gz: 7758fe2c0d5b9cdd2bcf62aa510c4146ef29898335ddffb118abb35409367d5f7cbd1aeba6365f865bb36d4011b1030f136cfb9b6eee27fb3aa3d06d11eeb21e
7
+ data.tar.gz: e437a4af9b4aa0c121245de966775cd3be0bad5ceb5918f139cfaf059545159631797b630a9b21f3add8855308c832f5846efb78f97f36141042f40d28b32341
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-03-07
4
+
5
+ ### Added
6
+
7
+ - **Handler callbacks** — `Dex::Event::Handler` now supports `before`, `after`, and `around` callbacks, same DSL as operations
8
+ - **Handler transactions** — `Dex::Event::Handler` supports `transaction` and `after_commit` DSL. Transactions are disabled by default on handlers (opt in with `transaction`)
9
+ - **Handler pipeline** — `Dex::Event::Handler` supports `use` for adding custom wrapper modules, same as operations
10
+ - **`Dex::Executable`** — shared execution skeleton (Settings, Pipeline, `use` DSL) extracted from Operation and used by both Operation and Handler
11
+
12
+ ### Breaking
13
+
14
+ - **`transaction false` fully opts out** — operations with `transaction false` no longer route `after_commit` through the database adapter. Previously, `after_commit` on a non-transactional operation would still detect and defer to ambient database transactions (e.g., `ActiveRecord::Base.transaction { op.call }`); now it fires callbacks directly after the pipeline completes. To restore ambient transaction awareness, remove `transaction false` or use `transaction` (enabled)
15
+
3
16
  ## 0.5.0 - 2026-03-05
4
17
 
5
18
  ### Added
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Dexkit
1
+ # dexkit
2
2
 
3
3
  Rails patterns toolbelt. Equip to gain +4 DEX.
4
4
 
@@ -127,6 +127,10 @@ order_placed.trace do
127
127
  end
128
128
  ```
129
129
 
130
+ **Callbacks** — `before`, `after`, `around` hooks on handlers, same DSL as operations.
131
+
132
+ **Transactions** — opt-in `transaction` and `after_commit` for handlers that write to the database.
133
+
130
134
  **Suppression**, optional **persistence**, **context capture**, and **retries** with exponential backoff.
131
135
 
132
136
  ### Testing
@@ -259,7 +263,7 @@ Full documentation at **[dex.razorjack.net](https://dex.razorjack.net)**.
259
263
 
260
264
  ## AI Coding Assistant Setup
261
265
 
262
- Dexkit ships LLM-optimized guides. Copy them into your project so AI agents automatically know the API:
266
+ dexkit ships LLM-optimized guides. Copy them into your project so AI agents automatically know the API:
263
267
 
264
268
  ```bash
265
269
  cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
data/guides/llm/EVENT.md CHANGED
@@ -111,6 +111,70 @@ end
111
111
 
112
112
  When retries exhausted, exception propagates normally.
113
113
 
114
+ ### Callbacks
115
+
116
+ Same `before`/`after`/`around` DSL as operations:
117
+
118
+ ```ruby
119
+ class ProcessPayment < Dex::Event::Handler
120
+ on PaymentReceived
121
+
122
+ before :log_start
123
+ after :log_end
124
+
125
+ around ->(cont) {
126
+ Instrumentation.measure("payment") { cont.call }
127
+ }
128
+
129
+ def perform
130
+ PaymentGateway.charge(event.amount)
131
+ end
132
+
133
+ private
134
+
135
+ def log_start = Rails.logger.info("Processing payment...")
136
+ def log_end = Rails.logger.info("Payment processed")
137
+ end
138
+ ```
139
+
140
+ Callbacks are inherited. Child handlers run parent callbacks first.
141
+
142
+ ### Transactions
143
+
144
+ Handlers can opt into database transactions and deferred `after_commit`:
145
+
146
+ ```ruby
147
+ class FulfillOrder < Dex::Event::Handler
148
+ on OrderPlaced
149
+ transaction
150
+
151
+ def perform
152
+ order = Order.find(event.order_id)
153
+ order.update!(status: "fulfilled")
154
+
155
+ after_commit { Shipment::Ship.new(order_id: order.id).async.call }
156
+ end
157
+ end
158
+ ```
159
+
160
+ Transactions are **disabled by default** on handlers (unlike operations). Opt in with `transaction`. The `after_commit` block defers until the transaction commits; on exception, deferred blocks are discarded.
161
+
162
+ ### Custom Pipeline
163
+
164
+ Handlers support the same `use` DSL as operations for adding custom wrappers:
165
+
166
+ ```ruby
167
+ class Monitored < Dex::Event::Handler
168
+ use MetricsWrapper, as: :metrics
169
+
170
+ def perform
171
+ # ...
172
+ end
173
+ end
174
+ ```
175
+
176
+ Default handler pipeline: `[:transaction, :callback]`.
177
+
114
178
  ---
115
179
 
116
180
  ## Tracing (Causality)
@@ -347,7 +347,7 @@ record params: false # response only
347
347
 
348
348
  Recording happens inside the transaction — rolled back on `error!`/`assert!`. Missing columns silently skipped.
349
349
 
350
- When both async and recording are enabled, Dexkit automatically stores only the record ID in the job payload instead of full params. The record tracks `status` (pending → running → done/failed) and `error` (code or exception class name).
350
+ When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params. The record tracks `status` (pending → running → done/failed) and `error` (code or exception class name).
351
351
 
352
352
  ---
353
353
 
@@ -3,6 +3,8 @@
3
3
  module Dex
4
4
  class Event
5
5
  class Handler
6
+ include Dex::Executable
7
+
6
8
  attr_reader :event
7
9
 
8
10
  def self.on(*event_classes)
@@ -36,7 +38,7 @@ module Dex
36
38
  def self._event_handle(event)
37
39
  instance = new
38
40
  instance.instance_variable_set(:@event, event)
39
- instance.perform
41
+ instance.send(:call)
40
42
  end
41
43
 
42
44
  def self._event_handle_from_payload(event_class_name, payload, metadata_hash)
@@ -69,6 +71,23 @@ module Dex
69
71
  end
70
72
  end
71
73
 
74
+ use TransactionWrapper
75
+ use CallbackWrapper
76
+
77
+ transaction false
78
+ private :call
79
+
80
+ # Guard must be defined after `include Executable` (which defines #call).
81
+ def self.method_added(method_name)
82
+ super
83
+
84
+ if method_name == :call
85
+ raise ArgumentError, "#{name || "Handler"} must not define #call — define #perform instead"
86
+ end
87
+
88
+ private :perform if method_name == :perform
89
+ end
90
+
72
91
  def perform
73
92
  raise NotImplementedError, "#{self.class.name} must implement #perform"
74
93
  end
data/lib/dex/event.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  # Modules loaded before class body (no reference to Dex::Event needed)
6
4
  require_relative "event/execution_state"
7
5
  require_relative "event/metadata"
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module Executable
5
+ def self.included(base)
6
+ base.include(Dex::Settings)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def inherited(subclass)
12
+ subclass.instance_variable_set(:@_pipeline, pipeline.dup)
13
+ super
14
+ end
15
+
16
+ def pipeline
17
+ @_pipeline ||= Pipeline.new
18
+ end
19
+
20
+ def use(mod, as: nil, wrap: nil, before: nil, after: nil, at: nil)
21
+ step_name = as || _derive_step_name(mod)
22
+ wrap_method = wrap || :"_#{step_name}_wrap"
23
+ pipeline.add(step_name, method: wrap_method, before: before, after: after, at: at)
24
+ include mod
25
+ end
26
+
27
+ private
28
+
29
+ def _derive_step_name(mod)
30
+ base = mod.name&.split("::")&.last
31
+ raise ArgumentError, "anonymous modules require explicit as: parameter" unless base
32
+
33
+ base.sub(/Wrapper\z/, "")
34
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
35
+ .downcase
36
+ .to_sym
37
+ end
38
+ end
39
+
40
+ def call
41
+ self.class.pipeline.execute(self) { perform }
42
+ end
43
+ end
44
+ end
@@ -121,7 +121,8 @@ module Dex
121
121
  return if callbacks.empty?
122
122
 
123
123
  flush = -> { callbacks.each(&:call) }
124
- adapter = _transaction_adapter
124
+ enabled = self.class.settings_for(:transaction).fetch(:enabled, true)
125
+ adapter = enabled && _transaction_adapter
125
126
  if adapter
126
127
  adapter.after_commit(&flush)
127
128
  else
data/lib/dex/operation.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Wrapper modules (loaded before class body so `include`/`use` can find them)
4
- require_relative "operation/settings"
5
4
  require_relative "operation/result_wrapper"
6
5
  require_relative "operation/record_wrapper"
7
6
  require_relative "operation/transaction_wrapper"
@@ -11,9 +10,6 @@ require_relative "operation/safe_wrapper"
11
10
  require_relative "operation/rescue_wrapper"
12
11
  require_relative "operation/callback_wrapper"
13
12
 
14
- # Pipeline (referenced inside class body)
15
- require_relative "operation/pipeline"
16
-
17
13
  module Dex
18
14
  class Operation
19
15
  Halt = Struct.new(:type, :value, :error_code, :error_message, :error_details, keyword_init: true) do
@@ -46,6 +42,7 @@ module Dex
46
42
 
47
43
  RESERVED_PROP_NAMES = %i[call perform async safe initialize].to_set.freeze
48
44
 
45
+ include Executable
49
46
  include PropsSetup
50
47
  include TypeCoercion
51
48
 
@@ -60,22 +57,6 @@ module Dex
60
57
  )
61
58
  end
62
59
 
63
- def inherited(subclass)
64
- subclass.instance_variable_set(:@_pipeline, pipeline.dup)
65
- super
66
- end
67
-
68
- def pipeline
69
- @_pipeline ||= Pipeline.new
70
- end
71
-
72
- def use(mod, as: nil, wrap: nil, before: nil, after: nil, at: nil)
73
- step_name = as || _derive_step_name(mod)
74
- wrap_method = wrap || :"_#{step_name}_wrap"
75
- pipeline.add(step_name, method: wrap_method, before: before, after: after, at: at)
76
- include mod
77
- end
78
-
79
60
  private
80
61
 
81
62
  def _contract_params
@@ -85,25 +66,11 @@ module Dex
85
66
  hash[prop.name] = prop.type
86
67
  end
87
68
  end
88
-
89
- def _derive_step_name(mod)
90
- base = mod.name&.split("::")&.last
91
- raise ArgumentError, "anonymous modules require explicit as: parameter" unless base
92
-
93
- base.sub(/Wrapper\z/, "")
94
- .gsub(/([a-z])([A-Z])/, '\1_\2')
95
- .downcase
96
- .to_sym
97
- end
98
69
  end
99
70
 
100
71
  def perform(*, **)
101
72
  end
102
73
 
103
- def call
104
- self.class.pipeline.execute(self) { perform }
105
- end
106
-
107
74
  def self.method_added(method_name)
108
75
  super
109
76
  return unless method_name == :perform
@@ -117,7 +84,6 @@ module Dex
117
84
  new(**kwargs).call
118
85
  end
119
86
 
120
- include Settings
121
87
  include AsyncWrapper
122
88
  include SafeWrapper
123
89
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Pipeline
5
+ Step = Data.define(:name, :method)
6
+
7
+ def initialize(steps = [])
8
+ @steps = steps.dup
9
+ end
10
+
11
+ def dup
12
+ self.class.new(@steps)
13
+ end
14
+
15
+ def steps
16
+ @steps.dup.freeze
17
+ end
18
+
19
+ def add(name, method: :"_#{name}_wrap", before: nil, after: nil, at: nil)
20
+ validate_positioning!(before, after, at)
21
+ step = Step.new(name: name, method: method)
22
+
23
+ if at == :outer then @steps.unshift(step)
24
+ elsif at == :inner then @steps.push(step)
25
+ elsif before then @steps.insert(find_index!(before), step)
26
+ elsif after then @steps.insert(find_index!(after) + 1, step)
27
+ else @steps.push(step)
28
+ end
29
+ self
30
+ end
31
+
32
+ def remove(name)
33
+ @steps.reject! { |s| s.name == name }
34
+ self
35
+ end
36
+
37
+ def execute(target)
38
+ chain = @steps.reverse_each.reduce(-> { yield }) do |next_step, step|
39
+ -> { target.send(step.method, &next_step) }
40
+ end
41
+ chain.call
42
+ end
43
+
44
+ private
45
+
46
+ def validate_positioning!(before, after, at)
47
+ count = [before, after, at].count { |v| !v.nil? }
48
+ raise ArgumentError, "specify only one of before:, after:, at:" if count > 1
49
+ raise ArgumentError, "at: must be :outer or :inner" if at && !%i[outer inner].include?(at)
50
+ end
51
+
52
+ def find_index!(name)
53
+ idx = @steps.index { |s| s.name == name }
54
+ raise ArgumentError, "pipeline step :#{name} not found" unless idx
55
+ idx
56
+ end
57
+ end
58
+ end
data/lib/dex/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dex
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/dexkit.rb CHANGED
@@ -15,6 +15,9 @@ require_relative "dex/ref_type"
15
15
  require_relative "dex/type_coercion"
16
16
  require_relative "dex/props_setup"
17
17
  require_relative "dex/error"
18
+ require_relative "dex/settings"
19
+ require_relative "dex/pipeline"
20
+ require_relative "dex/executable"
18
21
  require_relative "dex/operation"
19
22
  require_relative "dex/event"
20
23
  require_relative "dex/form"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dexkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jacek Galanciak
@@ -177,6 +177,7 @@ files:
177
177
  - lib/dex/event/trace.rb
178
178
  - lib/dex/event_test_helpers.rb
179
179
  - lib/dex/event_test_helpers/assertions.rb
180
+ - lib/dex/executable.rb
180
181
  - lib/dex/form.rb
181
182
  - lib/dex/form/nesting.rb
182
183
  - lib/dex/form/uniqueness_validator.rb
@@ -188,21 +189,21 @@ files:
188
189
  - lib/dex/operation/jobs.rb
189
190
  - lib/dex/operation/lock_wrapper.rb
190
191
  - lib/dex/operation/outcome.rb
191
- - lib/dex/operation/pipeline.rb
192
192
  - lib/dex/operation/record_backend.rb
193
193
  - lib/dex/operation/record_wrapper.rb
194
194
  - lib/dex/operation/rescue_wrapper.rb
195
195
  - lib/dex/operation/result_wrapper.rb
196
196
  - lib/dex/operation/safe_wrapper.rb
197
- - lib/dex/operation/settings.rb
198
197
  - lib/dex/operation/transaction_adapter.rb
199
198
  - lib/dex/operation/transaction_wrapper.rb
199
+ - lib/dex/pipeline.rb
200
200
  - lib/dex/props_setup.rb
201
201
  - lib/dex/query.rb
202
202
  - lib/dex/query/backend.rb
203
203
  - lib/dex/query/filtering.rb
204
204
  - lib/dex/query/sorting.rb
205
205
  - lib/dex/ref_type.rb
206
+ - lib/dex/settings.rb
206
207
  - lib/dex/test_helpers.rb
207
208
  - lib/dex/test_helpers/assertions.rb
208
209
  - lib/dex/test_helpers/execution.rb
@@ -211,14 +212,15 @@ files:
211
212
  - lib/dex/type_coercion.rb
212
213
  - lib/dex/version.rb
213
214
  - lib/dexkit.rb
214
- homepage: https://github.com/razorjack/dexkit
215
+ homepage: https://dex.razorjack.net/
215
216
  licenses:
216
217
  - MIT
217
218
  metadata:
218
219
  allowed_push_host: https://rubygems.org
219
- homepage_uri: https://github.com/razorjack/dexkit
220
+ homepage_uri: https://dex.razorjack.net/
220
221
  source_code_uri: https://github.com/razorjack/dexkit
221
222
  changelog_uri: https://github.com/razorjack/dexkit/blob/master/CHANGELOG.md
223
+ documentation_uri: https://dex.razorjack.net/
222
224
  rdoc_options: []
223
225
  require_paths:
224
226
  - lib
@@ -235,5 +237,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
235
237
  requirements: []
236
238
  rubygems_version: 4.0.3
237
239
  specification_version: 4
238
- summary: 'Dexkit: Rails Patterns Toolbelt. Equip to gain +4 DEX'
240
+ summary: 'dexkit: Rails Patterns Toolbelt. Equip to gain +4 DEX'
239
241
  test_files: []
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Dex
4
- class Operation
5
- class Pipeline
6
- Step = Data.define(:name, :method)
7
-
8
- def initialize(steps = [])
9
- @steps = steps.dup
10
- end
11
-
12
- def dup
13
- self.class.new(@steps)
14
- end
15
-
16
- def steps
17
- @steps.dup.freeze
18
- end
19
-
20
- def add(name, method: :"_#{name}_wrap", before: nil, after: nil, at: nil)
21
- validate_positioning!(before, after, at)
22
- step = Step.new(name: name, method: method)
23
-
24
- if at == :outer then @steps.unshift(step)
25
- elsif at == :inner then @steps.push(step)
26
- elsif before then @steps.insert(find_index!(before), step)
27
- elsif after then @steps.insert(find_index!(after) + 1, step)
28
- else @steps.push(step)
29
- end
30
- self
31
- end
32
-
33
- def remove(name)
34
- @steps.reject! { |s| s.name == name }
35
- self
36
- end
37
-
38
- def execute(operation)
39
- chain = @steps.reverse_each.reduce(-> { yield }) do |next_step, step|
40
- -> { operation.send(step.method, &next_step) }
41
- end
42
- chain.call
43
- end
44
-
45
- private
46
-
47
- def validate_positioning!(before, after, at)
48
- count = [before, after, at].count { |v| !v.nil? }
49
- raise ArgumentError, "specify only one of before:, after:, at:" if count > 1
50
- raise ArgumentError, "at: must be :outer or :inner" if at && !%i[outer inner].include?(at)
51
- end
52
-
53
- def find_index!(name)
54
- idx = @steps.index { |s| s.name == name }
55
- raise ArgumentError, "pipeline step :#{name} not found" unless idx
56
- idx
57
- end
58
- end
59
- end
60
- end
File without changes