dexkit 0.1.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.
data/lib/dex/error.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Error < StandardError
5
+ attr_reader :code, :details
6
+
7
+ def initialize(code, message = nil, details: nil)
8
+ @code = code
9
+ @details = details
10
+ super(message || code.to_s)
11
+ end
12
+
13
+ def deconstruct_keys(keys)
14
+ { code: @code, message: message, details: @details }
15
+ end
16
+ end
17
+ end
data/lib/dex/match.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ # Top-level aliases for clean pattern matching
5
+ Ok = Operation::Ok
6
+ Err = Operation::Err
7
+
8
+ # Module for including Ok/Err constants without namespace prefix
9
+ module Match
10
+ Ok = Dex::Ok
11
+ Err = Dex::Err
12
+ end
13
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ class AsyncProxy
6
+ def initialize(operation, **runtime_options)
7
+ @operation = operation
8
+ @runtime_options = runtime_options
9
+ end
10
+
11
+ def call
12
+ _async_ensure_active_job_loaded!
13
+ if _async_use_record_strategy?
14
+ _async_enqueue_record_job
15
+ else
16
+ _async_enqueue_direct_job
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def _async_enqueue_direct_job
23
+ job = _async_apply_options(Operation::DirectJob)
24
+ job.perform_later(class_name: _async_operation_class_name, params: _async_serialized_params)
25
+ end
26
+
27
+ def _async_enqueue_record_job
28
+ record = Dex.record_backend.create_record(
29
+ name: _async_operation_class_name,
30
+ params: _async_serialized_params,
31
+ status: "pending"
32
+ )
33
+ begin
34
+ job = _async_apply_options(Operation::RecordJob)
35
+ job.perform_later(class_name: _async_operation_class_name, record_id: record.id)
36
+ rescue => e
37
+ begin
38
+ record.destroy
39
+ rescue => destroy_error
40
+ _async_log_warning("Failed to clean up pending record #{record.id}: #{destroy_error.message}")
41
+ end
42
+ raise e
43
+ end
44
+ end
45
+
46
+ def _async_use_record_strategy?
47
+ return false unless Dex.record_backend
48
+ return false unless @operation.class.name
49
+
50
+ record_settings = @operation.class.settings_for(:record)
51
+ return false if record_settings[:enabled] == false
52
+ return false if record_settings[:params] == false
53
+
54
+ true
55
+ end
56
+
57
+ def _async_apply_options(job_class)
58
+ options = {}
59
+ options[:queue] = _async_queue if _async_queue
60
+ options[:wait_until] = _async_scheduled_at if _async_scheduled_at
61
+ options[:wait] = _async_scheduled_in if _async_scheduled_in
62
+ options.empty? ? job_class : job_class.set(**options)
63
+ end
64
+
65
+ def _async_ensure_active_job_loaded!
66
+ return if defined?(ActiveJob::Base)
67
+
68
+ raise LoadError, "ActiveJob is required for async operations. Add 'activejob' to your Gemfile."
69
+ end
70
+
71
+ def _async_merged_options
72
+ @operation.class.settings_for(:async).merge(@runtime_options)
73
+ end
74
+
75
+ def _async_queue = _async_merged_options[:queue]
76
+ def _async_scheduled_at = _async_merged_options[:at]
77
+ def _async_scheduled_in = _async_merged_options[:in]
78
+ def _async_operation_class_name = @operation.class.name
79
+
80
+ def _async_serialized_params
81
+ @_async_serialized_params ||= begin
82
+ hash = @operation._props_as_json
83
+ _async_validate_serializable!(hash)
84
+ hash
85
+ end
86
+ end
87
+
88
+ def _async_log_warning(message)
89
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
90
+ Rails.logger.warn "[Dex] #{message}"
91
+ end
92
+ end
93
+
94
+ def _async_validate_serializable!(hash, path: "")
95
+ hash.each do |key, value|
96
+ current = path.empty? ? key.to_s : "#{path}.#{key}"
97
+ case value
98
+ when String, Integer, Float, NilClass, TrueClass, FalseClass
99
+ next
100
+ when Hash
101
+ _async_validate_serializable!(value, path: current)
102
+ when Array
103
+ value.each_with_index do |v, i|
104
+ _async_validate_serializable!({ i => v }, path: current)
105
+ end
106
+ else
107
+ raise ArgumentError,
108
+ "Param '#{current}' (#{value.class}) is not JSON-serializable. " \
109
+ "Async operations require all params to be serializable."
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module AsyncWrapper
5
+ ASYNC_KNOWN_OPTIONS = %i[queue in at].freeze
6
+
7
+ module ClassMethods
8
+ def async(**options)
9
+ unknown = options.keys - AsyncWrapper::ASYNC_KNOWN_OPTIONS
10
+ if unknown.any?
11
+ raise ArgumentError,
12
+ "unknown async option(s): #{unknown.map(&:inspect).join(", ")}. " \
13
+ "Known: #{AsyncWrapper::ASYNC_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
14
+ end
15
+
16
+ set(:async, **options)
17
+ end
18
+ end
19
+
20
+ def self.included(base)
21
+ base.extend(ClassMethods)
22
+ end
23
+
24
+ def async(**options)
25
+ unknown = options.keys - AsyncWrapper::ASYNC_KNOWN_OPTIONS
26
+ if unknown.any?
27
+ raise ArgumentError,
28
+ "unknown async option(s): #{unknown.map(&:inspect).join(", ")}. " \
29
+ "Known: #{AsyncWrapper::ASYNC_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
30
+ end
31
+
32
+ Operation::AsyncProxy.new(self, **options)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module CallbackWrapper
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def before(callable = nil, &block)
11
+ _callback_validate!(callable, block)
12
+ entry = callable.is_a?(Symbol) ? [:method, callable] : [:proc, callable || block]
13
+ _callback_own(:before) << entry
14
+ end
15
+
16
+ def after(callable = nil, &block)
17
+ _callback_validate!(callable, block)
18
+ entry = callable.is_a?(Symbol) ? [:method, callable] : [:proc, callable || block]
19
+ _callback_own(:after) << entry
20
+ end
21
+
22
+ def around(callable = nil, &block)
23
+ _callback_validate!(callable, block)
24
+ entry = callable.is_a?(Symbol) ? [:method, callable] : [:proc, callable || block]
25
+ _callback_own(:around) << entry
26
+ end
27
+
28
+ def _callback_list(type)
29
+ parent_callbacks = superclass.respond_to?(:_callback_list) ? superclass._callback_list(type) : []
30
+ parent_callbacks + _callback_own(type)
31
+ end
32
+
33
+ def _callback_any?
34
+ %i[before after around].any? { |type| _callback_list(type).any? }
35
+ end
36
+
37
+ private
38
+
39
+ def _callback_validate!(callable, block)
40
+ return if callable.is_a?(Symbol)
41
+ return if callable.nil? && block
42
+ return if callable.is_a?(Proc)
43
+
44
+ if callable.nil?
45
+ raise ArgumentError, "callback requires a Symbol, Proc, or block"
46
+ else
47
+ raise ArgumentError, "callback must be a Symbol or Proc, got: #{callable.class}"
48
+ end
49
+ end
50
+
51
+ def _callback_own(type)
52
+ @_callbacks ||= { before: [], after: [], around: [] }
53
+ @_callbacks[type]
54
+ end
55
+ end
56
+
57
+ def _callback_wrap
58
+ return yield unless self.class._callback_any?
59
+
60
+ halted = nil
61
+ result = _callback_run_around(self.class._callback_list(:around)) do
62
+ _callback_run_before
63
+ caught = catch(:_dex_halt) { yield }
64
+ if caught.is_a?(Operation::Halt)
65
+ if caught.success?
66
+ halted = caught
67
+ _callback_run_after
68
+ caught.value
69
+ else
70
+ throw(:_dex_halt, caught)
71
+ end
72
+ else
73
+ _callback_run_after
74
+ caught
75
+ end
76
+ end
77
+ throw(:_dex_halt, halted) if halted
78
+ result
79
+ end
80
+
81
+ private
82
+
83
+ def _callback_run_before
84
+ self.class._callback_list(:before).each { |cb| _callback_invoke(cb) }
85
+ end
86
+
87
+ def _callback_run_after
88
+ self.class._callback_list(:after).each { |cb| _callback_invoke(cb) }
89
+ end
90
+
91
+ def _callback_invoke(cb)
92
+ kind, callable = cb
93
+ case kind
94
+ when :method then send(callable)
95
+ when :proc then instance_exec(&callable)
96
+ end
97
+ end
98
+
99
+ def _callback_run_around(chain, &core)
100
+ if chain.empty?
101
+ core.call
102
+ else
103
+ kind, callable = chain.first
104
+ rest = chain[1..]
105
+ continuation = -> { _callback_run_around(rest, &core) }
106
+ case kind
107
+ when :method then send(callable) { continuation.call }
108
+ when :proc then instance_exec(continuation, &callable)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ # Job classes are defined lazily when ActiveJob is loaded
6
+ def self.const_missing(name)
7
+ return super unless defined?(ActiveJob::Base)
8
+
9
+ case name
10
+ when :DirectJob
11
+ const_set(:DirectJob, Class.new(ActiveJob::Base) do
12
+ def perform(class_name:, params:)
13
+ klass = class_name.constantize
14
+ klass.new(**klass.send(:_dex_coerce_serialized_hash, params)).call
15
+ end
16
+ end)
17
+ when :RecordJob
18
+ const_set(:RecordJob, Class.new(ActiveJob::Base) do
19
+ def perform(class_name:, record_id:)
20
+ klass = class_name.constantize
21
+ record = Dex.record_backend.find_record(record_id)
22
+ params = klass.send(:_dex_coerce_serialized_hash, record.params || {})
23
+
24
+ op = klass.new(**params)
25
+ op.instance_variable_set(:@_dex_record_id, record_id)
26
+
27
+ _dex_update_status(record_id, status: "running")
28
+ op.call
29
+ rescue => e
30
+ _dex_handle_failure(record_id, e)
31
+ raise
32
+ end
33
+
34
+ private
35
+
36
+ def _dex_update_status(record_id, **attributes)
37
+ Dex.record_backend.update_record(record_id, attributes)
38
+ rescue => e
39
+ _dex_log_warning("Failed to update record status: #{e.message}")
40
+ end
41
+
42
+ def _dex_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
+ _dex_update_status(record_id, status: "failed", error: error_value)
49
+ end
50
+
51
+ def _dex_log_warning(message)
52
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
53
+ Rails.logger.warn "[Dex] #{message}"
54
+ end
55
+ end
56
+ end)
57
+ when :Job
58
+ const_set(:Job, const_get(:DirectJob))
59
+ else
60
+ super
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module LockWrapper
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ LOCK_KNOWN_OPTIONS = %i[timeout].freeze
10
+
11
+ module ClassMethods
12
+ def advisory_lock(key = nil, **options, &block)
13
+ unknown = options.keys - LockWrapper::LOCK_KNOWN_OPTIONS
14
+ if unknown.any?
15
+ raise ArgumentError,
16
+ "unknown advisory_lock option(s): #{unknown.map(&:inspect).join(", ")}. " \
17
+ "Known: #{LockWrapper::LOCK_KNOWN_OPTIONS.map(&:inspect).join(", ")}"
18
+ end
19
+
20
+ lock_key = block || key
21
+
22
+ unless lock_key.nil? || lock_key.is_a?(String) || lock_key.is_a?(Symbol) || lock_key.is_a?(Proc)
23
+ raise ArgumentError, "advisory_lock key must be a String, Symbol, or Proc, got: #{lock_key.class}"
24
+ end
25
+
26
+ if options.key?(:timeout) && !options[:timeout].is_a?(Numeric)
27
+ raise ArgumentError, "advisory_lock :timeout must be Numeric, got: #{options[:timeout].inspect}"
28
+ end
29
+
30
+ set(:advisory_lock, enabled: true, key: lock_key, **options)
31
+ end
32
+ end
33
+
34
+ def _lock_wrap
35
+ _lock_enabled? ? _lock_execute { yield } : yield
36
+ end
37
+
38
+ private
39
+
40
+ def _lock_enabled?
41
+ self.class.settings_for(:advisory_lock).fetch(:enabled, false)
42
+ end
43
+
44
+ def _lock_key
45
+ key = self.class.settings_for(:advisory_lock)[:key]
46
+ case key
47
+ when String then key
48
+ when Symbol then send(key)
49
+ when Proc then instance_exec(&key)
50
+ when nil then self.class.name || raise(ArgumentError, "Anonymous classes must provide an explicit lock key")
51
+ else raise ArgumentError, "Unsupported advisory_lock key type: #{key.class}"
52
+ end
53
+ end
54
+
55
+ def _lock_options
56
+ opts = {}
57
+ timeout = self.class.settings_for(:advisory_lock)[:timeout]
58
+ opts[:timeout_seconds] = timeout if timeout
59
+ opts
60
+ end
61
+
62
+ def _lock_execute(&block)
63
+ _lock_ensure_loaded!
64
+ key = _lock_key
65
+ ActiveRecord::Base.with_advisory_lock!(key, **_lock_options, &block)
66
+ rescue WithAdvisoryLock::FailedToAcquireLock
67
+ raise Dex::Error.new(:lock_timeout, "Could not acquire advisory lock: #{key}")
68
+ end
69
+
70
+ def _lock_ensure_loaded!
71
+ return if ActiveRecord::Base.respond_to?(:with_advisory_lock!)
72
+
73
+ raise LoadError,
74
+ "with_advisory_lock gem is required for advisory locking. Add 'with_advisory_lock' to your Gemfile."
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ class Ok
6
+ attr_reader :value
7
+
8
+ def initialize(value)
9
+ @value = value
10
+ end
11
+
12
+ def ok? = true
13
+ def error? = false
14
+
15
+ def value! = @value
16
+
17
+ def method_missing(method, *args, &block)
18
+ if @value.respond_to?(method)
19
+ @value.public_send(method, *args, &block)
20
+ else
21
+ super
22
+ end
23
+ end
24
+
25
+ def respond_to_missing?(method, include_private = false)
26
+ @value.respond_to?(method, include_private) || super
27
+ end
28
+
29
+ def deconstruct_keys(keys)
30
+ return { value: @value } unless @value.respond_to?(:deconstruct_keys)
31
+ @value.deconstruct_keys(keys)
32
+ end
33
+ end
34
+
35
+ class Err
36
+ attr_reader :error
37
+
38
+ def initialize(error)
39
+ @error = error
40
+ end
41
+
42
+ def ok? = false
43
+ def error? = true
44
+
45
+ def value = nil
46
+ def value! = raise @error
47
+
48
+ def code = @error.code
49
+ def message = @error.message
50
+ def details = @error.details
51
+
52
+ def deconstruct_keys(keys)
53
+ { code: @error.code, message: @error.message, details: @error.details }
54
+ end
55
+ end
56
+
57
+ class SafeProxy
58
+ def initialize(operation)
59
+ @operation = operation
60
+ end
61
+
62
+ def call
63
+ result = @operation.call
64
+ Operation::Ok.new(result)
65
+ rescue Dex::Error => e
66
+ Operation::Err.new(e)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Dex
6
+ module PropsSetup
7
+ def self.included(base)
8
+ base.extend(Literal::Properties)
9
+ base.extend(Literal::Types)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ RESERVED_PROP_NAMES = %i[call perform async safe initialize].to_set.freeze
15
+
16
+ def prop(name, type, kind = :keyword, **options, &block)
17
+ _props_validate_name!(name)
18
+ options[:reader] = :public unless options.key?(:reader)
19
+ if type.is_a?(Dex::RefType) && !block
20
+ ref = type
21
+ block = ->(v) { ref.coerce(v) }
22
+ end
23
+ super(name, type, kind, **options, &block)
24
+ end
25
+
26
+ def prop?(name, type, kind = :keyword, **options, &block)
27
+ options[:reader] = :public unless options.key?(:reader)
28
+ options[:default] = nil unless options.key?(:default)
29
+ if type.is_a?(Dex::RefType) && !block
30
+ ref = type
31
+ block = ->(v) { v.nil? ? v : ref.coerce(v) }
32
+ end
33
+ prop(name, _Nilable(type), kind, **options, &block)
34
+ end
35
+
36
+ def _Ref(model_class, lock: false) # rubocop:disable Naming/MethodName
37
+ Dex::RefType.new(model_class, lock: lock)
38
+ end
39
+
40
+ private
41
+
42
+ def _props_validate_name!(name)
43
+ return unless RESERVED_PROP_NAMES.include?(name)
44
+
45
+ raise ArgumentError,
46
+ "Property :#{name} conflicts with core Operation methods."
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ module RecordBackend
6
+ def self.for(record_class)
7
+ return nil unless record_class
8
+
9
+ if defined?(ActiveRecord::Base) && record_class < ActiveRecord::Base
10
+ ActiveRecordAdapter.new(record_class)
11
+ elsif defined?(Mongoid::Document) && record_class.include?(Mongoid::Document)
12
+ MongoidAdapter.new(record_class)
13
+ else
14
+ raise ArgumentError, "record_class must inherit from ActiveRecord::Base or include Mongoid::Document"
15
+ end
16
+ end
17
+
18
+ class Base
19
+ attr_reader :record_class
20
+
21
+ def initialize(record_class)
22
+ @record_class = record_class
23
+ end
24
+
25
+ def create_record(attributes)
26
+ record_class.create!(safe_attributes(attributes))
27
+ end
28
+
29
+ def find_record(id)
30
+ record_class.find(id)
31
+ end
32
+
33
+ def update_record(id, attributes)
34
+ record_class.find(id).update!(safe_attributes(attributes))
35
+ end
36
+
37
+ def safe_attributes(attributes)
38
+ attributes.select { |key, _| has_field?(key.to_s) }
39
+ end
40
+
41
+ def has_field?(field_name)
42
+ raise NotImplementedError
43
+ end
44
+ end
45
+
46
+ class ActiveRecordAdapter < Base
47
+ def initialize(record_class)
48
+ super
49
+ @column_set = record_class.column_names.to_set
50
+ end
51
+
52
+ def has_field?(field_name)
53
+ @column_set.include?(field_name.to_s)
54
+ end
55
+ end
56
+
57
+ class MongoidAdapter < Base
58
+ def has_field?(field_name)
59
+ record_class.fields.key?(field_name.to_s)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end