jetstream_bridge 2.10.0 → 3.0.1

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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +164 -0
  3. data/LICENSE +21 -0
  4. data/README.md +397 -0
  5. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +65 -0
  6. data/lib/generators/jetstream_bridge/health_check/templates/health_controller.rb +38 -0
  7. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +58 -13
  8. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +1 -1
  9. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb +1 -1
  10. data/lib/jetstream_bridge/consumer/consumer.rb +42 -1
  11. data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
  12. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +3 -1
  13. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -31
  14. data/lib/jetstream_bridge/consumer/message_processor.rb +65 -31
  15. data/lib/jetstream_bridge/core/config.rb +35 -0
  16. data/lib/jetstream_bridge/core/connection.rb +78 -3
  17. data/lib/jetstream_bridge/core/connection_factory.rb +100 -0
  18. data/lib/jetstream_bridge/core/debug_helper.rb +119 -0
  19. data/lib/jetstream_bridge/core/duration.rb +8 -1
  20. data/lib/jetstream_bridge/core/retry_strategy.rb +136 -0
  21. data/lib/jetstream_bridge/errors.rb +39 -0
  22. data/lib/jetstream_bridge/models/event_envelope.rb +136 -0
  23. data/lib/jetstream_bridge/models/subject.rb +94 -0
  24. data/lib/jetstream_bridge/publisher/outbox_repository.rb +47 -28
  25. data/lib/jetstream_bridge/publisher/publisher.rb +12 -35
  26. data/lib/jetstream_bridge/railtie.rb +33 -1
  27. data/lib/jetstream_bridge/tasks/install.rake +99 -0
  28. data/lib/jetstream_bridge/topology/overlap_guard.rb +15 -1
  29. data/lib/jetstream_bridge/topology/stream.rb +15 -5
  30. data/lib/jetstream_bridge/version.rb +1 -1
  31. data/lib/jetstream_bridge.rb +65 -0
  32. metadata +55 -11
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'connection'
4
+ require_relative '../errors'
5
+
6
+ module JetstreamBridge
7
+ module Core
8
+ # Factory for creating and managing NATS connections
9
+ class ConnectionFactory
10
+ # Connection options builder
11
+ class ConnectionOptions
12
+ DEFAULT_OPTS = {
13
+ reconnect: true,
14
+ reconnect_time_wait: 2,
15
+ max_reconnect_attempts: 10,
16
+ connect_timeout: 5
17
+ }.freeze
18
+
19
+ attr_accessor :servers, :reconnect, :reconnect_time_wait,
20
+ :max_reconnect_attempts, :connect_timeout,
21
+ :name, :user, :pass, :token
22
+ attr_reader :additional_opts
23
+
24
+ def initialize(servers: nil, **opts)
25
+ @servers = normalize_servers(servers) if servers
26
+ @additional_opts = {}
27
+
28
+ DEFAULT_OPTS.merge(opts).each do |key, value|
29
+ if respond_to?(:"#{key}=")
30
+ send(:"#{key}=", value)
31
+ else
32
+ @additional_opts[key] = value
33
+ end
34
+ end
35
+ end
36
+
37
+ def self.build(opts = {})
38
+ new(**opts)
39
+ end
40
+
41
+ def to_h
42
+ base = {
43
+ reconnect: @reconnect,
44
+ reconnect_time_wait: @reconnect_time_wait,
45
+ max_reconnect_attempts: @max_reconnect_attempts,
46
+ connect_timeout: @connect_timeout
47
+ }
48
+
49
+ base[:servers] = @servers if @servers
50
+ base[:name] = @name if @name
51
+ base[:user] = @user if @user
52
+ base[:pass] = @pass if @pass
53
+ base[:token] = @token if @token
54
+
55
+ base.merge(@additional_opts)
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_servers(servers)
61
+ Array(servers)
62
+ .flat_map { |s| s.to_s.split(',') }
63
+ .map(&:strip)
64
+ .reject(&:empty?)
65
+ end
66
+ end
67
+
68
+ class << self
69
+ # Create connection options from config
70
+ def build_options(config = JetstreamBridge.config)
71
+ servers = config.nats_urls
72
+ raise ConnectionNotEstablishedError, 'No NATS URLs configured' if servers.to_s.strip.empty?
73
+
74
+ ConnectionOptions.new(
75
+ servers: servers,
76
+ name: "#{config.app_name}-#{config.env}"
77
+ )
78
+ end
79
+
80
+ # Create a new NATS client
81
+ def create_client(options = nil)
82
+ opts = options || build_options
83
+ client = NATS::IO::Client.new
84
+ client.connect(opts.to_h)
85
+ client
86
+ end
87
+
88
+ # Create JetStream context with health monitoring
89
+ def create_jetstream(client)
90
+ jts = client.jetstream
91
+
92
+ # Ensure JetStream responds to #nc for compatibility
93
+ jts.define_singleton_method(:nc) { client } unless jts.respond_to?(:nc)
94
+
95
+ jts
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+
5
+ module JetstreamBridge
6
+ # Debug helper for troubleshooting JetStream Bridge operations
7
+ module DebugHelper
8
+ class << self
9
+ # Print comprehensive debug information about the current setup
10
+ def debug_info
11
+ info = {
12
+ config: config_debug,
13
+ connection: connection_debug,
14
+ stream: stream_debug,
15
+ health: JetstreamBridge.health_check
16
+ }
17
+
18
+ Logging.info('=== JetStream Bridge Debug Info ===', tag: 'JetstreamBridge::Debug')
19
+ info.each do |section, data|
20
+ Logging.info("#{section.to_s.upcase}:", tag: 'JetstreamBridge::Debug')
21
+ log_hash(data, indent: 2)
22
+ end
23
+ Logging.info('=== End Debug Info ===', tag: 'JetstreamBridge::Debug')
24
+
25
+ info
26
+ end
27
+
28
+ private
29
+
30
+ def config_debug
31
+ cfg = JetstreamBridge.config
32
+ {
33
+ env: cfg.env,
34
+ app_name: cfg.app_name,
35
+ destination_app: cfg.destination_app,
36
+ stream_name: cfg.stream_name,
37
+ source_subject: begin
38
+ cfg.source_subject
39
+ rescue StandardError
40
+ 'ERROR'
41
+ end,
42
+ destination_subject: begin
43
+ cfg.destination_subject
44
+ rescue StandardError
45
+ 'ERROR'
46
+ end,
47
+ dlq_subject: begin
48
+ cfg.dlq_subject
49
+ rescue StandardError
50
+ 'ERROR'
51
+ end,
52
+ durable_name: cfg.durable_name,
53
+ nats_urls: cfg.nats_urls,
54
+ max_deliver: cfg.max_deliver,
55
+ ack_wait: cfg.ack_wait,
56
+ backoff: cfg.backoff,
57
+ use_outbox: cfg.use_outbox,
58
+ use_inbox: cfg.use_inbox,
59
+ use_dlq: cfg.use_dlq,
60
+ outbox_model: cfg.outbox_model,
61
+ inbox_model: cfg.inbox_model
62
+ }
63
+ end
64
+
65
+ def connection_debug
66
+ conn = Connection.instance
67
+ {
68
+ connected: conn.connected?,
69
+ connected_at: conn.connected_at&.iso8601,
70
+ nc_present: !conn.instance_variable_get(:@nc).nil?,
71
+ jts_present: !conn.instance_variable_get(:@jts).nil?
72
+ }
73
+ rescue StandardError => e
74
+ { error: "#{e.class}: #{e.message}" }
75
+ end
76
+
77
+ def stream_debug
78
+ return { error: 'Not connected' } unless Connection.instance.connected?
79
+
80
+ jts = Connection.jetstream
81
+ cfg = JetstreamBridge.config
82
+ info = jts.stream_info(cfg.stream_name)
83
+
84
+ {
85
+ name: cfg.stream_name,
86
+ exists: true,
87
+ subjects: info.config.subjects,
88
+ retention: info.config.retention,
89
+ storage: info.config.storage,
90
+ max_consumers: info.config.max_consumers,
91
+ messages: info.state.messages,
92
+ bytes: info.state.bytes,
93
+ first_seq: info.state.first_seq,
94
+ last_seq: info.state.last_seq
95
+ }
96
+ rescue StandardError => e
97
+ {
98
+ name: JetstreamBridge.config.stream_name,
99
+ exists: false,
100
+ error: "#{e.class}: #{e.message}"
101
+ }
102
+ end
103
+
104
+ def log_hash(hash, indent: 0)
105
+ prefix = ' ' * indent
106
+ hash.each do |key, value|
107
+ if value.is_a?(Hash)
108
+ Logging.info("#{prefix}#{key}:", tag: 'JetstreamBridge::Debug')
109
+ log_hash(value, indent: indent + 2)
110
+ elsif value.is_a?(Array)
111
+ Logging.info("#{prefix}#{key}: #{value.inspect}", tag: 'JetstreamBridge::Debug')
112
+ else
113
+ Logging.info("#{prefix}#{key}: #{value}", tag: 'JetstreamBridge::Debug')
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -71,7 +71,14 @@ module JetstreamBridge
71
71
  def int_to_ms(num, default_unit:)
72
72
  case default_unit
73
73
  when :auto
74
- # Preserve existing heuristic for compatibility
74
+ # Preserve existing heuristic for compatibility but log deprecation warning
75
+ if defined?(Logging) && num.positive? && num < 1_000
76
+ Logging.debug(
77
+ "Duration :auto heuristic treating #{num} as seconds. " \
78
+ "Consider specifying default_unit: :s or :ms for clarity.",
79
+ tag: 'JetstreamBridge::Duration'
80
+ )
81
+ end
75
82
  num >= 1_000 ? num : num * 1_000
76
83
  else
77
84
  coerce_numeric_to_ms(num.to_f, default_unit)
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+
5
+ begin
6
+ require 'nats/io/client'
7
+ rescue LoadError
8
+ # NATS not available, PublisherRetryStrategy won't work but that's ok for tests
9
+ end
10
+
11
+ module JetstreamBridge
12
+ # Base retry strategy interface
13
+ class RetryStrategy
14
+ class RetryExhausted < StandardError; end
15
+
16
+ def initialize(max_attempts:, backoffs: [], transient_errors: [])
17
+ @max_attempts = max_attempts
18
+ @backoffs = backoffs
19
+ @transient_errors = transient_errors
20
+ end
21
+
22
+ # Execute block with retry logic
23
+ # @yield Block to execute with retry
24
+ # @return Result of the block
25
+ # @raise RetryExhausted if all attempts fail
26
+ def execute(context: nil)
27
+ attempts = 0
28
+ last_error = nil
29
+
30
+ loop do
31
+ attempts += 1
32
+ begin
33
+ return yield
34
+ rescue *retryable_errors => e
35
+ last_error = e
36
+ raise e if attempts >= @max_attempts
37
+
38
+ delay = calculate_delay(attempts, e)
39
+ log_retry(attempts, e, delay, context)
40
+ sleep delay
41
+ end
42
+ end
43
+ rescue StandardError => e
44
+ raise unless retryable?(e)
45
+
46
+ raise RetryExhausted, "Failed after #{attempts} attempts: #{e.message}"
47
+ end
48
+
49
+ protected
50
+
51
+ def calculate_delay(attempt, _error)
52
+ @backoffs[attempt - 1] || @backoffs.last || 1.0
53
+ end
54
+
55
+ def retryable_errors
56
+ @transient_errors.empty? ? [StandardError] : @transient_errors
57
+ end
58
+
59
+ def retryable?(error)
60
+ retryable_errors.any? { |klass| error.is_a?(klass) }
61
+ end
62
+
63
+ def log_retry(attempt, error, delay, context)
64
+ ctx_info = context ? " [#{context}]" : ''
65
+ Logging.warn(
66
+ "Retry #{attempt}/#{@max_attempts}#{ctx_info}: #{error.class} - #{error.message}, " \
67
+ "waiting #{delay}s",
68
+ tag: 'JetstreamBridge::RetryStrategy'
69
+ )
70
+ end
71
+ end
72
+
73
+ # Exponential backoff retry strategy
74
+ class ExponentialBackoffStrategy < RetryStrategy
75
+ def initialize(max_attempts: 3, base_delay: 0.1, max_delay: 60, multiplier: 2, transient_errors: [], jitter: true)
76
+ @base_delay = base_delay
77
+ @max_delay = max_delay
78
+ @multiplier = multiplier
79
+ @jitter = jitter
80
+ backoffs = compute_backoffs(max_attempts)
81
+ super(max_attempts: max_attempts, backoffs: backoffs, transient_errors: transient_errors)
82
+ end
83
+
84
+ protected
85
+
86
+ def calculate_delay(attempt, _error)
87
+ @backoffs[attempt - 1] || @backoffs.last || @base_delay
88
+ end
89
+
90
+ private
91
+
92
+ def compute_backoffs(max_attempts)
93
+ (0...max_attempts).map do |i|
94
+ delay = @base_delay * (@multiplier**i)
95
+ if @jitter
96
+ # Add up to 10% jitter, but ensure we don't exceed max_delay
97
+ jitter_amount = delay * 0.1 * rand
98
+ delay = [delay + jitter_amount, @max_delay].min
99
+ else
100
+ delay = [delay, @max_delay].min
101
+ end
102
+ delay
103
+ end
104
+ end
105
+ end
106
+
107
+ # Linear backoff retry strategy
108
+ class LinearBackoffStrategy < RetryStrategy
109
+ def initialize(max_attempts: 3, delays: [0.25, 1.0, 2.0], transient_errors: [])
110
+ super(max_attempts: max_attempts, backoffs: delays, transient_errors: transient_errors)
111
+ end
112
+ end
113
+
114
+ # Publisher-specific retry strategy
115
+ class PublisherRetryStrategy < LinearBackoffStrategy
116
+ TRANSIENT_ERRORS = begin
117
+ errs = []
118
+ if defined?(NATS::IO)
119
+ errs << NATS::IO::Timeout if defined?(NATS::IO::Timeout)
120
+ errs << NATS::IO::Error if defined?(NATS::IO::Error)
121
+ errs << NATS::IO::NoServersError if defined?(NATS::IO::NoServersError)
122
+ errs << NATS::IO::SocketTimeoutError if defined?(NATS::IO::SocketTimeoutError)
123
+ errs << Errno::ECONNREFUSED
124
+ end
125
+ errs
126
+ end.freeze
127
+
128
+ def initialize(max_attempts: 2)
129
+ super(
130
+ max_attempts: max_attempts,
131
+ delays: [0.25, 1.0],
132
+ transient_errors: TRANSIENT_ERRORS
133
+ )
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ # Base error for all JetStream Bridge errors
5
+ class Error < StandardError; end
6
+
7
+ # Configuration errors
8
+ class ConfigurationError < Error; end
9
+ class InvalidSubjectError < ConfigurationError; end
10
+ class MissingConfigurationError < ConfigurationError; end
11
+
12
+ # Connection errors
13
+ class ConnectionError < Error; end
14
+ class ConnectionNotEstablishedError < ConnectionError; end
15
+ class HealthCheckFailedError < ConnectionError; end
16
+
17
+ # Publisher errors
18
+ class PublishError < Error; end
19
+ class PublishFailedError < PublishError; end
20
+ class OutboxError < PublishError; end
21
+
22
+ # Consumer errors
23
+ class ConsumerError < Error; end
24
+ class HandlerError < ConsumerError; end
25
+ class InboxError < ConsumerError; end
26
+
27
+ # Topology errors
28
+ class TopologyError < Error; end
29
+ class StreamNotFoundError < TopologyError; end
30
+ class SubjectOverlapError < TopologyError; end
31
+ class StreamCreationFailedError < TopologyError; end
32
+
33
+ # DLQ errors
34
+ class DlqError < Error; end
35
+ class DlqPublishFailedError < DlqError; end
36
+
37
+ # Retry errors (already defined in retry_strategy.rb)
38
+ # class RetryExhausted < Error; end
39
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'time'
5
+
6
+ module JetstreamBridge
7
+ module Models
8
+ # Value object representing an event envelope
9
+ class EventEnvelope
10
+ SCHEMA_VERSION = 1
11
+
12
+ attr_reader :event_id, :schema_version, :event_type, :producer,
13
+ :resource_type, :resource_id, :occurred_at, :trace_id, :payload
14
+
15
+ def initialize(
16
+ resource_type:,
17
+ event_type:,
18
+ payload:,
19
+ event_id: nil,
20
+ occurred_at: nil,
21
+ trace_id: nil,
22
+ producer: nil,
23
+ resource_id: nil
24
+ )
25
+ @event_id = event_id || SecureRandom.uuid
26
+ @schema_version = SCHEMA_VERSION
27
+ @event_type = event_type.to_s
28
+ @producer = producer || JetstreamBridge.config.app_name
29
+ @resource_type = resource_type.to_s
30
+ @resource_id = resource_id || extract_resource_id(payload)
31
+ @occurred_at = parse_occurred_at(occurred_at)
32
+ @trace_id = trace_id || SecureRandom.hex(8)
33
+ @payload = deep_freeze(payload)
34
+
35
+ validate!
36
+ freeze
37
+ end
38
+
39
+ # Convert to hash for serialization
40
+ def to_h
41
+ hash = {
42
+ event_id: @event_id,
43
+ schema_version: @schema_version,
44
+ event_type: @event_type,
45
+ producer: @producer,
46
+ resource_type: @resource_type,
47
+ occurred_at: format_time(@occurred_at),
48
+ payload: @payload
49
+ }
50
+
51
+ # Only include optional fields if they have values
52
+ hash[:resource_id] = @resource_id if @resource_id && !@resource_id.to_s.empty?
53
+ hash[:trace_id] = @trace_id if @trace_id && !@trace_id.to_s.empty?
54
+
55
+ hash
56
+ end
57
+
58
+ # Create from hash
59
+ def self.from_h(hash)
60
+ new(
61
+ event_id: hash['event_id'] || hash[:event_id],
62
+ event_type: hash['event_type'] || hash[:event_type],
63
+ producer: hash['producer'] || hash[:producer],
64
+ resource_type: hash['resource_type'] || hash[:resource_type],
65
+ resource_id: hash['resource_id'] || hash[:resource_id],
66
+ occurred_at: parse_time(hash['occurred_at'] || hash[:occurred_at]),
67
+ trace_id: hash['trace_id'] || hash[:trace_id],
68
+ payload: hash['payload'] || hash[:payload] || {}
69
+ )
70
+ end
71
+
72
+ def ==(other)
73
+ other.is_a?(EventEnvelope) && event_id == other.event_id
74
+ end
75
+
76
+ alias eql? ==
77
+
78
+ def hash
79
+ event_id.hash
80
+ end
81
+
82
+ private
83
+
84
+ def extract_resource_id(payload)
85
+ return '' unless payload.respond_to?(:[])
86
+
87
+ (payload['id'] || payload[:id]).to_s
88
+ end
89
+
90
+ def validate!
91
+ raise ArgumentError, 'event_type cannot be blank' if @event_type.empty?
92
+ raise ArgumentError, 'resource_type cannot be blank' if @resource_type.empty?
93
+ raise ArgumentError, 'payload cannot be nil' if @payload.nil?
94
+ end
95
+
96
+ def parse_occurred_at(value)
97
+ return Time.now.utc if value.nil?
98
+ return value if value.is_a?(Time)
99
+
100
+ Time.parse(value.to_s)
101
+ rescue ArgumentError
102
+ Time.now.utc
103
+ end
104
+
105
+ def format_time(time)
106
+ time.is_a?(Time) ? time.iso8601 : time.to_s
107
+ end
108
+
109
+ def deep_freeze(obj)
110
+ case obj
111
+ when Hash
112
+ obj.each do |k, v|
113
+ deep_freeze(k)
114
+ deep_freeze(v)
115
+ end
116
+ obj.freeze
117
+ when Array
118
+ obj.each { |item| deep_freeze(item) }
119
+ obj.freeze
120
+ else
121
+ obj.freeze if obj.respond_to?(:freeze)
122
+ end
123
+ obj
124
+ end
125
+
126
+ def self.parse_time(value)
127
+ return value if value.is_a?(Time)
128
+ return Time.now.utc if value.nil?
129
+
130
+ Time.parse(value.to_s)
131
+ rescue ArgumentError
132
+ Time.now.utc
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ module Models
5
+ # Value object representing a NATS subject
6
+ class Subject
7
+ WILDCARD_SINGLE = '*'
8
+ WILDCARD_MULTI = '>'
9
+ SEPARATOR = '.'
10
+ INVALID_CHARS = /[#{Regexp.escape(WILDCARD_SINGLE + WILDCARD_MULTI + SEPARATOR)}]/.freeze
11
+
12
+ attr_reader :value, :tokens
13
+
14
+ def initialize(value)
15
+ @value = value.to_s
16
+ @tokens = @value.split(SEPARATOR)
17
+ validate!
18
+ @value.freeze
19
+ @tokens.freeze
20
+ freeze
21
+ end
22
+
23
+ # Factory methods
24
+ def self.source(env:, app_name:, dest:)
25
+ new("#{env}.#{app_name}.sync.#{dest}")
26
+ end
27
+
28
+ def self.destination(env:, source:, app_name:)
29
+ new("#{env}.#{source}.sync.#{app_name}")
30
+ end
31
+
32
+ def self.dlq(env:)
33
+ new("#{env}.sync.dlq")
34
+ end
35
+
36
+ # Check if this subject matches a pattern
37
+ def matches?(pattern)
38
+ SubjectMatcher.match?(pattern.to_s, @value)
39
+ end
40
+
41
+ # Check if this subject overlaps with another
42
+ def overlaps?(other)
43
+ SubjectMatcher.overlap?(@value, other.to_s)
44
+ end
45
+
46
+ # Check if covered by any pattern in a list
47
+ def covered_by?(patterns)
48
+ SubjectMatcher.covered?(Array(patterns).map(&:to_s), @value)
49
+ end
50
+
51
+ def to_s
52
+ @value
53
+ end
54
+
55
+ def ==(other)
56
+ @value == (other.is_a?(Subject) ? other.value : other.to_s)
57
+ end
58
+
59
+ alias eql? ==
60
+
61
+ def hash
62
+ @value.hash
63
+ end
64
+
65
+ # Validate a component (env, app_name, etc.) for use in subjects
66
+ def self.validate_component!(value, name)
67
+ str = value.to_s
68
+ if str.match?(INVALID_CHARS)
69
+ raise ArgumentError,
70
+ "#{name} cannot contain NATS wildcards (#{SEPARATOR}, #{WILDCARD_SINGLE}, #{WILDCARD_MULTI}): #{value.inspect}"
71
+ end
72
+ raise ArgumentError, "#{name} cannot be empty" if str.strip.empty?
73
+
74
+ true
75
+ end
76
+
77
+ private
78
+
79
+ def validate!
80
+ raise ArgumentError, 'Subject cannot be empty' if @value.empty?
81
+ raise ArgumentError, 'Subject cannot contain only separators' if @tokens.all?(&:empty?)
82
+ raise ArgumentError, 'Subject has invalid format (contains spaces or special characters)' if @value.match?(/\s/)
83
+ end
84
+
85
+ # Lazy-load SubjectMatcher to avoid circular dependency
86
+ def self.subject_matcher
87
+ require_relative '../topology/subject_matcher' unless defined?(JetstreamBridge::SubjectMatcher)
88
+ JetstreamBridge::SubjectMatcher
89
+ end
90
+
91
+ SubjectMatcher = subject_matcher
92
+ end
93
+ end
94
+ end