jetstream_bridge 5.1.0 → 7.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 +37 -0
  3. data/README.md +6 -3
  4. data/docs/API.md +395 -0
  5. data/docs/ARCHITECTURE.md +123 -171
  6. data/docs/GETTING_STARTED.md +72 -1
  7. data/docs/PRODUCTION.md +10 -3
  8. data/docs/RESTRICTED_PERMISSIONS.md +7 -14
  9. data/docs/TESTING.md +3 -3
  10. data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +29 -13
  11. data/lib/jetstream_bridge/config_helpers/lifecycle.rb +34 -0
  12. data/lib/jetstream_bridge/config_helpers.rb +118 -0
  13. data/lib/jetstream_bridge/consumer/consumer.rb +131 -41
  14. data/lib/jetstream_bridge/consumer/consumer_state.rb +58 -0
  15. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +12 -2
  16. data/lib/jetstream_bridge/consumer/pull_subscription_builder.rb +6 -6
  17. data/lib/jetstream_bridge/consumer/subscription_manager.rb +72 -110
  18. data/lib/jetstream_bridge/core/config.rb +31 -0
  19. data/lib/jetstream_bridge/core/connection.rb +97 -31
  20. data/lib/jetstream_bridge/core/consumer_mode_resolver.rb +64 -0
  21. data/lib/jetstream_bridge/core/duration.rb +30 -0
  22. data/lib/jetstream_bridge/models/inbox_event.rb +1 -1
  23. data/lib/jetstream_bridge/models/outbox_event.rb +1 -1
  24. data/lib/jetstream_bridge/provisioner.rb +108 -13
  25. data/lib/jetstream_bridge/publisher/outbox_repository.rb +35 -20
  26. data/lib/jetstream_bridge/publisher/publisher.rb +4 -4
  27. data/lib/jetstream_bridge/tasks/install.rake +2 -2
  28. data/lib/jetstream_bridge/topology/stream.rb +6 -1
  29. data/lib/jetstream_bridge/topology/topology.rb +1 -1
  30. data/lib/jetstream_bridge/version.rb +1 -1
  31. data/lib/jetstream_bridge.rb +8 -12
  32. metadata +7 -2
@@ -41,60 +41,68 @@ module JetstreamBridge
41
41
  # Bind a subscriber to the existing durable consumer.
42
42
  def subscribe!
43
43
  if @cfg.push_consumer?
44
- subscribe_push!
44
+ subscribe_push_with_fallback
45
45
  else
46
- # Always bypass consumer_info to avoid requiring JetStream API permissions at runtime.
47
- subscribe_without_verification!
46
+ subscribe_pull_with_fallback
48
47
  end
49
48
  end
50
49
 
51
50
  def subscribe_without_verification!
52
51
  # Manually create a pull subscription without calling consumer_info
53
52
  # 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'
53
+ create_subscription_with_fallback(
54
+ description: "pull subscription for consumer #{@durable} (stream=#{stream_name})",
55
+ primary_check: ->(nc) { nc.respond_to?(:new_inbox) && nc.respond_to?(:subscribe) },
56
+ primary_action: ->(nc) { build_pull_subscription(nc) },
57
+ fallback_name: :pull_subscribe,
58
+ fallback_available: -> { @jts.respond_to?(:pull_subscribe) },
59
+ fallback_action: -> { @jts.pull_subscribe(filter_subject, @durable, stream: stream_name) }
60
+ )
69
61
  end
70
62
 
71
63
  def subscribe_push!
72
64
  # Push consumers deliver messages directly to a subscription subject
73
65
  # No JetStream API calls needed - just subscribe to the delivery subject
74
- nc = resolve_nc
75
66
  delivery_subject = @cfg.push_delivery_subject
67
+ queue_group = @cfg.push_consumer_group_name
68
+
69
+ create_subscription_with_fallback(
70
+ description: "push subscription for consumer #{@durable} " \
71
+ "(stream=#{stream_name}, delivery=#{delivery_subject}, queue=#{queue_group})",
72
+ primary_check: ->(nc) { nc.respond_to?(:subscribe) },
73
+ primary_action: lambda do |nc|
74
+ sub = nc.subscribe(delivery_subject, queue: queue_group)
75
+ Logging.info(
76
+ "Created push subscription for consumer #{@durable} " \
77
+ "(stream=#{stream_name}, delivery=#{delivery_subject}, queue=#{queue_group})",
78
+ tag: 'JetstreamBridge::Consumer'
79
+ )
80
+ sub
81
+ end,
82
+ fallback_name: :subscribe,
83
+ fallback_available: -> { @jts.respond_to?(:subscribe) },
84
+ fallback_action: -> { @jts.subscribe(delivery_subject, queue: queue_group) }
85
+ )
86
+ end
76
87
 
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
88
+ def subscribe_push_with_fallback
89
+ subscribe_push!
90
+ rescue JetstreamBridge::ConnectionError, StandardError => e
91
+ Logging.warn(
92
+ "Push subscription failed (#{e.class}: #{e.message}); falling back to pull subscription for #{@durable}",
93
+ tag: 'JetstreamBridge::Consumer'
94
+ )
95
+ subscribe_without_verification!
96
+ end
95
97
 
96
- raise JetstreamBridge::ConnectionError,
97
- 'Unable to create push subscription: NATS client not available'
98
+ def subscribe_pull_with_fallback
99
+ subscribe_without_verification!
100
+ rescue JetstreamBridge::ConnectionError, StandardError => e
101
+ Logging.warn(
102
+ "Pull subscription failed (#{e.class}: #{e.message}); falling back to push subscription for #{@durable}",
103
+ tag: 'JetstreamBridge::Consumer'
104
+ )
105
+ subscribe_push!
98
106
  end
99
107
 
100
108
  private
@@ -107,12 +115,15 @@ module JetstreamBridge
107
115
  deliver_policy: 'all',
108
116
  max_deliver: JetstreamBridge.config.max_deliver,
109
117
  # 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) }
118
+ ack_wait: Duration.to_seconds(JetstreamBridge.config.ack_wait),
119
+ backoff: Duration.normalize_list_to_seconds(JetstreamBridge.config.backoff)
112
120
  }
113
121
 
114
- # Add deliver_subject for push consumers
115
- config[:deliver_subject] = @cfg.push_delivery_subject if @cfg.push_consumer?
122
+ # Add deliver_subject and deliver_group for push consumers
123
+ if @cfg.push_consumer?
124
+ config[:deliver_subject] = @cfg.push_delivery_subject
125
+ config[:deliver_group] = @cfg.push_consumer_group_name
126
+ end
116
127
 
117
128
  config
118
129
  end
@@ -125,73 +136,6 @@ module JetstreamBridge
125
136
  )
126
137
  end
127
138
 
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
139
  def log_runtime_skip
196
140
  Logging.info(
197
141
  "Skipping consumer provisioning/verification for #{@durable} at runtime to avoid JetStream API usage. " \
@@ -213,5 +157,23 @@ module JetstreamBridge
213
157
  builder = PullSubscriptionBuilder.new(@jts, @durable, stream_name, filter_subject)
214
158
  builder.build(nats_client)
215
159
  end
160
+
161
+ def create_subscription_with_fallback(description:, primary_check:, primary_action:, fallback_name:,
162
+ fallback_available:, fallback_action:)
163
+ nc = resolve_nc
164
+
165
+ return primary_action.call(nc) if nc && primary_check.call(nc)
166
+
167
+ if fallback_available.call
168
+ Logging.info(
169
+ "Using #{fallback_name} fallback for #{description}",
170
+ tag: 'JetstreamBridge::Consumer'
171
+ )
172
+ return fallback_action.call
173
+ end
174
+
175
+ raise JetstreamBridge::ConnectionError,
176
+ "Unable to create #{description}: NATS client not available"
177
+ end
216
178
  end
217
179
  end
@@ -114,6 +114,10 @@ module JetstreamBridge
114
114
  # Only used when consumer_mode is :push
115
115
  # @return [String, nil]
116
116
  attr_accessor :delivery_subject
117
+ # Queue group / deliver_group for push consumers (optional, defaults to durable_name or app_name)
118
+ # Only used when consumer_mode is :push. Determines how push consumers load-balance.
119
+ # @return [String, nil]
120
+ attr_accessor :push_consumer_group
117
121
 
118
122
  def initialize
119
123
  @nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
@@ -142,6 +146,7 @@ module JetstreamBridge
142
146
  # Consumer mode
143
147
  @consumer_mode = :pull
144
148
  @delivery_subject = nil
149
+ @push_consumer_group = nil
145
150
  end
146
151
 
147
152
  # Apply a configuration preset
@@ -223,11 +228,26 @@ module JetstreamBridge
223
228
  "#{destination_subject}.worker"
224
229
  end
225
230
 
231
+ # Queue group for push consumers. Controls deliver_group and queue subscription.
232
+ #
233
+ # @return [String] Queue group name
234
+ # @raise [InvalidSubjectError, MissingConfigurationError] If derived components invalid
235
+ def push_consumer_group_name
236
+ group = push_consumer_group
237
+ group = durable_name if group.to_s.strip.empty?
238
+ group = app_name if group.to_s.strip.empty?
239
+
240
+ validate_subject_component!(group, 'push_consumer_group')
241
+ group
242
+ end
243
+
226
244
  # Check if using pull consumer mode.
227
245
  #
228
246
  # @return [Boolean]
229
247
  def pull_consumer?
230
248
  consumer_mode.to_sym == :pull
249
+ rescue NoMethodError
250
+ false
231
251
  end
232
252
 
233
253
  # Check if using push consumer mode.
@@ -235,6 +255,8 @@ module JetstreamBridge
235
255
  # @return [Boolean]
236
256
  def push_consumer?
237
257
  consumer_mode.to_sym == :push
258
+ rescue NoMethodError
259
+ false
238
260
  end
239
261
 
240
262
  # Validate all configuration settings.
@@ -252,6 +274,7 @@ module JetstreamBridge
252
274
  validate_numeric_constraints!(errors)
253
275
  validate_backoff!(errors)
254
276
  validate_consumer_mode!(errors)
277
+ validate_push_consumer!(errors)
255
278
 
256
279
  raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
257
280
 
@@ -298,5 +321,13 @@ module JetstreamBridge
298
321
 
299
322
  errors << 'consumer_mode must be :pull or :push' unless [:pull, :push].include?(consumer_mode.to_sym)
300
323
  end
324
+
325
+ def validate_push_consumer!(errors)
326
+ return unless push_consumer?
327
+
328
+ push_consumer_group_name
329
+ rescue ConfigurationError => e
330
+ errors << e.message
331
+ end
301
332
  end
302
333
  end
@@ -44,6 +44,9 @@ module JetstreamBridge
44
44
  }.freeze
45
45
 
46
46
  VALID_NATS_SCHEMES = %w[nats nats+tls].freeze
47
+ REFRESH_RETRY_BASE_DELAY = 0.01
48
+ REFRESH_RETRY_MAX_DELAY = 30.0
49
+ REFRESH_RETRY_MAX_ATTEMPTS = 30
47
50
 
48
51
  # Class-level mutex for thread-safe connection initialization
49
52
  # Using class variable to avoid race condition in mutex creation
@@ -238,36 +241,10 @@ module JetstreamBridge
238
241
  tag: 'JetstreamBridge::Connection'
239
242
  )
240
243
 
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
244
+ success = refresh_jetstream_with_retry?
269
245
 
270
246
  @reconnecting = false
247
+ start_refresh_retry_loop unless success
271
248
  end
272
249
 
273
250
  @nc.on_disconnect do |reason|
@@ -304,7 +281,7 @@ module JetstreamBridge
304
281
  if verify_js
305
282
  verify_jetstream!
306
283
  if config_auto_provision
307
- Topology.ensure!(@jts)
284
+ Topology.provision!(@jts)
308
285
  Logging.info(
309
286
  'Topology ensured after connection (auto_provision=true).',
310
287
  tag: 'JetstreamBridge::Connection'
@@ -461,7 +438,7 @@ module JetstreamBridge
461
438
 
462
439
  # Re-ensure topology after reconnect when allowed
463
440
  if config_auto_provision
464
- Topology.ensure!(@jts)
441
+ Topology.provision!(@jts)
465
442
  else
466
443
  Logging.info(
467
444
  'Skipping topology provisioning after reconnect (auto_provision=false).',
@@ -504,11 +481,95 @@ module JetstreamBridge
504
481
  raise
505
482
  end
506
483
 
484
+ def refresh_jetstream_with_retry?(max_attempts: 3, base_delay: 0.5)
485
+ attempts = 0
486
+ while attempts < max_attempts
487
+ attempts += 1
488
+ begin
489
+ refresh_jetstream_context
490
+ return true
491
+ rescue StandardError => e
492
+ if attempts < max_attempts
493
+ delay = refresh_retry_sleep_duration(attempts, base: base_delay)
494
+ Logging.warn(
495
+ "JetStream context refresh attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
496
+ "Retrying in #{delay}s...",
497
+ tag: 'JetstreamBridge::Connection'
498
+ )
499
+ sleep(delay)
500
+ else
501
+ Logging.error(
502
+ "Failed to refresh JetStream context after #{attempts} attempts. " \
503
+ 'Starting background retry loop.',
504
+ tag: 'JetstreamBridge::Connection'
505
+ )
506
+ end
507
+ end
508
+ end
509
+
510
+ false
511
+ end
512
+
513
+ def start_refresh_retry_loop(initial_delay: REFRESH_RETRY_BASE_DELAY, max_attempts: REFRESH_RETRY_MAX_ATTEMPTS)
514
+ return if @refresh_retry_thread&.alive?
515
+ return unless @nc
516
+
517
+ @refresh_retry_thread = Thread.new do
518
+ Thread.current.report_on_exception = false
519
+ attempts = 0
520
+ delay = initial_delay
521
+
522
+ while @nc&.connected?
523
+ sleep(delay)
524
+ attempts += 1
525
+ break if max_attempts && attempts > max_attempts
526
+
527
+ begin
528
+ refresh_jetstream_context
529
+ Logging.info(
530
+ "JetStream context refreshed via background retry after #{attempts} attempt#{'s' if attempts != 1}",
531
+ tag: 'JetstreamBridge::Connection'
532
+ )
533
+ break
534
+ rescue StandardError => e
535
+ if defined?(RSpec::Mocks::ExpiredTestDoubleError) &&
536
+ e.is_a?(RSpec::Mocks::ExpiredTestDoubleError)
537
+ Logging.debug(
538
+ 'Stopping background JetStream refresh due to expired test double',
539
+ tag: 'JetstreamBridge::Connection'
540
+ )
541
+ break
542
+ end
543
+ delay = refresh_retry_sleep_duration(attempts + 1)
544
+ Logging.warn(
545
+ "Background JetStream refresh attempt #{attempts} failed: #{e.message}. " \
546
+ "Retrying in #{delay}s...",
547
+ tag: 'JetstreamBridge::Connection'
548
+ )
549
+ end
550
+ end
551
+ ensure
552
+ @refresh_retry_thread = nil
553
+ end
554
+ rescue StandardError => e
555
+ Logging.debug(
556
+ "Could not start refresh retry loop: #{e.class} #{e.message}",
557
+ tag: 'JetstreamBridge::Connection'
558
+ )
559
+ end
560
+
561
+ def refresh_retry_sleep_duration(attempt, base: REFRESH_RETRY_BASE_DELAY)
562
+ [base * (2**(attempt - 1)), REFRESH_RETRY_MAX_DELAY].min
563
+ end
564
+
507
565
  # Expose for class-level helpers (not part of public API)
508
566
  attr_reader :nc
509
567
 
510
568
  def jetstream
511
- @jts
569
+ return @jts if @jts
570
+
571
+ raise ConnectionNotEstablishedError,
572
+ 'JetStream context unavailable (refresh pending or failed)'
512
573
  end
513
574
 
514
575
  # Mask credentials in NATS URLs:
@@ -539,6 +600,11 @@ module JetstreamBridge
539
600
  @jts = nil
540
601
  end
541
602
 
603
+ if @refresh_retry_thread&.alive?
604
+ @refresh_retry_thread.kill
605
+ @refresh_retry_thread = nil
606
+ end
607
+
542
608
  # Always invalidate health check cache
543
609
  @cached_health_status = nil
544
610
  @last_health_check = nil
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ module ConsumerModeResolver
5
+ VALID_MODES = [:pull, :push].freeze
6
+
7
+ module_function
8
+
9
+ # Resolve consumer mode for a given app.
10
+ #
11
+ # Priority:
12
+ # 1) explicit override (symbol/string)
13
+ # 2) env map CONSUMER_MODES="app:mode,..."
14
+ # 3) per-app env CONSUMER_MODE_<APP_NAME>
15
+ # 4) shared env CONSUMER_MODE
16
+ # 5) fallback (default :pull)
17
+ def resolve(app_name:, override: nil, fallback: :pull)
18
+ return normalize(override) if override
19
+
20
+ from_env = env_for(app_name)
21
+ return from_env if from_env
22
+
23
+ normalize(fallback)
24
+ end
25
+
26
+ def env_for(app_name)
27
+ from_map(app_name) || app_specific(app_name) || shared
28
+ end
29
+
30
+ def from_map(app_name)
31
+ env_map = ENV.fetch('CONSUMER_MODES', nil)
32
+ return nil if env_map.to_s.strip.empty?
33
+
34
+ pairs = env_map.split(',').each_with_object({}) do |pair, memo|
35
+ key, mode = pair.split(':', 2).map { |p| p&.strip }
36
+ memo[key] = mode if key && mode
37
+ end
38
+
39
+ normalize(pairs[app_name.to_s]) if pairs.key?(app_name.to_s)
40
+ end
41
+
42
+ def app_specific(app_name)
43
+ key = "CONSUMER_MODE_#{app_name.to_s.upcase}"
44
+ return nil unless ENV.key?(key)
45
+
46
+ normalize(ENV.fetch(key, nil))
47
+ end
48
+
49
+ def shared
50
+ return nil unless ENV['CONSUMER_MODE']
51
+
52
+ normalize(ENV.fetch('CONSUMER_MODE', nil))
53
+ end
54
+
55
+ def normalize(mode)
56
+ return nil if mode.nil? || mode.to_s.strip.empty?
57
+
58
+ m = mode.to_s.downcase.to_sym
59
+ return m if VALID_MODES.include?(m)
60
+
61
+ raise ArgumentError, "Invalid consumer mode #{mode.inspect}. Use :pull or :push."
62
+ end
63
+ end
64
+ end
@@ -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.