dexkit 0.1.0 → 0.3.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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ class Handler
6
+ attr_reader :event
7
+
8
+ def self.on(*event_classes)
9
+ event_classes.each do |ec|
10
+ Event.validate_event_class!(ec)
11
+ Bus.subscribe(ec, self)
12
+ end
13
+ end
14
+
15
+ def self.retries(count, **opts)
16
+ raise ArgumentError, "retries count must be a positive Integer" unless count.is_a?(Integer) && count > 0
17
+
18
+ if opts.key?(:wait)
19
+ wait = opts[:wait]
20
+ unless wait.is_a?(Numeric) || wait.is_a?(Proc)
21
+ raise ArgumentError, "wait: must be Numeric or Proc"
22
+ end
23
+ end
24
+
25
+ @_event_handler_retries = { count: count, **opts }
26
+ end
27
+
28
+ def self._event_handler_retry_config
29
+ if defined?(@_event_handler_retries)
30
+ @_event_handler_retries
31
+ elsif superclass.respond_to?(:_event_handler_retry_config)
32
+ superclass._event_handler_retry_config
33
+ end
34
+ end
35
+
36
+ def self._event_handle(event)
37
+ instance = new
38
+ instance.instance_variable_set(:@event, event)
39
+ instance.perform
40
+ end
41
+
42
+ def self._event_handle_from_payload(event_class_name, payload, metadata_hash)
43
+ event_class = Object.const_get(event_class_name)
44
+ event = _event_reconstruct(event_class, payload, metadata_hash)
45
+ _event_handle(event)
46
+ end
47
+
48
+ class << self
49
+ private
50
+
51
+ def _event_reconstruct(event_class, payload, metadata_hash)
52
+ coerced = event_class.send(:_coerce_serialized_hash, payload)
53
+ instance = event_class.allocate
54
+
55
+ event_class.literal_properties.each do |prop|
56
+ instance.instance_variable_set(:"@#{prop.name}", coerced[prop.name])
57
+ end
58
+
59
+ metadata = Event::Metadata.new(
60
+ id: metadata_hash["id"],
61
+ timestamp: Time.parse(metadata_hash["timestamp"]),
62
+ trace_id: metadata_hash["trace_id"],
63
+ caused_by_id: metadata_hash["caused_by_id"],
64
+ context: metadata_hash["context"]
65
+ )
66
+ instance.instance_variable_set(:@metadata, metadata)
67
+ instance.freeze
68
+ instance
69
+ end
70
+ end
71
+
72
+ def perform
73
+ raise NotImplementedError, "#{self.class.name} must implement #perform"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Dex
6
+ class Event
7
+ class Metadata
8
+ attr_reader :id, :timestamp, :trace_id, :caused_by_id, :context
9
+
10
+ def initialize(id:, timestamp:, trace_id:, caused_by_id:, context:)
11
+ @id = id
12
+ @timestamp = timestamp
13
+ @trace_id = trace_id
14
+ @caused_by_id = caused_by_id
15
+ @context = context
16
+ freeze
17
+ end
18
+
19
+ def self.build(caused_by_id: nil)
20
+ id = SecureRandom.uuid
21
+ trace_id = Trace.current_trace_id || id
22
+ caused = caused_by_id || Trace.current_event_id
23
+
24
+ ctx = if Dex.configuration.event_context
25
+ begin
26
+ Dex.configuration.event_context.call
27
+ rescue => e
28
+ Event._warn("event_context failed: #{e.message}")
29
+ nil
30
+ end
31
+ end
32
+
33
+ new(
34
+ id: id,
35
+ timestamp: Time.now.utc,
36
+ trace_id: trace_id,
37
+ caused_by_id: caused,
38
+ context: ctx
39
+ )
40
+ end
41
+
42
+ def as_json
43
+ h = {
44
+ "id" => @id,
45
+ "timestamp" => @timestamp.iso8601(6),
46
+ "trace_id" => @trace_id
47
+ }
48
+ h["caused_by_id"] = @caused_by_id if @caused_by_id
49
+ h["context"] = @context if @context
50
+ h
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ # Lazy-loaded ActiveJob processor (same pattern as Operation::DirectJob)
6
+ def self.const_missing(name)
7
+ return super unless name == :Processor && defined?(ActiveJob::Base)
8
+
9
+ const_set(:Processor, Class.new(ActiveJob::Base) do
10
+ def perform(handler_class:, event_class:, payload:, metadata:, trace: nil, context: nil, attempt_number: 1)
11
+ restore_context(context)
12
+
13
+ handler = Object.const_get(handler_class)
14
+ retry_config = handler._event_handler_retry_config
15
+
16
+ Dex::Event::Trace.restore(trace) do
17
+ handler._event_handle_from_payload(event_class, payload, metadata)
18
+ end
19
+ rescue => _e
20
+ if retry_config && attempt_number <= retry_config[:count]
21
+ delay = compute_delay(retry_config, attempt_number)
22
+ self.class.set(wait: delay).perform_later(
23
+ handler_class: handler_class,
24
+ event_class: event_class,
25
+ payload: payload,
26
+ metadata: metadata,
27
+ trace: trace,
28
+ context: context,
29
+ attempt_number: attempt_number + 1
30
+ )
31
+ else
32
+ raise
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def restore_context(context)
39
+ return unless context
40
+
41
+ restorer = Dex.configuration.restore_event_context
42
+ return unless restorer
43
+
44
+ restorer.call(context)
45
+ rescue => e
46
+ Dex::Event._warn("restore_event_context failed: #{e.message}")
47
+ end
48
+
49
+ def compute_delay(config, attempt)
50
+ wait = config[:wait]
51
+ case wait
52
+ when Numeric then wait
53
+ when Proc then wait.call(attempt)
54
+ else
55
+ 2**(attempt - 1) # exponential backoff
56
+ end
57
+ end
58
+ end)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ module Suppression
6
+ SUPPRESSED_KEY = :_dex_event_suppressed
7
+
8
+ class << self
9
+ include ExecutionState
10
+
11
+ def suppress(*classes, &block)
12
+ previous = _suppressed_set
13
+ new_set = previous.dup
14
+ if classes.empty?
15
+ new_set << :all
16
+ else
17
+ classes.each do |klass|
18
+ Event.validate_event_class!(klass)
19
+ new_set << klass
20
+ end
21
+ end
22
+ _set_suppressed(new_set)
23
+ yield
24
+ ensure
25
+ _set_suppressed(previous)
26
+ end
27
+
28
+ def suppressed?(event_class)
29
+ set = _suppressed_set
30
+ set.include?(:all) || set.any? { |k| k != :all && event_class <= k }
31
+ end
32
+
33
+ def clear!
34
+ _execution_state[SUPPRESSED_KEY] = Set.new
35
+ end
36
+
37
+ private
38
+
39
+ def _suppressed_set
40
+ _execution_state[SUPPRESSED_KEY] ||= Set.new
41
+ end
42
+
43
+ def _set_suppressed(set)
44
+ _execution_state[SUPPRESSED_KEY] = set
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ module Trace
6
+ STACK_KEY = :_dex_event_trace_stack
7
+
8
+ class << self
9
+ include ExecutionState
10
+
11
+ def with_event(event, &block)
12
+ stack = _stack
13
+ stack.push(event.trace_frame)
14
+ yield
15
+ ensure
16
+ stack.pop
17
+ end
18
+
19
+ def current_event_id
20
+ _stack.last&.dig(:id)
21
+ end
22
+
23
+ def current_trace_id
24
+ _stack.last&.dig(:trace_id)
25
+ end
26
+
27
+ def dump
28
+ frame = _stack.last
29
+ return nil unless frame
30
+
31
+ { id: frame[:id], trace_id: frame[:trace_id] }
32
+ end
33
+
34
+ def restore(data, &block)
35
+ return yield unless data
36
+
37
+ stack = _stack
38
+ stack.push(data)
39
+ yield
40
+ ensure
41
+ stack.pop if data
42
+ end
43
+
44
+ def clear!
45
+ _execution_state[STACK_KEY] = []
46
+ end
47
+
48
+ private
49
+
50
+ def _stack
51
+ _execution_state[STACK_KEY] ||= []
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/dex/event.rb ADDED
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ # Modules loaded before class body (no reference to Dex::Event needed)
6
+ require_relative "event/execution_state"
7
+ require_relative "event/metadata"
8
+ require_relative "event/trace"
9
+ require_relative "event/suppression"
10
+
11
+ module Dex
12
+ class Event
13
+ RESERVED_PROP_NAMES = %i[
14
+ id timestamp trace_id caused_by_id caused_by
15
+ context publish metadata sync
16
+ ].to_set.freeze
17
+
18
+ include PropsSetup
19
+ include TypeCoercion
20
+
21
+ def self._warn(message)
22
+ Dex.warn("Event: #{message}")
23
+ end
24
+
25
+ def self.validate_event_class!(klass)
26
+ return if klass.is_a?(Class) && klass < Dex::Event
27
+
28
+ raise ArgumentError, "#{klass.inspect} is not a Dex::Event subclass"
29
+ end
30
+
31
+ # --- Instance ---
32
+
33
+ attr_reader :metadata
34
+
35
+ def after_initialize
36
+ @metadata = Metadata.build
37
+ freeze
38
+ end
39
+
40
+ # Metadata delegates
41
+ def id = metadata.id
42
+ def timestamp = metadata.timestamp
43
+ def trace_id = metadata.trace_id
44
+ def caused_by_id = metadata.caused_by_id
45
+ def context = metadata.context
46
+ def trace_frame = { id: id, trace_id: trace_id }
47
+
48
+ # Publishing
49
+ def publish(sync: false)
50
+ Bus.publish(self, sync: sync)
51
+ end
52
+
53
+ def self.publish(sync: false, caused_by: nil, **kwargs)
54
+ if caused_by
55
+ Trace.with_event(caused_by) do
56
+ new(**kwargs).publish(sync: sync)
57
+ end
58
+ else
59
+ new(**kwargs).publish(sync: sync)
60
+ end
61
+ end
62
+
63
+ # Tracing
64
+ def trace(&block)
65
+ Trace.with_event(self, &block)
66
+ end
67
+
68
+ # Suppression
69
+ def self.suppress(*classes, &block)
70
+ Suppression.suppress(*classes, &block)
71
+ end
72
+
73
+ # Serialization
74
+ def as_json
75
+ {
76
+ "type" => self.class.name,
77
+ "payload" => _props_as_json,
78
+ "metadata" => metadata.as_json
79
+ }
80
+ end
81
+ end
82
+ end
83
+
84
+ # Classes loaded after Event is defined (they reference Dex::Event)
85
+ require_relative "event/bus"
86
+ require_relative "event/handler"
87
+ require_relative "event/processor"
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ module TestHelpers
6
+ def assert_event_published(event_class, msg: nil, **expected_props)
7
+ matching = _dex_find_published_events(event_class, expected_props)
8
+ assert matching.any?,
9
+ msg || _dex_event_published_failure_message(event_class, expected_props)
10
+ end
11
+
12
+ def refute_event_published(event_class = nil, msg: nil, **expected_props)
13
+ if event_class.nil?
14
+ assert _dex_published_events.empty?,
15
+ msg || "Expected no events published, but #{_dex_published_events.size} were:\n#{_dex_event_list}"
16
+ else
17
+ matching = _dex_find_published_events(event_class, expected_props)
18
+ assert matching.empty?,
19
+ msg || "Expected no #{event_class.name || event_class} events published, but found #{matching.size}"
20
+ end
21
+ end
22
+
23
+ def assert_event_count(event_class, count, msg: nil)
24
+ matching = _dex_find_published_events(event_class, {})
25
+ assert_equal count, matching.size,
26
+ msg || "Expected #{count} #{event_class.name || event_class} events, got #{matching.size}"
27
+ end
28
+
29
+ def assert_event_trace(parent, child, msg: nil)
30
+ assert_equal parent.id, child.caused_by_id,
31
+ msg || "Expected child event to be caused by parent (caused_by_id mismatch)"
32
+ end
33
+
34
+ def assert_same_trace(*events, msg: nil)
35
+ trace_ids = events.map(&:trace_id).uniq
36
+ assert_equal 1, trace_ids.size,
37
+ msg || "Expected all events to share the same trace_id, got: #{trace_ids.inspect}"
38
+ end
39
+
40
+ private
41
+
42
+ def _dex_find_published_events(event_class, expected_props)
43
+ _dex_published_events.select do |event|
44
+ next false unless event.is_a?(event_class)
45
+
46
+ expected_props.all? do |key, value|
47
+ event.respond_to?(key) && event.public_send(key) == value
48
+ end
49
+ end
50
+ end
51
+
52
+ def _dex_event_published_failure_message(event_class, expected_props)
53
+ name = event_class.name || event_class.to_s
54
+ if _dex_published_events.empty?
55
+ "Expected #{name} to be published, but no events were published"
56
+ else
57
+ msg = "Expected #{name} to be published"
58
+ msg += " with #{expected_props.inspect}" unless expected_props.empty?
59
+ msg + ", but only found:\n#{_dex_event_list}"
60
+ end
61
+ end
62
+
63
+ def _dex_event_list
64
+ _dex_published_events.map.with_index do |event, i|
65
+ " #{i + 1}. #{event.class.name || event.class}"
66
+ end.join("\n")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ module EventTestWrapper
6
+ CAPTURING_KEY = :_dex_event_capturing
7
+ PUBLISHED_KEY = :_dex_event_published
8
+
9
+ @_installed = false
10
+
11
+ class << self
12
+ include ExecutionState
13
+
14
+ def install!
15
+ return if @_installed
16
+
17
+ Dex::Event::Bus.singleton_class.prepend(BusInterceptor)
18
+ @_installed = true
19
+ end
20
+
21
+ def installed?
22
+ @_installed
23
+ end
24
+
25
+ def capturing?
26
+ (_execution_state[CAPTURING_KEY] || 0) > 0
27
+ end
28
+
29
+ def begin_capture!
30
+ _execution_state[CAPTURING_KEY] = (_execution_state[CAPTURING_KEY] || 0) + 1
31
+ end
32
+
33
+ def end_capture!
34
+ depth = (_execution_state[CAPTURING_KEY] || 0) - 1
35
+ _execution_state[CAPTURING_KEY] = [depth, 0].max
36
+ end
37
+
38
+ def published_events
39
+ _execution_state[PUBLISHED_KEY] ||= []
40
+ end
41
+
42
+ def clear_published!
43
+ _execution_state[PUBLISHED_KEY] = []
44
+ end
45
+ end
46
+
47
+ module BusInterceptor
48
+ def publish(event, sync:)
49
+ if Dex::Event::EventTestWrapper.capturing?
50
+ return if Dex::Event::Suppression.suppressed?(event.class)
51
+
52
+ Dex::Event::EventTestWrapper.published_events << event
53
+ else
54
+ super(event, sync: true)
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ module TestHelpers
61
+ def self.included(base)
62
+ EventTestWrapper.install!
63
+ end
64
+
65
+ def setup
66
+ super
67
+ EventTestWrapper.clear_published!
68
+ Dex::Event::Trace.clear!
69
+ Dex::Event::Suppression.clear!
70
+ end
71
+
72
+ def capture_events
73
+ EventTestWrapper.begin_capture!
74
+ yield
75
+ ensure
76
+ EventTestWrapper.end_capture!
77
+ end
78
+
79
+ private
80
+
81
+ def _dex_published_events
82
+ EventTestWrapper.published_events
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ require_relative "event_test_helpers/assertions"