dexkit 0.5.0 → 0.7.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.
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ # Shared context DSL for Operation and Event.
5
+ #
6
+ # Maps declared props to ambient context keys so they can be auto-filled
7
+ # from Dex.context when not passed explicitly as kwargs.
8
+ module ContextSetup
9
+ extend Dex::Concern
10
+
11
+ module ClassMethods
12
+ def context(*names, **mappings)
13
+ names.each do |name|
14
+ unless name.is_a?(Symbol)
15
+ raise ArgumentError, "context shorthand must be a Symbol, got: #{name.inspect}"
16
+ end
17
+ mappings[name] = name
18
+ end
19
+
20
+ raise ArgumentError, "context requires at least one mapping" if mappings.empty?
21
+
22
+ mappings.each do |prop_name, context_key|
23
+ unless _context_prop_declared?(prop_name)
24
+ raise ArgumentError,
25
+ "context references undeclared prop :#{prop_name}. Declare the prop before calling context."
26
+ end
27
+ unless context_key.is_a?(Symbol)
28
+ raise ArgumentError,
29
+ "context key must be a Symbol, got: #{context_key.inspect} for prop :#{prop_name}"
30
+ end
31
+ end
32
+
33
+ _context_own.merge!(mappings)
34
+ end
35
+
36
+ def context_mappings
37
+ parent = superclass.respond_to?(:context_mappings) ? superclass.context_mappings : {}
38
+ parent.merge(_context_own)
39
+ end
40
+
41
+ def new(**kwargs)
42
+ mappings = context_mappings
43
+ unless mappings.empty?
44
+ ambient = Dex.context
45
+ mappings.each do |prop_name, context_key|
46
+ next if kwargs.key?(prop_name)
47
+ kwargs[prop_name] = ambient[context_key] if ambient.key?(context_key)
48
+ end
49
+ end
50
+ super
51
+ end
52
+
53
+ private
54
+
55
+ def _context_own
56
+ @_context_own_mappings ||= {}
57
+ end
58
+
59
+ def _context_prop_declared?(name)
60
+ respond_to?(:literal_properties) && literal_properties.any? { |p| p.name == name }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -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"
@@ -17,6 +15,7 @@ module Dex
17
15
 
18
16
  include PropsSetup
19
17
  include TypeCoercion
18
+ include ContextSetup
20
19
 
21
20
  def self._warn(message)
22
21
  Dex.warn("Event: #{message}")
@@ -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
@@ -21,7 +21,9 @@ module Dex
21
21
 
22
22
  def enqueue_direct_job
23
23
  job = apply_options(Operation::DirectJob)
24
- job.perform_later(class_name: operation_class_name, params: serialized_params)
24
+ payload = { class_name: operation_class_name, params: serialized_params }
25
+ apply_once_payload!(payload)
26
+ job.perform_later(**payload)
25
27
  end
26
28
 
27
29
  def enqueue_record_job
@@ -32,7 +34,9 @@ module Dex
32
34
  )
33
35
  begin
34
36
  job = apply_options(Operation::RecordJob)
35
- job.perform_later(class_name: operation_class_name, record_id: record.id.to_s)
37
+ payload = { class_name: operation_class_name, record_id: record.id.to_s }
38
+ apply_once_payload!(payload)
39
+ job.perform_later(**payload)
36
40
  rescue => e
37
41
  begin
38
42
  record.destroy
@@ -68,6 +72,18 @@ module Dex
68
72
  raise LoadError, "ActiveJob is required for async operations. Add 'activejob' to your Gemfile."
69
73
  end
70
74
 
75
+ def apply_once_payload!(payload)
76
+ return unless @operation.instance_variable_defined?(:@_once_key_explicit) &&
77
+ @operation.instance_variable_get(:@_once_key_explicit)
78
+
79
+ once_key = @operation.instance_variable_get(:@_once_key)
80
+ if once_key
81
+ payload[:once_key] = once_key
82
+ else
83
+ payload[:once_bypass] = true
84
+ end
85
+ end
86
+
71
87
  def merged_options
72
88
  @operation.class.settings_for(:async).merge(@runtime_options)
73
89
  end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module GuardWrapper
5
+ extend Dex::Concern
6
+
7
+ GuardDefinition = Data.define(:name, :message, :requires, :block)
8
+
9
+ module ClassMethods
10
+ def guard(code, message = nil, requires: nil, &block)
11
+ raise ArgumentError, "guard code must be a Symbol, got: #{code.inspect}" unless code.is_a?(Symbol)
12
+ raise ArgumentError, "guard requires a block" unless block
13
+
14
+ requires = _guard_normalize_requires!(code, requires)
15
+ _guard_validate_unique!(code)
16
+
17
+ _guard_own << GuardDefinition.new(name: code, message: message, requires: requires, block: block)
18
+
19
+ error(code) if respond_to?(:error)
20
+ end
21
+
22
+ def _guard_list
23
+ parent = superclass.respond_to?(:_guard_list) ? superclass._guard_list : []
24
+ parent + _guard_own
25
+ end
26
+
27
+ def callable(**kwargs)
28
+ instance = new(**kwargs)
29
+ failures = instance.send(:_guard_evaluate)
30
+ if failures.empty?
31
+ Operation::Ok.new(nil)
32
+ else
33
+ first = failures.first
34
+ error = Dex::Error.new(first[:guard], first[:message], details: failures)
35
+ Operation::Err.new(error)
36
+ end
37
+ end
38
+
39
+ def callable?(*args, **kwargs)
40
+ if args.size > 1
41
+ raise ArgumentError, "callable? accepts at most one guard name, got #{args.size} arguments"
42
+ end
43
+
44
+ if args.first
45
+ guard_name = args.first
46
+ unless guard_name.is_a?(Symbol)
47
+ raise ArgumentError, "guard name must be a Symbol, got: #{guard_name.inspect}"
48
+ end
49
+ unless _guard_list.any? { |g| g.name == guard_name }
50
+ raise ArgumentError, "unknown guard :#{guard_name}. Declared: #{_guard_list.map(&:name).map(&:inspect).join(", ")}"
51
+ end
52
+ instance = new(**kwargs)
53
+ failures = instance.send(:_guard_evaluate)
54
+ failures.none? { |f| f[:guard] == guard_name }
55
+ else
56
+ callable(**kwargs).ok?
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def _guard_own
63
+ @_guards ||= []
64
+ end
65
+
66
+ def _guard_normalize_requires!(code, requires)
67
+ return [] if requires.nil?
68
+
69
+ deps = Array(requires)
70
+ invalid = deps.reject { |d| d.is_a?(Symbol) }
71
+ if invalid.any?
72
+ raise ArgumentError,
73
+ "guard :#{code} requires: must be Symbol(s), got: #{invalid.map(&:inspect).join(", ")}"
74
+ end
75
+
76
+ all_names = _guard_list.map(&:name)
77
+ deps.each do |dep|
78
+ unless all_names.include?(dep)
79
+ raise ArgumentError,
80
+ "guard :#{code} requires :#{dep}, but no guard with that name has been declared"
81
+ end
82
+ end
83
+
84
+ deps.freeze
85
+ end
86
+
87
+ def _guard_validate_unique!(code)
88
+ all_names = _guard_list.map(&:name)
89
+ if all_names.include?(code)
90
+ raise ArgumentError, "duplicate guard name :#{code}"
91
+ end
92
+ end
93
+ end
94
+
95
+ def _guard_wrap
96
+ guards = self.class._guard_list
97
+ return yield if guards.empty?
98
+
99
+ failures = _guard_evaluate
100
+ unless failures.empty?
101
+ first = failures.first
102
+ error!(first[:guard], first[:message], details: failures)
103
+ end
104
+
105
+ yield
106
+ end
107
+
108
+ private
109
+
110
+ def _guard_evaluate
111
+ guards = self.class._guard_list
112
+ return [] if guards.empty?
113
+
114
+ blocked_names = Set.new
115
+ failures = []
116
+
117
+ guards.each do |guard|
118
+ if guard.requires.any? { |dep| blocked_names.include?(dep) }
119
+ blocked_names << guard.name
120
+ next
121
+ end
122
+
123
+ threat = catch(:_dex_halt) { instance_exec(&guard.block) }
124
+ if threat.is_a?(Operation::Halt)
125
+ raise ArgumentError,
126
+ "guard :#{guard.name} must return truthy/falsy, not call error!/success!/assert!"
127
+ end
128
+
129
+ if threat
130
+ blocked_names << guard.name
131
+ failures << { guard: guard.name, message: guard.message || guard.name.to_s }
132
+ end
133
+ end
134
+
135
+ failures
136
+ end
137
+ end
138
+ end
@@ -9,25 +9,33 @@ module Dex
9
9
  case name
10
10
  when :DirectJob
11
11
  const_set(:DirectJob, Class.new(ActiveJob::Base) do
12
- def perform(class_name:, params:)
12
+ def perform(class_name:, params:, once_key: nil, once_bypass: false)
13
13
  klass = class_name.constantize
14
- klass.new(**klass.send(:_coerce_serialized_hash, params)).call
14
+ op = klass.new(**klass.send(:_coerce_serialized_hash, params))
15
+ op.once(once_key) if once_key
16
+ op.once(nil) if once_bypass
17
+ op.call
15
18
  end
16
19
  end)
17
20
  when :RecordJob
18
21
  const_set(:RecordJob, Class.new(ActiveJob::Base) do
19
- def perform(class_name:, record_id:)
22
+ def perform(class_name:, record_id:, once_key: nil, once_bypass: false)
20
23
  klass = class_name.constantize
21
24
  record = Dex.record_backend.find_record(record_id)
22
25
  params = klass.send(:_coerce_serialized_hash, record.params || {})
23
26
 
24
27
  op = klass.new(**params)
25
28
  op.instance_variable_set(:@_dex_record_id, record_id)
29
+ op.once(once_key) if once_key
30
+ op.once(nil) if once_bypass
26
31
 
27
32
  update_status(record_id, status: "running")
33
+ pipeline_started = true
28
34
  op.call
29
35
  rescue => e
30
- handle_failure(record_id, e)
36
+ # RecordWrapper handles failures during op.call via its own rescue.
37
+ # This catches pre-pipeline failures (find_record, deserialization, etc.)
38
+ mark_failed(record_id, e) unless pipeline_started
31
39
  raise
32
40
  end
33
41
 
@@ -39,13 +47,12 @@ module Dex
39
47
  Dex.warn("Failed to update record status: #{e.message}")
40
48
  end
41
49
 
42
- def handle_failure(record_id, exception)
43
- error_value = if exception.is_a?(Dex::Error)
44
- exception.code.to_s
45
- else
46
- exception.class.name
47
- end
48
- update_status(record_id, status: "failed", error: error_value)
50
+ def mark_failed(record_id, exception)
51
+ update_status(record_id,
52
+ status: "failed",
53
+ error_code: exception.class.name,
54
+ error_message: exception.message,
55
+ performed_at: Time.respond_to?(:current) ? Time.current : Time.now)
49
56
  end
50
57
  end)
51
58
  when :Job
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module OnceWrapper
5
+ extend Dex::Concern
6
+
7
+ module ClassMethods
8
+ def once(*props, expires_in: nil, &block)
9
+ if settings_for(:once)[:defined]
10
+ raise ArgumentError, "once can only be declared once per operation"
11
+ end
12
+
13
+ record_settings = settings_for(:record)
14
+ if record_settings[:enabled] == false
15
+ raise ArgumentError, "once requires record to be enabled"
16
+ end
17
+
18
+ if record_settings[:result] == false
19
+ raise ArgumentError, "once requires result recording (cannot use record result: false)"
20
+ end
21
+
22
+ if block && props.any?
23
+ raise ArgumentError, "once accepts either prop names or a block, not both"
24
+ end
25
+
26
+ if expires_in && !expires_in.is_a?(Numeric)
27
+ raise ArgumentError, "once :expires_in must be a duration, got: #{expires_in.inspect}"
28
+ end
29
+
30
+ _once_validate_props!(props) if props.any?
31
+
32
+ set(:once,
33
+ defined: true,
34
+ props: props.any? ? props : nil,
35
+ block: block || nil,
36
+ expires_in: expires_in)
37
+ end
38
+
39
+ def clear_once!(key = nil, **props)
40
+ derived = if key.is_a?(String)
41
+ key
42
+ elsif props.any?
43
+ _once_build_scoped_key(props)
44
+ else
45
+ raise ArgumentError, "pass a String key or keyword arguments matching the once props"
46
+ end
47
+ Dex.record_backend.update_record_by_once_key(derived, once_key: nil)
48
+ end
49
+
50
+ def _once_build_scoped_key(props_hash)
51
+ segments = props_hash.sort_by { |k, _| k.to_s }.map do |k, v|
52
+ "#{k}=#{URI.encode_www_form_component(v.to_s)}"
53
+ end
54
+ "#{name}/#{segments.join("/")}"
55
+ end
56
+
57
+ private
58
+
59
+ def _once_validate_props!(prop_names)
60
+ return unless respond_to?(:literal_properties)
61
+
62
+ defined_names = literal_properties.map(&:name).to_set
63
+ unknown = prop_names.reject { |p| defined_names.include?(p) }
64
+ return if unknown.empty?
65
+
66
+ raise ArgumentError,
67
+ "once references unknown prop(s): #{unknown.map(&:inspect).join(", ")}. " \
68
+ "Defined: #{defined_names.map(&:inspect).join(", ")}"
69
+ end
70
+ end
71
+
72
+ def once(key)
73
+ @_once_key = key
74
+ @_once_key_explicit = true
75
+ self
76
+ end
77
+
78
+ def _once_wrap
79
+ return yield unless _once_active?
80
+
81
+ key = _once_derive_key
82
+ return yield if key.nil? && _once_key_explicit?
83
+
84
+ _once_ensure_backend!
85
+
86
+ raise "once key must not be nil" if key.nil?
87
+
88
+ expires_in = self.class.settings_for(:once)[:expires_in]
89
+ expires_at = expires_in ? _once_current_time + expires_in : nil
90
+
91
+ existing = Dex.record_backend.find_by_once_key(key)
92
+ if existing
93
+ _once_finalize_duplicate!(existing)
94
+ return _once_replay!(existing)
95
+ end
96
+
97
+ expired = Dex.record_backend.find_expired_once_key(key)
98
+ Dex.record_backend.update_record(expired.id.to_s, once_key: nil) if expired
99
+
100
+ _once_claim!(key, expires_at) { yield }
101
+ end
102
+
103
+ private
104
+
105
+ def _once_key_explicit?
106
+ defined?(@_once_key_explicit) && @_once_key_explicit
107
+ end
108
+
109
+ def _once_active?
110
+ return true if _once_key_explicit?
111
+ self.class.settings_for(:once).fetch(:defined, false)
112
+ end
113
+
114
+ def _once_ensure_backend!
115
+ unless Dex.record_backend
116
+ raise "once requires a record backend (configure Dex.record_class)"
117
+ end
118
+
119
+ return if self.class.instance_variable_defined?(:@_once_fields_checked)
120
+
121
+ unless Dex.record_backend.has_field?("once_key")
122
+ raise "once requires once_key column on #{Dex.record_class}. Run the migration to add it."
123
+ end
124
+
125
+ if self.class.settings_for(:once)[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
126
+ raise "once with expires_in requires once_key_expires_at column on #{Dex.record_class}. Run the migration to add it."
127
+ end
128
+
129
+ self.class.instance_variable_set(:@_once_fields_checked, true)
130
+ end
131
+
132
+ def _once_derive_key
133
+ return @_once_key if _once_key_explicit?
134
+
135
+ settings = self.class.settings_for(:once)
136
+
137
+ if settings[:block]
138
+ instance_exec(&settings[:block])
139
+ elsif settings[:props]
140
+ props_hash = settings[:props].each_with_object({}) { |p, h| h[p] = public_send(p) }
141
+ self.class._once_build_scoped_key(props_hash)
142
+ else
143
+ hash = {}
144
+ if self.class.respond_to?(:literal_properties)
145
+ self.class.literal_properties.each { |p| hash[p.name] = public_send(p.name) }
146
+ end
147
+ self.class._once_build_scoped_key(hash)
148
+ end
149
+ end
150
+
151
+ def _once_claim!(key, expires_at)
152
+ begin
153
+ _once_acquire_key!(key, expires_at)
154
+ rescue => e
155
+ if Dex.record_backend.unique_constraint_error?(e)
156
+ existing = Dex.record_backend.find_by_once_key(key)
157
+ if existing
158
+ _once_finalize_duplicate!(existing)
159
+ return _once_replay!(existing)
160
+ end
161
+
162
+ raise "once key #{key.inspect} is claimed by another in-flight execution"
163
+ end
164
+ raise
165
+ end
166
+ yield
167
+ end
168
+
169
+ def _once_acquire_key!(key, expires_at)
170
+ if _once_has_pending_record?
171
+ Dex.record_backend.update_record(@_dex_record_id,
172
+ once_key: key, once_key_expires_at: expires_at)
173
+ else
174
+ record = Dex.record_backend.create_record(
175
+ name: self.class.name,
176
+ once_key: key,
177
+ once_key_expires_at: expires_at,
178
+ status: "pending"
179
+ )
180
+ @_dex_record_id = record.id.to_s
181
+ end
182
+ end
183
+
184
+ def _once_has_pending_record?
185
+ defined?(@_dex_record_id) && @_dex_record_id
186
+ end
187
+
188
+ def _once_finalize_duplicate!(source_record)
189
+ return unless _once_has_pending_record?
190
+
191
+ attrs = { performed_at: _once_current_time }
192
+ if source_record.status == "error"
193
+ attrs[:status] = "error"
194
+ attrs[:error_code] = source_record.error_code
195
+ attrs[:error_message] = source_record.error_message
196
+ attrs[:error_details] = source_record.respond_to?(:error_details) ? source_record.error_details : nil
197
+ else
198
+ attrs[:status] = "completed"
199
+ attrs[:result] = source_record.respond_to?(:result) ? source_record.result : nil
200
+ end
201
+ Dex.record_backend.update_record(@_dex_record_id, attrs)
202
+ rescue => e
203
+ Dex.warn("Failed to finalize replayed record: #{e.message}")
204
+ end
205
+
206
+ def _once_replay!(record)
207
+ case record.status
208
+ when "completed"
209
+ _once_replay_success(record)
210
+ when "error"
211
+ _once_replay_error(record)
212
+ end
213
+ end
214
+
215
+ def _once_replay_success(record)
216
+ stored = record.respond_to?(:result) ? record.result : nil
217
+ success_type = self.class.respond_to?(:_success_type) && self.class._success_type
218
+
219
+ if success_type && stored
220
+ self.class.send(:_coerce_value, success_type, stored)
221
+ elsif stored.is_a?(Hash) && stored.key?("_dex_value")
222
+ stored["_dex_value"]
223
+ else
224
+ stored
225
+ end
226
+ end
227
+
228
+ def _once_replay_error(record)
229
+ raise Dex::Error.new(
230
+ record.error_code.to_sym,
231
+ record.error_message,
232
+ details: record.respond_to?(:error_details) ? record.error_details : nil
233
+ )
234
+ end
235
+
236
+ def _once_current_time
237
+ Time.respond_to?(:current) ? Time.current : Time.now
238
+ end
239
+ end
240
+ end