jetstream_bridge 4.4.0 → 4.5.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -337
  3. data/README.md +1 -5
  4. data/docs/GETTING_STARTED.md +11 -7
  5. data/docs/PRODUCTION.md +51 -11
  6. data/docs/TESTING.md +24 -35
  7. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +1 -1
  8. data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +1 -1
  9. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +0 -4
  10. data/lib/generators/jetstream_bridge/install/install_generator.rb +5 -5
  11. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +2 -2
  12. data/lib/jetstream_bridge/consumer/consumer.rb +34 -96
  13. data/lib/jetstream_bridge/consumer/health_monitor.rb +107 -0
  14. data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
  15. data/lib/jetstream_bridge/consumer/subscription_manager.rb +51 -34
  16. data/lib/jetstream_bridge/core/config.rb +153 -46
  17. data/lib/jetstream_bridge/core/connection_manager.rb +513 -0
  18. data/lib/jetstream_bridge/core/debug_helper.rb +9 -3
  19. data/lib/jetstream_bridge/core/health_checker.rb +184 -0
  20. data/lib/jetstream_bridge/core.rb +0 -2
  21. data/lib/jetstream_bridge/facade.rb +212 -0
  22. data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +110 -0
  23. data/lib/jetstream_bridge/publisher/publisher.rb +87 -117
  24. data/lib/jetstream_bridge/rails/integration.rb +8 -5
  25. data/lib/jetstream_bridge/rails/railtie.rb +4 -3
  26. data/lib/jetstream_bridge/tasks/install.rake +0 -1
  27. data/lib/jetstream_bridge/topology/topology.rb +6 -1
  28. data/lib/jetstream_bridge/version.rb +1 -1
  29. data/lib/jetstream_bridge.rb +206 -297
  30. metadata +7 -5
  31. data/lib/jetstream_bridge/core/bridge_helpers.rb +0 -109
  32. data/lib/jetstream_bridge/core/connection.rb +0 -464
  33. data/lib/jetstream_bridge/core/connection_factory.rb +0 -100
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/logging'
4
+
5
+ module JetstreamBridge
6
+ # Health monitoring for Consumer instances
7
+ #
8
+ # Responsible for:
9
+ # - Periodic health checks
10
+ # - Memory usage monitoring
11
+ # - Heap object tracking
12
+ # - GC recommendations
13
+ class ConsumerHealthMonitor
14
+ # Health check interval in seconds (10 minutes)
15
+ HEALTH_CHECK_INTERVAL = 600
16
+ # Memory warning threshold in MB
17
+ MEMORY_WARNING_THRESHOLD_MB = 1000
18
+ # Heap object count warning threshold
19
+ HEAP_OBJECT_WARNING_THRESHOLD = 200_000
20
+
21
+ def initialize(consumer_name)
22
+ @consumer_name = consumer_name
23
+ @start_time = Time.now
24
+ @last_check = Time.now
25
+ @iterations = 0
26
+ @gc_warning_logged = false
27
+ end
28
+
29
+ # Increment iteration counter
30
+ def increment_iterations
31
+ @iterations += 1
32
+ end
33
+
34
+ # Check if health check is due and perform if needed
35
+ #
36
+ # @return [Boolean] true if check was performed
37
+ def check_health_if_due
38
+ now = Time.now
39
+ time_since_check = now - @last_check
40
+
41
+ return false unless time_since_check >= HEALTH_CHECK_INTERVAL
42
+
43
+ perform_check(now)
44
+ true
45
+ end
46
+
47
+ private
48
+
49
+ def perform_check(now)
50
+ @last_check = now
51
+ uptime = now - @start_time
52
+ memory_mb = memory_usage_mb
53
+
54
+ Logging.info(
55
+ "Consumer health: iterations=#{@iterations}, " \
56
+ "memory=#{memory_mb}MB, uptime=#{uptime.round}s",
57
+ tag: "JetstreamBridge::Consumer(#{@consumer_name})"
58
+ )
59
+
60
+ warn_if_high_memory(memory_mb)
61
+ suggest_gc_if_needed
62
+ rescue StandardError => e
63
+ Logging.debug(
64
+ "Health check failed: #{e.class} #{e.message}",
65
+ tag: "JetstreamBridge::Consumer(#{@consumer_name})"
66
+ )
67
+ end
68
+
69
+ def memory_usage_mb
70
+ # Get memory usage from OS (works on Linux/macOS)
71
+ rss_kb = `ps -o rss= -p #{Process.pid}`.to_i
72
+ rss_kb / 1024.0
73
+ rescue StandardError
74
+ 0.0
75
+ end
76
+
77
+ def warn_if_high_memory(memory_mb)
78
+ return unless memory_mb > MEMORY_WARNING_THRESHOLD_MB
79
+
80
+ Logging.warn(
81
+ "High memory usage detected: #{memory_mb}MB",
82
+ tag: "JetstreamBridge::Consumer(#{@consumer_name})"
83
+ )
84
+ end
85
+
86
+ def suggest_gc_if_needed
87
+ return unless defined?(GC) && GC.respond_to?(:stat)
88
+
89
+ stats = GC.stat
90
+ heap_live_slots = stats[:heap_live_slots] || stats['heap_live_slots'] || 0
91
+
92
+ return if heap_live_slots < HEAP_OBJECT_WARNING_THRESHOLD || @gc_warning_logged
93
+
94
+ @gc_warning_logged = true
95
+ Logging.warn(
96
+ "High heap object count detected (#{heap_live_slots}); " \
97
+ 'consider profiling or manual GC in the host app',
98
+ tag: "JetstreamBridge::Consumer(#{@consumer_name})"
99
+ )
100
+ rescue StandardError => e
101
+ Logging.debug(
102
+ "GC check failed: #{e.class} #{e.message}",
103
+ tag: "JetstreamBridge::Consumer(#{@consumer_name})"
104
+ )
105
+ end
106
+ end
107
+ end
@@ -198,7 +198,7 @@ module JetstreamBridge
198
198
 
199
199
  def log_ack(result)
200
200
  ctx = result.ctx
201
- Logging.info(
201
+ Logging.debug(
202
202
  "ACK event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} deliveries=#{ctx&.deliveries}",
203
203
  tag: 'JetstreamBridge::Consumer'
204
204
  )
@@ -10,6 +10,8 @@ module JetstreamBridge
10
10
  @jts = jts
11
11
  @durable = durable
12
12
  @cfg = cfg
13
+ return if @cfg.disable_js_api
14
+
13
15
  @desired_cfg = build_consumer_config(@durable, filter_subject)
14
16
  @desired_cfg_norm = normalize_consumer_config(@desired_cfg)
15
17
  end
@@ -27,6 +29,11 @@ module JetstreamBridge
27
29
  end
28
30
 
29
31
  def ensure_consumer!
32
+ if @cfg.disable_js_api
33
+ Logging.info("JS API disabled; assuming consumer #{@durable} exists", tag: 'JetstreamBridge::Consumer')
34
+ return
35
+ end
36
+
30
37
  info = consumer_info_or_nil
31
38
  return create_consumer! unless info
32
39
 
@@ -41,11 +48,17 @@ module JetstreamBridge
41
48
 
42
49
  # Bind a pull subscriber to the existing durable.
43
50
  def subscribe!
51
+ opts = { stream: stream_name }
52
+ if @cfg.disable_js_api
53
+ opts[:bind] = true
54
+ else
55
+ opts[:config] = desired_consumer_cfg
56
+ end
57
+
44
58
  @jts.pull_subscribe(
45
59
  filter_subject,
46
60
  @durable,
47
- stream: stream_name,
48
- config: desired_consumer_cfg
61
+ **opts
49
62
  )
50
63
  end
51
64
 
@@ -80,9 +93,9 @@ module JetstreamBridge
80
93
  ack_policy: 'explicit',
81
94
  deliver_policy: 'all',
82
95
  max_deliver: JetstreamBridge.config.max_deliver,
83
- # JetStream expects seconds (the client multiplies by nanoseconds).
84
- ack_wait: duration_to_seconds(JetstreamBridge.config.ack_wait),
85
- backoff: Array(JetstreamBridge.config.backoff).map { |d| duration_to_seconds(d) }
96
+ # JetStream expects nanoseconds for ack_wait/backoff.
97
+ ack_wait: duration_to_nanos(JetstreamBridge.config.ack_wait),
98
+ backoff: Array(JetstreamBridge.config.backoff).map { |d| duration_to_nanos(d) }
86
99
  }
87
100
  end
88
101
 
@@ -94,8 +107,8 @@ module JetstreamBridge
94
107
  ack_policy: sval(cfg, :ack_policy), # string
95
108
  deliver_policy: sval(cfg, :deliver_policy), # string
96
109
  max_deliver: ival(cfg, :max_deliver), # integer
97
- ack_wait_secs: d_secs(cfg, :ack_wait), # integer seconds
98
- backoff_secs: darr_secs(cfg, :backoff) # array of integer seconds
110
+ ack_wait_nanos: nanos(cfg, :ack_wait),
111
+ backoff_nanos: nanos_arr(cfg, :backoff)
99
112
  }
100
113
  end
101
114
 
@@ -151,54 +164,58 @@ module JetstreamBridge
151
164
  v.to_i
152
165
  end
153
166
 
154
- # Normalize duration-like field to **milliseconds** (Integer).
155
- # Accepts:
156
- # - Strings:"500ms""30s" "2m", "1h", "250us", "100ns"
157
- # - Integers/Floats:
158
- # * Server may return large integers in **nanoseconds** → detect and convert.
159
- # * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
160
- def d_secs(cfg, key)
167
+ # ---- duration coercion ----
168
+
169
+ def nanos(cfg, key)
161
170
  raw = get(cfg, key)
162
- duration_to_seconds(raw)
171
+ duration_to_nanos(raw)
163
172
  end
164
173
 
165
- # Normalize array of durations to integer milliseconds.
166
- def darr_secs(cfg, key)
174
+ def nanos_arr(cfg, key)
167
175
  raw = get(cfg, key)
168
- Array(raw).map { |d| duration_to_seconds(d) }
176
+ Array(raw).map { |d| duration_to_nanos(d) }
169
177
  end
170
178
 
171
- # ---- duration coercion ----
172
-
173
- def duration_to_seconds(val)
179
+ def duration_to_nanos(val)
174
180
  return nil if val.nil?
175
181
 
176
182
  case val
177
183
  when Integer
178
- # Heuristic: extremely large integers are likely **nanoseconds** from server
179
- # (e.g., 30s => 30_000_000_000 ns). Convert ns seconds.
180
- return (val / 1_000_000_000.0).round if val >= 1_000_000_000
184
+ # Heuristic: extremely large integers are likely already nanoseconds
185
+ return val if val >= 1_000_000_000 # >= 1s in nanos
181
186
 
182
- # otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
183
187
  millis = Duration.to_millis(val, default_unit: :auto)
184
- seconds_from_millis(millis)
188
+ (millis * 1_000_000).to_i
185
189
  when Float
186
190
  millis = Duration.to_millis(val, default_unit: :auto)
187
- seconds_from_millis(millis)
191
+ (millis * 1_000_000).to_i
188
192
  when String
189
- # Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
190
- millis = Duration.to_millis(val) # default_unit ignored when unit given
191
- seconds_from_millis(millis)
193
+ millis = Duration.to_millis(val) # unit-aware
194
+ (millis * 1_000_000).to_i
192
195
  else
193
- return duration_to_seconds(val.to_f) if val.respond_to?(:to_f)
196
+ return duration_to_nanos(val.to_f) if val.respond_to?(:to_f)
194
197
 
195
198
  raise ArgumentError, "invalid duration: #{val.inspect}"
196
199
  end
197
200
  end
198
201
 
199
- def seconds_from_millis(millis)
200
- # Always round up to avoid zero-second waits when sub-second durations are provided.
201
- [(millis / 1000.0).ceil, 1].max
202
+ # Legacy helper used in specs; kept for backward compatibility.
203
+ def duration_to_seconds(val)
204
+ return nil if val.nil?
205
+
206
+ case val
207
+ when Integer
208
+ return (val / 1_000_000_000.0).round if val >= 1_000_000_000 # nanoseconds
209
+ return val if val < 1000 # seconds
210
+
211
+ (val / 1000.0).round # milliseconds as integer
212
+ when Float
213
+ val
214
+ else
215
+ millis = Duration.to_millis(val, default_unit: :auto)
216
+ seconds = millis / 1000.0
217
+ seconds < 1 ? 1 : seconds.round(3)
218
+ end
202
219
  end
203
220
  end
204
221
  end
@@ -11,7 +11,6 @@ module JetstreamBridge
11
11
  # @example Basic configuration
12
12
  # JetstreamBridge.configure do |config|
13
13
  # config.nats_urls = "nats://localhost:4222"
14
- # config.env = "production"
15
14
  # config.app_name = "api_service"
16
15
  # config.destination_app = "worker_service"
17
16
  # config.use_outbox = true
@@ -50,11 +49,7 @@ module JetstreamBridge
50
49
  # NATS server URL(s)
51
50
  # @return [String]
52
51
  attr_accessor :nats_urls
53
- # Environment namespace (development, staging, production)
54
- # @return [String]
55
- attr_accessor :env
56
52
  # Application name for subject routing
57
- # @return [String]
58
53
  attr_accessor :app_name
59
54
  # Maximum delivery attempts before moving to DLQ
60
55
  # @return [Integer]
@@ -95,12 +90,23 @@ module JetstreamBridge
95
90
  # Enable lazy connection (connect on first use instead of during configure)
96
91
  # @return [Boolean]
97
92
  attr_accessor :lazy_connect
93
+ # Inbox prefix for request/reply (useful when NATS permissions restrict _INBOX.>)
94
+ # @return [String, nil]
95
+ attr_accessor :inbox_prefix
96
+ # Skip JetStream management API calls (account_info, stream ensure, etc.)
97
+ # Requires streams/consumers to be pre-provisioned and permissions handled externally.
98
+ # @return [Boolean]
99
+ attr_accessor :disable_js_api
100
+ # Optional durable consumer name
101
+ attr_writer :durable_name
98
102
 
99
103
  def initialize
100
104
  @nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
101
- @env = ENV['NATS_ENV'] || 'development'
102
105
  @app_name = ENV['APP_NAME'] || 'app'
103
106
  @destination_app = ENV.fetch('DESTINATION_APP', nil)
107
+ @stream_name = ENV.fetch('STREAM_NAME', nil)
108
+ @stream_name_set = !@stream_name.nil?
109
+ @durable_name = ENV.fetch('DURABLE_NAME', nil)
104
110
 
105
111
  @max_deliver = 5
106
112
  @ack_wait = '30s'
@@ -118,6 +124,8 @@ module JetstreamBridge
118
124
  @connect_retry_attempts = 3
119
125
  @connect_retry_delay = 2
120
126
  @lazy_connect = false
127
+ @inbox_prefix = ENV['NATS_INBOX_PREFIX'] || '_INBOX'
128
+ @disable_js_api = (ENV['JETSTREAM_DISABLE_JS_API'] || 'true') == 'true'
121
129
  end
122
130
 
123
131
  # Apply a configuration preset
@@ -131,85 +139,83 @@ module JetstreamBridge
131
139
  self
132
140
  end
133
141
 
134
- # Get the JetStream stream name for this environment.
142
+ # Get the JetStream stream name (required).
135
143
  #
136
- # @return [String] Stream name in format "{env}-jetstream-bridge-stream"
137
- # @example
138
- # config.env = "production"
139
- # config.stream_name # => "production-jetstream-bridge-stream"
144
+ # Side-effect free query. Use validate! to ensure validity.
145
+ #
146
+ # @return [String, nil] Stream name
140
147
  def stream_name
141
- "#{env}-jetstream-bridge-stream"
148
+ @cached_stream_name || (@stream_name_set ? @stream_name : default_stream_name)
142
149
  end
143
150
 
144
151
  # Get the NATS subject this application publishes to.
145
152
  #
146
- # Producer publishes to: {env}.{app}.sync.{dest}
147
- # Consumer subscribes to: {env}.{dest}.sync.{app}
153
+ # Producer publishes to: {app}.sync.{dest}
154
+ # Consumer subscribes to: {dest}.sync.{app}
155
+ #
156
+ # Side-effect free query. Use validate! to ensure validity.
148
157
  #
149
- # @return [String] Source subject for publishing
150
- # @raise [InvalidSubjectError] If components contain NATS wildcards
151
- # @raise [MissingConfigurationError] If required components empty
158
+ # @return [String, nil] Source subject for publishing
152
159
  # @example
153
- # config.env = "production"
154
160
  # config.app_name = "api"
155
161
  # config.destination_app = "worker"
156
- # config.source_subject # => "production.api.sync.worker"
162
+ # config.source_subject # => "api.sync.worker"
157
163
  def source_subject
158
- validate_subject_component!(env, 'env')
159
- validate_subject_component!(app_name, 'app_name')
160
- validate_subject_component!(destination_app, 'destination_app')
161
- "#{env}.#{app_name}.sync.#{destination_app}"
164
+ @cached_source_subject || build_source_subject
162
165
  end
163
166
 
164
167
  # Get the NATS subject this application subscribes to.
165
168
  #
166
- # @return [String] Destination subject for consuming
167
- # @raise [InvalidSubjectError] If components contain NATS wildcards
168
- # @raise [MissingConfigurationError] If required components empty
169
+ # Side-effect free query. Use validate! to ensure validity.
170
+ #
171
+ # @return [String, nil] Destination subject for consuming
169
172
  # @example
170
- # config.env = "production"
171
173
  # config.app_name = "api"
172
174
  # config.destination_app = "worker"
173
- # config.destination_subject # => "production.worker.sync.api"
175
+ # config.destination_subject # => "worker.sync.api"
174
176
  def destination_subject
175
- validate_subject_component!(env, 'env')
176
- validate_subject_component!(app_name, 'app_name')
177
- validate_subject_component!(destination_app, 'destination_app')
178
- "#{env}.#{destination_app}.sync.#{app_name}"
177
+ @cached_destination_subject || build_destination_subject
179
178
  end
180
179
 
181
180
  # Get the dead letter queue subject for this application.
182
181
  #
183
182
  # Each app has its own DLQ for better isolation and monitoring.
184
183
  #
185
- # @return [String] DLQ subject in format "{env}.{app_name}.sync.dlq"
186
- # @raise [InvalidSubjectError] If components contain NATS wildcards
187
- # @raise [MissingConfigurationError] If required components are empty
184
+ # Side-effect free query. Use validate! to ensure validity.
185
+ #
186
+ # @return [String, nil] DLQ subject in format "{app_name}.sync.dlq"
188
187
  # @example
189
- # config.env = "production"
190
188
  # config.app_name = "api"
191
- # config.dlq_subject # => "production.api.sync.dlq"
189
+ # config.dlq_subject # => "api.sync.dlq"
192
190
  def dlq_subject
193
- validate_subject_component!(env, 'env')
194
- validate_subject_component!(app_name, 'app_name')
195
- "#{env}.#{app_name}.sync.dlq"
191
+ @cached_dlq_subject || build_dlq_subject
196
192
  end
197
193
 
198
194
  # Get the durable consumer name for this application.
199
195
  #
200
- # @return [String] Durable name in format "{env}-{app_name}-workers"
196
+ # @return [String] Durable name in format "{app_name}-workers"
201
197
  # @example
202
- # config.env = "production"
203
198
  # config.app_name = "api"
204
- # config.durable_name # => "production-api-workers"
199
+ # config.durable_name # => "api-workers"
205
200
  def durable_name
206
- "#{env}-#{app_name}-workers"
201
+ value = @durable_name
202
+ return "#{app_name}-workers" if value.to_s.strip.empty?
203
+
204
+ value
205
+ end
206
+
207
+ def stream_name=(value)
208
+ @stream_name_set = true
209
+ @stream_name = value
207
210
  end
208
211
 
209
212
  # Validate all configuration settings.
210
213
  #
211
214
  # Checks that required settings are present and valid. Raises errors
212
- # for any invalid configuration.
215
+ # for any invalid configuration. Caches computed subjects after validation.
216
+ #
217
+ # This is a command method - performs validation and updates internal state.
218
+ # Call this once after configuration is complete.
213
219
  #
214
220
  # @return [true] If configuration is valid
215
221
  # @raise [ConfigurationError] If any validation fails
@@ -217,21 +223,122 @@ module JetstreamBridge
217
223
  # config.validate! # Raises if destination_app is missing
218
224
  def validate!
219
225
  errors = []
226
+
227
+ # Validate stream name
228
+ stream_val = @stream_name_set ? @stream_name : default_stream_name
229
+ if stream_val.to_s.strip.empty?
230
+ errors << 'stream_name is required'
231
+ else
232
+ begin
233
+ validate_stream_name!(stream_val)
234
+ rescue InvalidSubjectError => e
235
+ errors << e.message
236
+ end
237
+ end
238
+
239
+ # Validate required fields
220
240
  errors << 'destination_app is required' if destination_app.to_s.strip.empty?
221
241
  errors << 'nats_urls is required' if nats_urls.to_s.strip.empty?
222
- errors << 'env is required' if env.to_s.strip.empty?
223
242
  errors << 'app_name is required' if app_name.to_s.strip.empty?
224
243
  errors << 'max_deliver must be >= 1' if max_deliver.to_i < 1
225
244
  errors << 'backoff must be an array' unless backoff.is_a?(Array)
226
245
  errors << 'backoff must not be empty' if backoff.is_a?(Array) && backoff.empty?
246
+ errors << 'disable_js_api must be boolean' unless [true, false].include?(disable_js_api)
247
+
248
+ # Validate inbox prefix
249
+ begin
250
+ validate_inbox_prefix!(inbox_prefix)
251
+ rescue InvalidSubjectError => e
252
+ errors << e.message
253
+ end
254
+
255
+ # Validate subject components
256
+ begin
257
+ validate_subject_component!(app_name, 'app_name') unless app_name.to_s.strip.empty?
258
+ validate_subject_component!(destination_app, 'destination_app') unless destination_app.to_s.strip.empty?
259
+ rescue InvalidSubjectError, MissingConfigurationError => e
260
+ errors << e.message
261
+ end
227
262
 
228
263
  raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
229
264
 
265
+ # Cache computed values after successful validation
266
+ cache_computed_values!
267
+
230
268
  true
231
269
  end
232
270
 
233
271
  private
234
272
 
273
+ # Cache computed subjects after validation
274
+ def cache_computed_values!
275
+ @cached_stream_name = @stream_name_set ? @stream_name : default_stream_name
276
+ @cached_source_subject = build_source_subject
277
+ @cached_destination_subject = build_destination_subject
278
+ @cached_dlq_subject = build_dlq_subject
279
+ end
280
+
281
+ # Build source subject without side effects
282
+ def build_source_subject
283
+ build_subject(app_name, 'sync', destination_app)
284
+ end
285
+
286
+ # Build destination subject without side effects
287
+ def build_destination_subject
288
+ build_subject(destination_app, 'sync', app_name)
289
+ end
290
+
291
+ # Build DLQ subject without side effects
292
+ def build_dlq_subject
293
+ return nil if app_name.to_s.strip.empty?
294
+
295
+ build_subject(app_name, 'sync', 'dlq')
296
+ end
297
+
298
+ # Generic subject builder
299
+ #
300
+ # @param parts [Array<String>] Subject parts to join
301
+ # @return [String, nil] Built subject or nil if any required part is empty
302
+ def build_subject(*parts)
303
+ # Check if any required parts are empty
304
+ return nil if parts.any? { |p| p.to_s.strip.empty? }
305
+
306
+ parts.join('.')
307
+ end
308
+
309
+ def validate_inbox_prefix!(value)
310
+ str = value.to_s.strip
311
+ raise InvalidSubjectError, 'inbox_prefix cannot be empty' if str.empty?
312
+
313
+ # Disallow wildcards, spaces, or control characters in inbox prefix
314
+ return unless str.match?(/[*> \t\r\n\x00-\x1F\x7F]/)
315
+
316
+ raise InvalidSubjectError,
317
+ "inbox_prefix contains invalid characters (wildcards, spaces, or control chars): #{value.inspect}"
318
+ end
319
+
320
+ def validate_override!(name, value)
321
+ str = value.to_s.strip
322
+ raise InvalidSubjectError, "#{name} cannot be empty" if str.empty?
323
+ return unless str.match?(/[ \t\r\n\x00-\x1F\x7F]/)
324
+
325
+ raise InvalidSubjectError, "#{name} contains invalid whitespace/control characters: #{value.inspect}"
326
+ end
327
+
328
+ def validate_stream_name!(value)
329
+ str = value.to_s.strip
330
+ raise MissingConfigurationError, 'stream_name is required' if str.empty?
331
+
332
+ return unless str.match?(/[*> \t\r\n\x00-\x1F\x7F]/)
333
+
334
+ raise InvalidSubjectError,
335
+ "stream_name contains invalid characters (wildcards, whitespace, or control chars): #{value.inspect}"
336
+ end
337
+
338
+ def default_stream_name
339
+ nil
340
+ end
341
+
235
342
  def validate_subject_component!(value, name)
236
343
  str = value.to_s.strip
237
344
  raise MissingConfigurationError, "#{name} cannot be empty" if str.empty?