jetstream_bridge 2.2.1 → 2.4.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: 4575d208da9591190342d3252b48f7dafa5d33519230700b23b2e2b6ff20555f
4
- data.tar.gz: 35153c2cf70e7e446dfc4fb8ff2cdbe656d907d34d4769c2e9971ca10aebe766
3
+ metadata.gz: 3609786f5181b8d4af9197952b7ff8d2878123c1c248bd6345454b99b43e0803
4
+ data.tar.gz: e0f521d0962785ca1c8e3cb271c7961d8aa9c55ac58b441ae04c39d150de4aae
5
5
  SHA512:
6
- metadata.gz: eb6e2726cebd8d71092dcc34e1956e9e0bdcb598f41e26badd7dbd6376e778487ec5544470d6aee44b0312f3983aa5f74c717208f6b7208296853b0c2f49a4db
7
- data.tar.gz: 53ba518040a3bb9a7883764621549cc794e8c84784f89c7c9c205de61cc662f748fd994587d6f1821ff8d110ae193b2d86b998c3dacc2127f8fd778e9e69606d
6
+ metadata.gz: cd6d5b0380a0ff25c60f093c7d64b80bd5733666d7621f07462817112b5fdeb4b6f63396e1be09ce7f5ad4cb9368d185ecd4a8c5674fae3e677def970fd2f1ed
7
+ data.tar.gz: 7d595ae9091f19e2c35168ed3422a2b5cf424d183f5d4c3942c97b423b8a9f9c70cd560e08c26979ec818929975dc590143e7fa30f5706068d594e4c59ec2598
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ class BackoffStrategy
5
+ TRANSIENT_ERRORS = [Timeout::Error, IOError].freeze
6
+ MAX_EXPONENT = 6
7
+ MAX_DELAY = 60
8
+ MIN_DELAY = 1
9
+
10
+ # Returns a bounded delay in seconds
11
+ def delay(deliveries, error)
12
+ base = transient?(error) ? 0.5 : 2.0
13
+ power = [deliveries - 1, MAX_EXPONENT].min
14
+ raw = (base * (2**power)).to_i
15
+ raw.clamp(MIN_DELAY, MAX_DELAY)
16
+ end
17
+
18
+ private
19
+
20
+ def transient?(error)
21
+ TRANSIENT_ERRORS.any? { |k| error.is_a?(k) }
22
+ end
23
+ end
24
+ end
@@ -13,23 +13,24 @@ require_relative 'subscription_manager'
13
13
  require_relative 'inbox/inbox_processor'
14
14
 
15
15
  module JetstreamBridge
16
- # Subscribes to "{env}.{dest}.sync.{app}" and processes messages.
16
+ # Subscribes to destination subject and processes messages via a pull durable.
17
17
  class Consumer
18
18
  DEFAULT_BATCH_SIZE = 25
19
19
  FETCH_TIMEOUT_SECS = 5
20
20
  IDLE_SLEEP_SECS = 0.05
21
21
 
22
- def initialize(durable_name:, batch_size: DEFAULT_BATCH_SIZE, &block)
22
+ def initialize(durable_name: JetstreamBridge.config.durable_name,
23
+ batch_size: DEFAULT_BATCH_SIZE, &block)
23
24
  @handler = block
24
25
  @batch_size = batch_size
25
- @durable = durable_name || JetstreamBridge.config.durable_name
26
+ @durable = durable_name
26
27
  @jts = Connection.connect!
27
28
 
28
29
  ensure_destination!
29
30
 
30
31
  @sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
31
32
  @sub_mgr.ensure_consumer!
32
- @psub = @sub_mgr.subscribe!
33
+ @psub = @sub_mgr.subscribe!
33
34
 
34
35
  @processor = MessageProcessor.new(@jts, @handler)
35
36
  @inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
@@ -71,24 +72,28 @@ module JetstreamBridge
71
72
  msgs.sum { |m| process_one(m) }
72
73
  end
73
74
 
74
- def process_one(m)
75
+ def process_one(msg)
75
76
  if @inbox_proc
76
- @inbox_proc.process(m) ? 1 : 0
77
+ @inbox_proc.process(msg) ? 1 : 0
77
78
  else
78
- @processor.handle_message(m)
79
+ @processor.handle_message(msg)
79
80
  1
80
81
  end
81
82
  end
82
83
 
83
84
  def handle_js_error(e)
84
85
  if recoverable_consumer_error?(e)
85
- Logging.warn("Recovering subscription after error: #{e.class} #{e.message}",
86
- tag: 'JetstreamBridge::Consumer')
86
+ Logging.warn(
87
+ "Recovering subscription after error: #{e.class} #{e.message}",
88
+ tag: 'JetstreamBridge::Consumer'
89
+ )
87
90
  @sub_mgr.ensure_consumer!
88
91
  @psub = @sub_mgr.subscribe!
89
92
  else
90
- Logging.error("Fetch failed: #{e.class} #{e.message}",
91
- tag: 'JetstreamBridge::Consumer')
93
+ Logging.error(
94
+ "Fetch failed: #{e.class} #{e.message}",
95
+ tag: 'JetstreamBridge::Consumer'
96
+ )
92
97
  end
93
98
  0
94
99
  end
@@ -9,23 +9,17 @@ module JetstreamBridge
9
9
  module ConsumerConfig
10
10
  module_function
11
11
 
12
+ # Complete consumer config (pre-provisioned durable, pull mode).
12
13
  def consumer_config(durable, filter_subject)
13
14
  {
14
15
  durable_name: durable,
15
16
  filter_subject: filter_subject,
16
17
  ack_policy: 'explicit',
18
+ deliver_policy: 'all',
17
19
  max_deliver: JetstreamBridge.config.max_deliver,
18
20
  ack_wait: Duration.to_millis(JetstreamBridge.config.ack_wait),
19
- backoff: Array(JetstreamBridge.config.backoff).map { |d| Duration.to_millis(d) }
20
- }
21
- end
22
-
23
- def subscribe_config
24
- {
25
- ack_policy: 'explicit',
26
- max_deliver: JetstreamBridge.config.max_deliver,
27
- ack_wait: Duration.to_millis(JetstreamBridge.config.ack_wait),
28
- backoff: Array(JetstreamBridge.config.backoff).map { |d| Duration.to_millis(d) }
21
+ backoff: Array(JetstreamBridge.config.backoff)
22
+ .map { |d| Duration.to_millis(d) }
29
23
  }
30
24
  end
31
25
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require_relative '../core/logging'
6
+
7
+ module JetstreamBridge
8
+ class DlqPublisher
9
+ def initialize(jts)
10
+ @jts = jts
11
+ end
12
+
13
+ # Sends original payload to DLQ with explanatory headers/context
14
+ def publish(msg, ctx, reason:, error_class:, error_message:)
15
+ return unless JetstreamBridge.config.use_dlq
16
+
17
+ envelope = build_envelope(ctx, reason, error_class, error_message)
18
+ headers = build_headers(msg.header, reason, ctx.deliveries, envelope)
19
+ @jts.publish(JetstreamBridge.config.dlq_subject, msg.data, header: headers)
20
+ rescue StandardError => e
21
+ Logging.error(
22
+ "DLQ publish failed event_id=#{ctx.event_id}: #{e.class} #{e.message}",
23
+ tag: 'JetstreamBridge::Consumer'
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def build_envelope(ctx, reason, error_class, error_message)
30
+ {
31
+ event_id: ctx.event_id,
32
+ reason: reason,
33
+ error_class: error_class,
34
+ error_message: error_message,
35
+ deliveries: ctx.deliveries,
36
+ original_subject: ctx.subject,
37
+ sequence: ctx.seq,
38
+ consumer: ctx.consumer,
39
+ stream: ctx.stream,
40
+ published_at: Time.now.utc.iso8601
41
+ }
42
+ end
43
+
44
+ def build_headers(original_headers, reason, deliveries, envelope)
45
+ headers = (original_headers || {}).dup
46
+ headers['x-dead-letter'] = 'true'
47
+ headers['x-dlq-reason'] = reason
48
+ headers['x-deliveries'] = deliveries.to_s
49
+ headers['x-dlq-context'] = JSON.generate(envelope)
50
+ headers
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module JetstreamBridge
6
+ # Immutable per-message metadata
7
+ MessageContext = Struct.new(
8
+ :event_id, :deliveries, :subject, :seq, :consumer, :stream,
9
+ keyword_init: true
10
+ ) do
11
+ def self.build(msg)
12
+ new(
13
+ event_id: msg.header&.[]('nats-msg-id') || SecureRandom.uuid,
14
+ deliveries: msg.metadata&.num_delivered.to_i,
15
+ subject: msg.subject,
16
+ seq: msg.metadata&.sequence,
17
+ consumer: msg.metadata&.consumer,
18
+ stream: msg.metadata&.stream
19
+ )
20
+ end
21
+ end
22
+ end
@@ -1,65 +1,111 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'securerandom'
5
4
  require_relative '../core/logging'
5
+ require_relative 'message_context'
6
+ require_relative 'dlq_publisher'
7
+ require_relative 'backoff_strategy'
6
8
 
7
9
  module JetstreamBridge
8
- # Handles parse → handler → ack / nak → DLQ
10
+ # Orchestrates parse → handler → ack/nak → DLQ
9
11
  class MessageProcessor
10
- def initialize(jts, handler)
11
- @jts = jts
12
+ UNRECOVERABLE_ERRORS = [ArgumentError, TypeError].freeze
13
+
14
+ def initialize(jts, handler, dlq: nil, backoff: nil)
15
+ @jts = jts
12
16
  @handler = handler
17
+ @dlq = dlq || DlqPublisher.new(jts)
18
+ @backoff = backoff || BackoffStrategy.new
13
19
  end
14
20
 
15
21
  def handle_message(msg)
16
- deliveries = msg.metadata&.num_delivered.to_i
17
- event_id = msg.header&.[]('Nats-Msg-Id') || SecureRandom.uuid
18
- event = parse_message(msg, event_id)
22
+ ctx = MessageContext.build(msg)
23
+ event = parse_message(msg, ctx)
19
24
  return unless event
20
25
 
21
- process_event(msg, event, deliveries, event_id)
26
+ process_event(msg, event, ctx)
27
+ rescue StandardError => e
28
+ Logging.error(
29
+ "Processor crashed event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} " \
30
+ "deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}",
31
+ tag: 'JetstreamBridge::Consumer'
32
+ )
33
+ safe_nak(msg)
22
34
  end
23
35
 
24
36
  private
25
37
 
26
- def parse_message(msg, event_id)
27
- JSON.parse(msg.data)
38
+ def parse_message(msg, ctx)
39
+ data = msg.data
40
+ if defined?(Oj)
41
+ Oj.load(data, mode: :strict)
42
+ else
43
+ JSON.parse(data)
44
+ end
28
45
  rescue JSON::ParserError => e
29
- publish_to_dlq!(msg)
46
+ @dlq.publish(msg, ctx,
47
+ reason: 'malformed_json', error_class: e.class.name, error_message: e.message)
30
48
  msg.ack
31
- Logging.warn("Malformed JSON to DLQ event_id=#{event_id}: #{e.message}",
32
- tag: 'JetstreamBridge::Consumer')
49
+ Logging.warn(
50
+ "Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
51
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
52
+ tag: 'JetstreamBridge::Consumer'
53
+ )
33
54
  nil
34
55
  end
35
56
 
36
- def process_event(msg, event, deliveries, event_id)
37
- @handler.call(event, msg.subject, deliveries)
57
+ def process_event(msg, event, ctx)
58
+ @handler.call(event, ctx.subject, ctx.deliveries)
59
+ msg.ack
60
+ Logging.info(
61
+ "ACK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
62
+ tag: 'JetstreamBridge::Consumer'
63
+ )
64
+ rescue *UNRECOVERABLE_ERRORS => e
65
+ @dlq.publish(msg, ctx,
66
+ reason: 'unrecoverable', error_class: e.class.name, error_message: e.message)
38
67
  msg.ack
68
+ Logging.warn(
69
+ "DLQ (unrecoverable) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
70
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{e.class}: #{e.message}",
71
+ tag: 'JetstreamBridge::Consumer'
72
+ )
39
73
  rescue StandardError => e
40
- ack_or_nak(msg, deliveries, event_id, e)
74
+ ack_or_nak(msg, ctx, e)
41
75
  end
42
76
 
43
- def ack_or_nak(msg, deliveries, event_id, error)
44
- if deliveries >= JetstreamBridge.config.max_deliver.to_i
45
- publish_to_dlq!(msg)
77
+ def ack_or_nak(msg, ctx, error)
78
+ max_deliver = JetstreamBridge.config.max_deliver.to_i
79
+ if ctx.deliveries >= max_deliver
80
+ @dlq.publish(msg, ctx,
81
+ reason: 'max_deliver_exceeded', error_class: error.class.name, error_message: error.message)
46
82
  msg.ack
47
- Logging.warn("Sent to DLQ after max_deliver event_id=#{event_id} err=#{error.message}",
48
- tag: 'JetstreamBridge::Consumer')
83
+ Logging.warn(
84
+ "DLQ (max_deliver) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
85
+ "seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
86
+ tag: 'JetstreamBridge::Consumer'
87
+ )
49
88
  else
50
- msg.nak
51
- Logging.warn("NAK event_id=#{event_id} deliveries=#{deliveries} err=#{error.message}",
52
- tag: 'JetstreamBridge::Consumer')
89
+ safe_nak(msg, ctx, error)
90
+ Logging.warn(
91
+ "NAK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} " \
92
+ "deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
93
+ tag: 'JetstreamBridge::Consumer'
94
+ )
53
95
  end
54
96
  end
55
97
 
56
- def publish_to_dlq!(msg)
57
- return unless JetstreamBridge.config.use_dlq
58
-
59
- @jts.publish(JetstreamBridge.config.dlq_subject, msg.data, header: msg.header)
98
+ def safe_nak(msg, ctx = nil, _error = nil)
99
+ # If your NATS client supports delayed NAKs, uncomment:
100
+ # delay = @backoff.delay(ctx&.deliveries.to_i, error) if ctx
101
+ # msg.nak(next_delivery_delay: delay)
102
+ msg.nak
60
103
  rescue StandardError => e
61
- Logging.error("DLQ publish failed: #{e.class} #{e.message}",
62
- tag: 'JetstreamBridge::Consumer')
104
+ Logging.error(
105
+ "Failed to NAK event_id=#{ctx&.event_id} deliveries=#{ctx&.deliveries}: " \
106
+ "#{e.class} #{e.message}",
107
+ tag: 'JetstreamBridge::Consumer'
108
+ )
63
109
  end
64
110
  end
65
111
  end
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../core/logging'
4
+ require_relative '../core/duration'
4
5
  require_relative '../consumer/consumer_config'
5
6
 
6
7
  module JetstreamBridge
7
- # Encapsulates durable ensure + subscribe for a consumer.
8
+ # Encapsulates durable ensure + subscribe for a pull consumer.
8
9
  class SubscriptionManager
9
10
  def initialize(jts, durable, cfg = JetstreamBridge.config)
10
11
  @jts = jts
@@ -27,17 +28,23 @@ module JetstreamBridge
27
28
  def ensure_consumer!
28
29
  info = consumer_info_or_nil
29
30
  return create_consumer! unless info
30
- return log_consumer_ok if consumer_matches?(info)
31
31
 
32
- recreate_consumer!
32
+ want = desired_consumer_cfg
33
+ if consumer_matches?(info, want)
34
+ log_consumer_ok
35
+ else
36
+ log_consumer_diff(info, want)
37
+ recreate_consumer!
38
+ end
33
39
  end
34
40
 
41
+ # Bind a pull subscriber to the existing durable.
35
42
  def subscribe!
36
43
  @jts.pull_subscribe(
37
44
  filter_subject,
38
45
  @durable,
39
46
  stream: stream_name,
40
- config: ConsumerConfig.subscribe_config
47
+ config: desired_consumer_cfg
41
48
  )
42
49
  end
43
50
 
@@ -49,13 +56,44 @@ module JetstreamBridge
49
56
  nil
50
57
  end
51
58
 
52
- def consumer_matches?(info)
53
- cfg = info.config
54
- have = (cfg.respond_to?(:filter_subject) ? cfg.filter_subject : cfg[:filter_subject]).to_s
55
- want = desired_consumer_cfg[:filter_subject].to_s
56
- have == want
59
+ # ---- comparison ----
60
+
61
+ def consumer_matches?(info, want)
62
+ have_norm = normalize_consumer_config(info.config)
63
+ want_norm = normalize_consumer_config(want)
64
+ have_norm == want_norm
65
+ end
66
+
67
+ def log_consumer_diff(info, want)
68
+ have_norm = normalize_consumer_config(info.config)
69
+ want_norm = normalize_consumer_config(want)
70
+
71
+ diffs = {}
72
+ (have_norm.keys | want_norm.keys).each do |k|
73
+ diffs[k] = { have: have_norm[k], want: want_norm[k] } unless have_norm[k] == want_norm[k]
74
+ end
75
+
76
+ Logging.warn(
77
+ "Consumer #{@durable} config mismatch (filter=#{filter_subject}) diff=#{diffs}",
78
+ tag: 'JetstreamBridge::Consumer'
79
+ )
57
80
  end
58
81
 
82
+ # Normalize both server-returned config objects and our desired hash
83
+ # into a common hash with consistent units/types for accurate comparison.
84
+ def normalize_consumer_config(cfg)
85
+ {
86
+ filter_subject: sval(cfg, :filter_subject), # string
87
+ ack_policy: sval(cfg, :ack_policy), # string
88
+ deliver_policy: sval(cfg, :deliver_policy), # string
89
+ max_deliver: ival(cfg, :max_deliver), # integer
90
+ ack_wait_ms: d_ms(cfg, :ack_wait), # integer ms
91
+ backoff_ms: darr_ms(cfg, :backoff) # array of integer ms
92
+ }
93
+ end
94
+
95
+ # ---- lifecycle helpers ----
96
+
59
97
  def recreate_consumer!
60
98
  Logging.warn(
61
99
  "Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
@@ -67,26 +105,85 @@ module JetstreamBridge
67
105
 
68
106
  def create_consumer!
69
107
  @jts.add_consumer(stream_name, **desired_consumer_cfg)
70
- Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
71
- tag: 'JetstreamBridge::Consumer')
108
+ Logging.info(
109
+ "Created consumer #{@durable} (filter=#{filter_subject})",
110
+ tag: 'JetstreamBridge::Consumer'
111
+ )
72
112
  end
73
113
 
74
114
  def log_consumer_ok
75
- Logging.info("Consumer #{@durable} exists with desired config.",
76
- tag: 'JetstreamBridge::Consumer')
115
+ Logging.info(
116
+ "Consumer #{@durable} exists with desired config.",
117
+ tag: 'JetstreamBridge::Consumer'
118
+ )
77
119
  end
78
120
 
79
121
  def safe_delete_consumer
80
122
  @jts.delete_consumer(stream_name, @durable)
81
123
  rescue NATS::JetStream::Error => e
82
- Logging.warn("Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
83
- tag: 'JetstreamBridge::Consumer')
124
+ Logging.warn(
125
+ "Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
126
+ tag: 'JetstreamBridge::Consumer'
127
+ )
128
+ end
129
+
130
+ # ---- cfg access/normalization (struct-like or hash-like) ----
131
+
132
+ def get(cfg, key)
133
+ cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key]
134
+ end
135
+
136
+ def sval(cfg, key)
137
+ v = get(cfg, key)
138
+ v = v.to_s if v.is_a?(Symbol)
139
+ v&.to_s&.downcase
140
+ end
141
+
142
+ def ival(cfg, key)
143
+ v = get(cfg, key)
144
+ v.to_i
145
+ end
146
+
147
+ # Normalize duration-like field to **milliseconds** (Integer).
148
+ # Accepts:
149
+ # - Strings:"500ms""30s" "2m", "1h", "250us", "100ns"
150
+ # - Integers/Floats:
151
+ # * Server may return large integers in **nanoseconds** → detect and convert.
152
+ # * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
153
+ def d_ms(cfg, key)
154
+ raw = get(cfg, key)
155
+ duration_to_ms(raw)
156
+ end
157
+
158
+ # Normalize array of durations to integer milliseconds.
159
+ def darr_ms(cfg, key)
160
+ raw = get(cfg, key)
161
+ Array(raw).map { |d| duration_to_ms(d) }
84
162
  end
85
163
 
86
- def consumer_mismatch?(info, desired_cfg)
87
- cfg = info.config
88
- (cfg.respond_to?(:filter_subject) ? cfg.filter_subject.to_s : cfg[:filter_subject].to_s) !=
89
- desired_cfg[:filter_subject].to_s
164
+ # ---- duration coercion ----
165
+
166
+ def duration_to_ms(val)
167
+ return nil if val.nil?
168
+
169
+ case val
170
+ when Integer
171
+ # Heuristic: extremely large integers are likely **nanoseconds** from server
172
+ # (e.g., 30s => 30_000_000_000 ns). Convert ns → ms.
173
+ return (val / 1_000_000.0).round if val >= 1_000_000_000
174
+
175
+ # otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
176
+ Duration.to_millis(val, default_unit: :auto)
177
+ when Float
178
+ Duration.to_millis(val, default_unit: :auto) # treated as seconds
179
+ when String
180
+ # Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
181
+ Duration.to_millis(val) # default_unit ignored when unit given
182
+ else
183
+ return Duration.to_millis(val.to_f, default_unit: :auto) if val.respond_to?(:to_f)
184
+
185
+ raise ArgumentError, "invalid duration: #{val.inspect}"
186
+ end
90
187
  end
91
188
  end
92
189
  end
@@ -48,9 +48,8 @@ module JetstreamBridge
48
48
  establish_connection(servers)
49
49
 
50
50
  Logging.info(
51
- "Connected to NATS (#{servers.size} server#{unless servers.size == 1
52
- 's'
53
- end}): #{sanitize_urls(servers).join(', ')}",
51
+ "Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
52
+ "#{sanitize_urls(servers).join(', ')}",
54
53
  tag: 'JetstreamBridge::Connection'
55
54
  )
56
55
 
@@ -82,14 +81,10 @@ module JetstreamBridge
82
81
  @jts = @nc.jetstream
83
82
 
84
83
  # --- Compatibility shim: ensure JetStream responds to #nc for older/newer clients ---
85
- # Some versions of the NATS Ruby client don't expose nc on the JetStream object.
86
- # We attach a singleton method, so code expecting `js.nc` continues to work.
87
84
  return if @jts.respond_to?(:nc)
88
85
 
89
86
  nc_ref = @nc
90
87
  @jts.define_singleton_method(:nc) { nc_ref }
91
-
92
- # ------------------------------------------------------------------------------------
93
88
  end
94
89
 
95
90
  # Expose for class-level helpers (not part of public API)