jetstream_bridge 4.5.0 → 4.5.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +338 -87
  3. data/README.md +3 -13
  4. data/docs/GETTING_STARTED.md +8 -12
  5. data/docs/PRODUCTION.md +13 -35
  6. data/docs/RESTRICTED_PERMISSIONS.md +399 -0
  7. data/docs/TESTING.md +33 -22
  8. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +3 -3
  9. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +3 -0
  10. data/lib/jetstream_bridge/consumer/consumer.rb +100 -39
  11. data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
  12. data/lib/jetstream_bridge/consumer/subscription_manager.rb +97 -121
  13. data/lib/jetstream_bridge/core/bridge_helpers.rb +127 -0
  14. data/lib/jetstream_bridge/core/config.rb +32 -161
  15. data/lib/jetstream_bridge/core/connection.rb +508 -0
  16. data/lib/jetstream_bridge/core/connection_factory.rb +95 -0
  17. data/lib/jetstream_bridge/core/debug_helper.rb +2 -9
  18. data/lib/jetstream_bridge/core.rb +2 -0
  19. data/lib/jetstream_bridge/models/subject.rb +15 -23
  20. data/lib/jetstream_bridge/provisioner.rb +67 -0
  21. data/lib/jetstream_bridge/publisher/publisher.rb +121 -92
  22. data/lib/jetstream_bridge/rails/integration.rb +5 -8
  23. data/lib/jetstream_bridge/rails/railtie.rb +3 -4
  24. data/lib/jetstream_bridge/tasks/install.rake +17 -1
  25. data/lib/jetstream_bridge/topology/topology.rb +1 -6
  26. data/lib/jetstream_bridge/version.rb +1 -1
  27. data/lib/jetstream_bridge.rb +345 -202
  28. metadata +8 -8
  29. data/lib/jetstream_bridge/consumer/health_monitor.rb +0 -107
  30. data/lib/jetstream_bridge/core/connection_manager.rb +0 -513
  31. data/lib/jetstream_bridge/core/health_checker.rb +0 -184
  32. data/lib/jetstream_bridge/facade.rb +0 -212
  33. data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +0 -110
@@ -13,6 +13,7 @@ module JetstreamBridge
13
13
  # config.nats_urls = "nats://localhost:4222"
14
14
  # config.app_name = "api_service"
15
15
  # config.destination_app = "worker_service"
16
+ # config.stream_name = "jetstream-bridge-stream"
16
17
  # config.use_outbox = true
17
18
  # config.use_inbox = true
18
19
  # end
@@ -22,6 +23,7 @@ module JetstreamBridge
22
23
  # config.nats_urls = ENV["NATS_URLS"]
23
24
  # config.app_name = "api"
24
25
  # config.destination_app = "worker"
26
+ # config.stream_name = "jetstream-bridge-stream"
25
27
  # end
26
28
  #
27
29
  class Config
@@ -49,7 +51,11 @@ module JetstreamBridge
49
51
  # NATS server URL(s)
50
52
  # @return [String]
51
53
  attr_accessor :nats_urls
54
+ # JetStream stream name (required)
55
+ # @return [String]
56
+ attr_accessor :stream_name
52
57
  # Application name for subject routing
58
+ # @return [String]
53
59
  attr_accessor :app_name
54
60
  # Maximum delivery attempts before moving to DLQ
55
61
  # @return [Integer]
@@ -90,23 +96,16 @@ module JetstreamBridge
90
96
  # Enable lazy connection (connect on first use instead of during configure)
91
97
  # @return [Boolean]
92
98
  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.
99
+ # Allow JetStream Bridge to create/update streams/consumers and call JetStream management APIs.
100
+ # Disable for locked-down environments and handle provisioning separately.
98
101
  # @return [Boolean]
99
- attr_accessor :disable_js_api
100
- # Optional durable consumer name
101
- attr_writer :durable_name
102
+ attr_accessor :auto_provision
102
103
 
103
104
  def initialize
104
105
  @nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
105
- @app_name = ENV['APP_NAME'] || 'app'
106
+ @stream_name = ENV['JETSTREAM_STREAM_NAME'] || 'jetstream-bridge-stream'
107
+ @app_name = ENV['APP_NAME'] || 'app'
106
108
  @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)
110
109
 
111
110
  @max_deliver = 5
112
111
  @ack_wait = '30s'
@@ -124,8 +123,7 @@ module JetstreamBridge
124
123
  @connect_retry_attempts = 3
125
124
  @connect_retry_delay = 2
126
125
  @lazy_connect = false
127
- @inbox_prefix = ENV['NATS_INBOX_PREFIX'] || '_INBOX'
128
- @disable_js_api = (ENV['JETSTREAM_DISABLE_JS_API'] || 'true') == 'true'
126
+ @auto_provision = true
129
127
  end
130
128
 
131
129
  # Apply a configuration preset
@@ -139,83 +137,57 @@ module JetstreamBridge
139
137
  self
140
138
  end
141
139
 
142
- # Get the JetStream stream name (required).
143
- #
144
- # Side-effect free query. Use validate! to ensure validity.
145
- #
146
- # @return [String, nil] Stream name
147
- def stream_name
148
- @cached_stream_name || (@stream_name_set ? @stream_name : default_stream_name)
149
- end
150
-
151
140
  # Get the NATS subject this application publishes to.
152
141
  #
153
142
  # Producer publishes to: {app}.sync.{dest}
154
143
  # Consumer subscribes to: {dest}.sync.{app}
155
144
  #
156
- # Side-effect free query. Use validate! to ensure validity.
157
- #
158
- # @return [String, nil] Source subject for publishing
145
+ # @return [String] Source subject for publishing
146
+ # @raise [InvalidSubjectError] If components contain NATS wildcards
147
+ # @raise [MissingConfigurationError] If required components empty
159
148
  # @example
160
149
  # config.app_name = "api"
161
150
  # config.destination_app = "worker"
162
151
  # config.source_subject # => "api.sync.worker"
163
152
  def source_subject
164
- @cached_source_subject || build_source_subject
153
+ validate_subject_component!(app_name, 'app_name')
154
+ validate_subject_component!(destination_app, 'destination_app')
155
+ "#{app_name}.sync.#{destination_app}"
165
156
  end
166
157
 
167
158
  # Get the NATS subject this application subscribes to.
168
159
  #
169
- # Side-effect free query. Use validate! to ensure validity.
170
- #
171
- # @return [String, nil] Destination subject for consuming
172
- # @example
173
- # config.app_name = "api"
174
- # config.destination_app = "worker"
175
- # config.destination_subject # => "worker.sync.api"
160
+ # @return [String] Destination subject for consuming
161
+ # @raise [InvalidSubjectError] If components contain NATS wildcards
162
+ # @raise [MissingConfigurationError] If required components empty
176
163
  def destination_subject
177
- @cached_destination_subject || build_destination_subject
164
+ validate_subject_component!(app_name, 'app_name')
165
+ validate_subject_component!(destination_app, 'destination_app')
166
+ "#{destination_app}.sync.#{app_name}"
178
167
  end
179
168
 
180
169
  # Get the dead letter queue subject for this application.
181
170
  #
182
171
  # Each app has its own DLQ for better isolation and monitoring.
183
172
  #
184
- # Side-effect free query. Use validate! to ensure validity.
185
- #
186
- # @return [String, nil] DLQ subject in format "{app_name}.sync.dlq"
187
- # @example
188
- # config.app_name = "api"
189
- # config.dlq_subject # => "api.sync.dlq"
173
+ # @return [String] DLQ subject in format "{app_name}.sync.dlq"
174
+ # @raise [InvalidSubjectError] If components contain NATS wildcards
175
+ # @raise [MissingConfigurationError] If required components are empty
190
176
  def dlq_subject
191
- @cached_dlq_subject || build_dlq_subject
177
+ validate_subject_component!(app_name, 'app_name')
178
+ "#{app_name}.sync.dlq"
192
179
  end
193
180
 
194
181
  # Get the durable consumer name for this application.
195
182
  #
196
- # @return [String] Durable name in format "{app_name}-workers"
197
- # @example
198
- # config.app_name = "api"
199
- # config.durable_name # => "api-workers"
200
183
  def durable_name
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
184
+ "#{app_name}-workers"
210
185
  end
211
186
 
212
187
  # Validate all configuration settings.
213
188
  #
214
189
  # Checks that required settings are present and valid. Raises errors
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.
190
+ # for any invalid configuration.
219
191
  #
220
192
  # @return [true] If configuration is valid
221
193
  # @raise [ConfigurationError] If any validation fails
@@ -223,122 +195,21 @@ module JetstreamBridge
223
195
  # config.validate! # Raises if destination_app is missing
224
196
  def validate!
225
197
  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
240
198
  errors << 'destination_app is required' if destination_app.to_s.strip.empty?
241
199
  errors << 'nats_urls is required' if nats_urls.to_s.strip.empty?
200
+ errors << 'stream_name is required' if stream_name.to_s.strip.empty?
242
201
  errors << 'app_name is required' if app_name.to_s.strip.empty?
243
202
  errors << 'max_deliver must be >= 1' if max_deliver.to_i < 1
244
203
  errors << 'backoff must be an array' unless backoff.is_a?(Array)
245
204
  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
262
205
 
263
206
  raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
264
207
 
265
- # Cache computed values after successful validation
266
- cache_computed_values!
267
-
268
208
  true
269
209
  end
270
210
 
271
211
  private
272
212
 
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
-
342
213
  def validate_subject_component!(value, name)
343
214
  str = value.to_s.strip
344
215
  raise MissingConfigurationError, "#{name} cannot be empty" if str.empty?