jetstream_bridge 5.0.2 → 7.0.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.
@@ -51,50 +51,37 @@ module JetstreamBridge
51
51
  def subscribe_without_verification!
52
52
  # Manually create a pull subscription without calling consumer_info
53
53
  # This bypasses the permission check in nats-pure's pull_subscribe
54
- nc = resolve_nc
55
-
56
- return build_pull_subscription(nc) if nc.respond_to?(:new_inbox) && nc.respond_to?(:subscribe)
57
-
58
- # Fallback for environments (mocks/tests) where low-level NATS client is unavailable.
59
- if @jts.respond_to?(:pull_subscribe)
60
- Logging.info(
61
- "Using pull_subscribe fallback for consumer #{@durable} (stream=#{stream_name})",
62
- tag: 'JetstreamBridge::Consumer'
63
- )
64
- return @jts.pull_subscribe(filter_subject, @durable, stream: stream_name)
65
- end
66
-
67
- raise JetstreamBridge::ConnectionError,
68
- 'Unable to create subscription without verification: NATS client not available'
54
+ create_subscription_with_fallback(
55
+ description: "pull subscription for consumer #{@durable} (stream=#{stream_name})",
56
+ primary_check: ->(nc) { nc.respond_to?(:new_inbox) && nc.respond_to?(:subscribe) },
57
+ primary_action: ->(nc) { build_pull_subscription(nc) },
58
+ fallback_name: :pull_subscribe,
59
+ fallback_available: -> { @jts.respond_to?(:pull_subscribe) },
60
+ fallback_action: -> { @jts.pull_subscribe(filter_subject, @durable, stream: stream_name) }
61
+ )
69
62
  end
70
63
 
71
64
  def subscribe_push!
72
65
  # Push consumers deliver messages directly to a subscription subject
73
66
  # No JetStream API calls needed - just subscribe to the delivery subject
74
- nc = resolve_nc
75
67
  delivery_subject = @cfg.push_delivery_subject
76
68
 
77
- if nc.respond_to?(:subscribe)
78
- sub = nc.subscribe(delivery_subject)
79
- Logging.info(
80
- "Created push subscription for consumer #{@durable} " \
81
- "(stream=#{stream_name}, delivery=#{delivery_subject})",
82
- tag: 'JetstreamBridge::Consumer'
83
- )
84
- return sub
85
- end
86
-
87
- # Fallback for test environments
88
- if @jts.respond_to?(:subscribe)
89
- Logging.info(
90
- "Using JetStream subscribe fallback for push consumer #{@durable} (stream=#{stream_name})",
91
- tag: 'JetstreamBridge::Consumer'
92
- )
93
- return @jts.subscribe(delivery_subject)
94
- end
95
-
96
- raise JetstreamBridge::ConnectionError,
97
- 'Unable to create push subscription: NATS client not available'
69
+ create_subscription_with_fallback(
70
+ description: "push subscription for consumer #{@durable} (stream=#{stream_name}, delivery=#{delivery_subject})",
71
+ primary_check: ->(nc) { nc.respond_to?(:subscribe) },
72
+ primary_action: lambda do |nc|
73
+ sub = nc.subscribe(delivery_subject)
74
+ Logging.info(
75
+ "Created push subscription for consumer #{@durable} " \
76
+ "(stream=#{stream_name}, delivery=#{delivery_subject})",
77
+ tag: 'JetstreamBridge::Consumer'
78
+ )
79
+ sub
80
+ end,
81
+ fallback_name: :subscribe,
82
+ fallback_available: -> { @jts.respond_to?(:subscribe) },
83
+ fallback_action: -> { @jts.subscribe(delivery_subject) }
84
+ )
98
85
  end
99
86
 
100
87
  private
@@ -107,8 +94,8 @@ module JetstreamBridge
107
94
  deliver_policy: 'all',
108
95
  max_deliver: JetstreamBridge.config.max_deliver,
109
96
  # JetStream expects seconds (the client multiplies by nanoseconds).
110
- ack_wait: duration_to_seconds(JetstreamBridge.config.ack_wait),
111
- backoff: Array(JetstreamBridge.config.backoff).map { |d| duration_to_seconds(d) }
97
+ ack_wait: Duration.to_seconds(JetstreamBridge.config.ack_wait),
98
+ backoff: Duration.normalize_list_to_seconds(JetstreamBridge.config.backoff)
112
99
  }
113
100
 
114
101
  # Add deliver_subject for push consumers
@@ -125,73 +112,6 @@ module JetstreamBridge
125
112
  )
126
113
  end
127
114
 
128
- # ---- cfg access/normalization (struct-like or hash-like) ----
129
-
130
- def get(cfg, key)
131
- cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key]
132
- end
133
-
134
- def sval(cfg, key)
135
- v = get(cfg, key)
136
- v = v.to_s if v.is_a?(Symbol)
137
- v&.to_s&.downcase
138
- end
139
-
140
- def ival(cfg, key)
141
- v = get(cfg, key)
142
- v.to_i
143
- end
144
-
145
- # Normalize duration-like field to **milliseconds** (Integer).
146
- # Accepts:
147
- # - Strings:"500ms""30s" "2m", "1h", "250us", "100ns"
148
- # - Integers/Floats:
149
- # * Server may return large integers in **nanoseconds** → detect and convert.
150
- # * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
151
- def d_secs(cfg, key)
152
- raw = get(cfg, key)
153
- duration_to_seconds(raw)
154
- end
155
-
156
- # Normalize array of durations to integer milliseconds.
157
- def darr_secs(cfg, key)
158
- raw = get(cfg, key)
159
- Array(raw).map { |d| duration_to_seconds(d) }
160
- end
161
-
162
- # ---- duration coercion ----
163
-
164
- def duration_to_seconds(val)
165
- return nil if val.nil?
166
-
167
- case val
168
- when Integer
169
- # Heuristic: extremely large integers are likely **nanoseconds** from server
170
- # (e.g., 30s => 30_000_000_000 ns). Convert ns → seconds.
171
- return (val / 1_000_000_000.0).round if val >= 1_000_000_000
172
-
173
- # otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
174
- millis = Duration.to_millis(val, default_unit: :auto)
175
- seconds_from_millis(millis)
176
- when Float
177
- millis = Duration.to_millis(val, default_unit: :auto)
178
- seconds_from_millis(millis)
179
- when String
180
- # Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
181
- millis = Duration.to_millis(val) # default_unit ignored when unit given
182
- seconds_from_millis(millis)
183
- else
184
- return duration_to_seconds(val.to_f) if val.respond_to?(:to_f)
185
-
186
- raise ArgumentError, "invalid duration: #{val.inspect}"
187
- end
188
- end
189
-
190
- def seconds_from_millis(millis)
191
- # Always round up to avoid zero-second waits when sub-second durations are provided.
192
- [(millis / 1000.0).ceil, 1].max
193
- end
194
-
195
115
  def log_runtime_skip
196
116
  Logging.info(
197
117
  "Skipping consumer provisioning/verification for #{@durable} at runtime to avoid JetStream API usage. " \
@@ -213,5 +133,23 @@ module JetstreamBridge
213
133
  builder = PullSubscriptionBuilder.new(@jts, @durable, stream_name, filter_subject)
214
134
  builder.build(nats_client)
215
135
  end
136
+
137
+ def create_subscription_with_fallback(description:, primary_check:, primary_action:, fallback_name:,
138
+ fallback_available:, fallback_action:)
139
+ nc = resolve_nc
140
+
141
+ return primary_action.call(nc) if nc && primary_check.call(nc)
142
+
143
+ if fallback_available.call
144
+ Logging.info(
145
+ "Using #{fallback_name} fallback for #{description}",
146
+ tag: 'JetstreamBridge::Consumer'
147
+ )
148
+ return fallback_action.call
149
+ end
150
+
151
+ raise JetstreamBridge::ConnectionError,
152
+ "Unable to create #{description}: NATS client not available"
153
+ end
216
154
  end
217
155
  end
@@ -237,7 +237,36 @@ module JetstreamBridge
237
237
  'NATS reconnected, refreshing JetStream context',
238
238
  tag: 'JetstreamBridge::Connection'
239
239
  )
240
- refresh_jetstream_context
240
+
241
+ # Retry JetStream context refresh with exponential backoff
242
+ max_attempts = 3
243
+ attempts = 0
244
+ success = false
245
+
246
+ while attempts < max_attempts && !success
247
+ attempts += 1
248
+ begin
249
+ refresh_jetstream_context
250
+ success = true
251
+ rescue StandardError => e
252
+ if attempts < max_attempts
253
+ delay = 0.5 * (2**(attempts - 1)) # 0.5s, 1s, 2s
254
+ Logging.warn(
255
+ "JetStream context refresh attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
256
+ "Retrying in #{delay}s...",
257
+ tag: 'JetstreamBridge::Connection'
258
+ )
259
+ sleep(delay)
260
+ else
261
+ Logging.error(
262
+ "Failed to refresh JetStream context after #{attempts} attempts. " \
263
+ 'Will retry on next reconnect.',
264
+ tag: 'JetstreamBridge::Connection'
265
+ )
266
+ end
267
+ end
268
+ end
269
+
241
270
  @reconnecting = false
242
271
  end
243
272
 
@@ -275,7 +304,7 @@ module JetstreamBridge
275
304
  if verify_js
276
305
  verify_jetstream!
277
306
  if config_auto_provision
278
- Topology.ensure!(@jts)
307
+ Topology.provision!(@jts)
279
308
  Logging.info(
280
309
  'Topology ensured after connection (auto_provision=true).',
281
310
  tag: 'JetstreamBridge::Connection'
@@ -432,7 +461,7 @@ module JetstreamBridge
432
461
 
433
462
  # Re-ensure topology after reconnect when allowed
434
463
  if config_auto_provision
435
- Topology.ensure!(@jts)
464
+ Topology.provision!(@jts)
436
465
  else
437
466
  Logging.info(
438
467
  'Skipping topology provisioning after reconnect (auto_provision=false).',
@@ -458,15 +487,21 @@ module JetstreamBridge
458
487
  @last_reconnect_error = e
459
488
  @last_reconnect_error_at = Time.now
460
489
  @state = State::FAILED
461
- cleanup_connection!(close_nc: false)
490
+
491
+ # Clear JetStream context but keep NATS connection alive for retry
492
+ @jts = nil
493
+
494
+ # Invalidate health check cache to force re-check
495
+ @cached_health_status = false
496
+ @last_health_check = Time.now.to_i
497
+
462
498
  Logging.error(
463
499
  "Failed to refresh JetStream context: #{e.class} #{e.message}",
464
500
  tag: 'JetstreamBridge::Connection'
465
501
  )
466
502
 
467
- # Invalidate health check cache to force re-check
468
- @cached_health_status = false
469
- @last_health_check = Time.now.to_i
503
+ # Re-raise so caller (on_reconnect handler) can retry
504
+ raise
470
505
  end
471
506
 
472
507
  # Expose for class-level helpers (not part of public API)
@@ -491,11 +526,22 @@ module JetstreamBridge
491
526
  rescue StandardError
492
527
  # ignore cleanup errors
493
528
  end
494
- @nc = nil
495
- @jts = nil
529
+
530
+ # Only clear connection references if we're closing the connection
531
+ # When close_nc is false (e.g., during reconnect failures), keep @nc
532
+ # so the connection can recover when NATS auto-reconnects
533
+ if close_nc
534
+ @nc = nil
535
+ @jts = nil
536
+ @connected_at = nil
537
+ else
538
+ # Clear JetStream context but keep NATS connection reference
539
+ @jts = nil
540
+ end
541
+
542
+ # Always invalidate health check cache
496
543
  @cached_health_status = nil
497
544
  @last_health_check = nil
498
- @connected_at = nil
499
545
  end
500
546
 
501
547
  def config_auto_provision
@@ -64,6 +64,31 @@ module JetstreamBridge
64
64
  vals.map { |v| to_millis(v, default_unit: default_unit) }
65
65
  end
66
66
 
67
+ # Convert duration-like value to seconds (rounding up, min 1s).
68
+ #
69
+ # Retains the nanosecond heuristic used in SubscriptionManager:
70
+ # extremely large integers (>= 1_000_000_000) are treated as nanoseconds
71
+ # when default_unit is :auto.
72
+ def to_seconds(val, default_unit: :auto)
73
+ return nil if val.nil?
74
+
75
+ millis = if val.is_a?(Integer) && default_unit == :auto && val >= 1_000_000_000
76
+ to_millis(val, default_unit: :ns)
77
+ else
78
+ to_millis(val, default_unit: default_unit)
79
+ end
80
+
81
+ seconds_from_millis(millis)
82
+ end
83
+
84
+ # Normalize an array of durations into integer seconds.
85
+ def normalize_list_to_seconds(values, default_unit: :auto)
86
+ vals = Array(values)
87
+ return [] if vals.empty?
88
+
89
+ vals.map { |v| to_seconds(v, default_unit: default_unit) }
90
+ end
91
+
67
92
  # --- internal helpers ---
68
93
 
69
94
  def int_to_ms(num, default_unit:)
@@ -100,5 +125,10 @@ module JetstreamBridge
100
125
 
101
126
  (num * mult).round
102
127
  end
128
+
129
+ def seconds_from_millis(millis)
130
+ # Always round up to avoid zero-second waits when sub-second durations are provided.
131
+ [(millis / 1000.0).ceil, 1].max
132
+ end
103
133
  end
104
134
  end
@@ -11,7 +11,7 @@ end
11
11
  module JetstreamBridge
12
12
  if defined?(ActiveRecord::Base)
13
13
  class InboxEvent < ActiveRecord::Base
14
- self.table_name = 'jetstream_inbox_events'
14
+ self.table_name = 'jetstream_bridge_inbox_events'
15
15
 
16
16
  class << self
17
17
  # Safe column presence check that never boots a connection during class load.
@@ -11,7 +11,7 @@ end
11
11
  module JetstreamBridge
12
12
  if defined?(ActiveRecord::Base)
13
13
  class OutboxEvent < ActiveRecord::Base
14
- self.table_name = 'jetstream_outbox_events'
14
+ self.table_name = 'jetstream_bridge_outbox_events'
15
15
 
16
16
  class << self
17
17
  # Safe column presence check that never boots a connection during class load.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
3
4
  require_relative 'topology/topology'
4
5
  require_relative 'consumer/subscription_manager'
5
6
  require_relative 'core/logging'
@@ -13,52 +14,107 @@ module JetstreamBridge
13
14
  # deploy-time with admin credentials or during runtime when auto_provision
14
15
  # is enabled.
15
16
  class Provisioner
17
+ class << self
18
+ # Provision both directions (A->B and B->A) with shared defaults.
19
+ #
20
+ # @param app_a [String] First app name
21
+ # @param app_b [String] Second app name
22
+ # @param stream_name [String] Stream used for both directions
23
+ # @param nats_url [String] NATS connection URL
24
+ # @param logger [Logger] Logger used for progress output
25
+ # @param shared_config [Hash] Additional config applied to both directions
26
+ #
27
+ # @return [void]
28
+ def provision_bidirectional!(
29
+ app_a:,
30
+ app_b:,
31
+ stream_name: 'sync-stream',
32
+ nats_url: ENV.fetch('NATS_URL', 'nats://nats:4222'),
33
+ logger: Logger.new($stdout),
34
+ **shared_config
35
+ )
36
+ [
37
+ { app_name: app_a, destination_app: app_b },
38
+ { app_name: app_b, destination_app: app_a }
39
+ ].each do |direction|
40
+ logger&.info "Provisioning #{direction[:app_name]} -> #{direction[:destination_app]}"
41
+ configure_direction(direction, stream_name, nats_url, logger, shared_config)
42
+
43
+ begin
44
+ JetstreamBridge.startup!
45
+ new.provision!
46
+ ensure
47
+ JetstreamBridge.shutdown!
48
+ end
49
+ end
50
+ end
51
+
52
+ def configure_direction(direction, stream_name, nats_url, logger, shared_config)
53
+ JetstreamBridge.configure do |cfg|
54
+ cfg.nats_urls = nats_url
55
+ cfg.app_name = direction[:app_name]
56
+ cfg.destination_app = direction[:destination_app]
57
+ cfg.stream_name = stream_name
58
+ cfg.auto_provision = true
59
+ cfg.use_outbox = false
60
+ cfg.use_inbox = false
61
+ cfg.logger = logger if logger
62
+
63
+ shared_config.each do |key, value|
64
+ setter = "#{key}="
65
+ cfg.public_send(setter, value) if cfg.respond_to?(setter)
66
+ end
67
+ end
68
+ end
69
+ private :configure_direction
70
+ end
71
+
16
72
  def initialize(config: JetstreamBridge.config)
17
73
  @config = config
18
74
  end
19
75
 
20
- # Ensure stream (and optionally consumer) exist with desired config.
76
+ # Provision stream (and optionally consumer) with desired config.
21
77
  #
22
78
  # @param jts [Object, nil] Existing JetStream context (optional)
23
- # @param ensure_consumer [Boolean] Whether to create/align the consumer too
79
+ # @param provision_consumer [Boolean] Whether to create/align the consumer too
24
80
  # @return [Object] JetStream context used for provisioning
25
- def ensure!(jts: nil, ensure_consumer: true)
81
+ def provision!(jts: nil, provision_consumer: true)
26
82
  js = jts || Connection.connect!(verify_js: true)
27
83
 
28
- ensure_stream!(jts: js)
29
- ensure_consumer!(jts: js) if ensure_consumer
84
+ provision_stream!(jts: js)
85
+ provision_consumer!(jts: js) if provision_consumer
30
86
 
31
87
  Logging.info(
32
- "Provisioned stream=#{@config.stream_name} consumer=#{@config.durable_name if ensure_consumer}",
88
+ "Provisioned stream=#{@config.stream_name} consumer=#{@config.durable_name if provision_consumer}",
33
89
  tag: 'JetstreamBridge::Provisioner'
34
90
  )
35
91
 
36
92
  js
37
93
  end
38
94
 
39
- # Ensure stream only.
95
+ # Provision stream only.
40
96
  #
41
97
  # @param jts [Object, nil] Existing JetStream context (optional)
42
98
  # @return [Object] JetStream context used
43
- def ensure_stream!(jts: nil)
99
+ def provision_stream!(jts: nil)
44
100
  js = jts || Connection.connect!(verify_js: true)
45
- Topology.ensure!(js)
101
+ Topology.provision!(js)
46
102
  Logging.info(
47
- "Stream ensured: #{@config.stream_name}",
103
+ "Stream provisioned: #{@config.stream_name}",
48
104
  tag: 'JetstreamBridge::Provisioner'
49
105
  )
50
106
  js
51
107
  end
52
108
 
53
- # Ensure durable consumer only.
109
+ # Provision durable consumer only.
54
110
  #
55
111
  # @param jts [Object, nil] Existing JetStream context (optional)
56
112
  # @return [Object] JetStream context used
57
- def ensure_consumer!(jts: nil)
113
+ def provision_consumer!(jts: nil)
58
114
  js = jts || Connection.connect!(verify_js: true)
59
115
  SubscriptionManager.new(js, @config.durable_name, @config).ensure_consumer!(force: true)
60
116
  Logging.info(
61
- "Consumer ensured: #{@config.durable_name}",
117
+ "Consumer provisioned: #{@config.durable_name}",
62
118
  tag: 'JetstreamBridge::Provisioner'
63
119
  )
64
120
  js
@@ -35,29 +35,15 @@ module JetstreamBridge
35
35
  (record.respond_to?(:status) && record.status == 'sent')
36
36
  end
37
37
 
38
- def persist_pre(record, subject, envelope)
38
+ def record_publish_attempt(record, subject, envelope)
39
39
  ActiveRecord::Base.transaction do
40
- now = Time.now.utc
41
- event_id = envelope['event_id'].to_s
42
-
43
- attrs = {
44
- event_id: event_id,
45
- subject: subject,
46
- payload: ModelUtils.json_dump(envelope),
47
- headers: ModelUtils.json_dump({ 'nats-msg-id' => event_id }),
48
- status: 'publishing',
49
- last_error: nil
50
- }
51
- attrs[:attempts] = 1 + (record.attempts || 0) if record.respond_to?(:attempts)
52
- attrs[:enqueued_at] = (record.enqueued_at || now) if record.respond_to?(:enqueued_at)
53
- attrs[:updated_at] = now if record.respond_to?(:updated_at)
54
-
40
+ attrs = build_publish_attrs(record, subject, envelope)
55
41
  ModelUtils.assign_known_attrs(record, attrs)
56
42
  record.save!
57
43
  end
58
44
  end
59
45
 
60
- def persist_success(record)
46
+ def record_publish_success(record)
61
47
  ActiveRecord::Base.transaction do
62
48
  now = Time.now.utc
63
49
  attrs = { status: 'sent' }
@@ -68,7 +54,7 @@ module JetstreamBridge
68
54
  end
69
55
  end
70
56
 
71
- def persist_failure(record, message)
57
+ def record_publish_failure(record, message)
72
58
  ActiveRecord::Base.transaction do
73
59
  now = Time.now.utc
74
60
  attrs = { status: 'failed', last_error: message }
@@ -78,13 +64,42 @@ module JetstreamBridge
78
64
  end
79
65
  end
80
66
 
81
- def persist_exception(record, error)
67
+ def record_publish_exception(record, error)
82
68
  return unless record
83
69
 
84
- persist_failure(record, "#{error.class}: #{error.message}")
70
+ record_publish_failure(record, "#{error.class}: #{error.message}")
85
71
  rescue StandardError => e
86
72
  Logging.warn("Failed to persist outbox failure: #{e.class}: #{e.message}",
87
73
  tag: 'JetstreamBridge::Publisher')
88
74
  end
75
+
76
+ private
77
+
78
+ def build_publish_attrs(record, subject, envelope)
79
+ now = Time.now.utc
80
+ event_id = envelope['event_id'].to_s
81
+
82
+ attrs = {
83
+ event_id: event_id,
84
+ subject: subject,
85
+ payload: ModelUtils.json_dump(envelope),
86
+ headers: ModelUtils.json_dump({ 'nats-msg-id' => event_id }),
87
+ status: 'publishing',
88
+ last_error: nil,
89
+ resource_type: envelope['resource_type'],
90
+ resource_id: envelope['resource_id'],
91
+ event_type: envelope['type'] || envelope['event_type']
92
+ }
93
+
94
+ assign_optional_publish_attrs(record, attrs, now)
95
+ attrs
96
+ end
97
+
98
+ def assign_optional_publish_attrs(record, attrs, now)
99
+ attrs[:destination_app] = JetstreamBridge.config.destination_app if record.respond_to?(:destination_app=)
100
+ attrs[:attempts] = 1 + (record.attempts || 0) if record.respond_to?(:attempts)
101
+ attrs[:enqueued_at] = (record.enqueued_at || now) if record.respond_to?(:enqueued_at)
102
+ attrs[:updated_at] = now if record.respond_to?(:updated_at)
103
+ end
89
104
  end
90
105
  end
@@ -252,17 +252,17 @@ module JetstreamBridge
252
252
  )
253
253
  end
254
254
 
255
- repo.persist_pre(record, subject, envelope)
255
+ repo.record_publish_attempt(record, subject, envelope)
256
256
 
257
257
  result = with_retries { publish_to_nats(subject, envelope) }
258
258
  if result.success?
259
- repo.persist_success(record)
259
+ repo.record_publish_success(record)
260
260
  else
261
- repo.persist_failure(record, result.error&.message || 'Publish failed')
261
+ repo.record_publish_failure(record, result.error&.message || 'Publish failed')
262
262
  end
263
263
  result
264
264
  rescue StandardError => e
265
- repo.persist_exception(record, e) if defined?(repo) && defined?(record)
265
+ repo.record_publish_exception(record, e) if defined?(repo) && defined?(record)
266
266
  Models::PublishResult.new(
267
267
  success: false,
268
268
  event_id: envelope['event_id'],
@@ -113,12 +113,12 @@ namespace :jetstream_bridge do
113
113
 
114
114
  begin
115
115
  provision_enabled = JetstreamBridge.config.auto_provision
116
- jts = JetstreamBridge.connect_and_ensure_stream!
116
+ jts = JetstreamBridge.connect_and_provision!
117
117
 
118
118
  if provision_enabled
119
119
  puts '✓ Successfully connected to NATS'
120
120
  puts '✓ JetStream is available'
121
- puts '✓ Stream topology ensured'
121
+ puts '✓ Stream topology provisioned'
122
122
 
123
123
  # Check if we can get account info
124
124
  info = jts.account_info
@@ -174,7 +174,12 @@ module JetstreamBridge
174
174
 
175
175
  # Only include mutable fields on update (subjects, storage). Never retention.
176
176
  def apply_update(jts, name, subjects, storage: nil)
177
- params = { name: name, subjects: subjects }
177
+ # Fetch existing stream config to preserve retention
178
+ info = jts.stream_info(name)
179
+ config_data = info.config
180
+ existing_retention = config_data.respond_to?(:retention) ? config_data.retention : config_data[:retention]
181
+
182
+ params = { name: name, subjects: subjects, retention: existing_retention }
178
183
  params[:storage] = storage if storage
179
184
  jts.update_stream(**params)
180
185
  end
@@ -6,7 +6,7 @@ require_relative 'stream'
6
6
 
7
7
  module JetstreamBridge
8
8
  class Topology
9
- def self.ensure!(jts)
9
+ def self.provision!(jts)
10
10
  cfg = JetstreamBridge.config
11
11
  subjects = [cfg.source_subject, cfg.destination_subject]
12
12
  subjects << cfg.dlq_subject if cfg.use_dlq
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '5.0.2'
7
+ VERSION = '7.0.0'
8
8
  end