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
@@ -2,13 +2,13 @@
2
2
 
3
3
  require 'oj'
4
4
  require 'securerandom'
5
- require_relative '../core/connection'
6
5
  require_relative '../core/logging'
7
6
  require_relative '../core/config'
8
7
  require_relative '../core/model_utils'
9
8
  require_relative '../core/retry_strategy'
10
9
  require_relative '../models/publish_result'
11
10
  require_relative 'outbox_repository'
11
+ require_relative 'event_envelope_builder'
12
12
 
13
13
  module JetstreamBridge
14
14
  # Publishes events to NATS JetStream with reliability features.
@@ -34,19 +34,20 @@ module JetstreamBridge
34
34
  # JetstreamBridge.publish(event_type: "user.created", payload: { id: 1 })
35
35
  #
36
36
  class Publisher
37
- # Initialize a new Publisher instance.
37
+ # Initialize a new Publisher instance with dependency injection.
38
38
  #
39
- # Note: The NATS connection should already be established via JetstreamBridge.startup!
40
- # or automatically on first use. This assumes the connection is already established.
41
- #
42
- # @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy for handling transient failures.
43
- # Defaults to PublisherRetryStrategy with exponential backoff.
44
- # @raise [ConnectionError] If unable to get JetStream connection
45
- def initialize(retry_strategy: nil)
46
- @jts = Connection.jetstream
47
- raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
48
-
39
+ # @param connection [NATS::JetStream::JS] JetStream connection
40
+ # @param config [Config] Configuration instance
41
+ # @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy
42
+ # @raise [ArgumentError] If required dependencies are missing
43
+ def initialize(connection:, config:, retry_strategy: nil)
44
+ raise ArgumentError, 'connection is required' unless connection
45
+ raise ArgumentError, 'config is required' unless config
46
+
47
+ @jts = connection
48
+ @config = config
49
49
  @retry_strategy = retry_strategy || PublisherRetryStrategy.new
50
+ @envelope_builder = EventEnvelopeBuilder
50
51
  end
51
52
 
52
53
  # Publishes an event to NATS JetStream.
@@ -107,12 +108,17 @@ module JetstreamBridge
107
108
  # logger.error "Failed to publish: #{result.error.message}"
108
109
  # end
109
110
  #
110
- def publish(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, subject: nil, **options)
111
+ def publish(event_type:, payload:, resource_type: nil, subject: nil, **)
111
112
  ensure_destination_app_configured!
112
113
 
113
- params = { event_or_hash: event_or_hash, resource_type: resource_type, event_type: event_type,
114
- payload: payload, subject: subject, options: options }
115
- envelope, resolved_subject = route_publish_params(params)
114
+ envelope = @envelope_builder.build(
115
+ event_type: event_type,
116
+ payload: payload,
117
+ resource_type: resource_type,
118
+ **
119
+ )
120
+
121
+ resolved_subject = subject || @config.source_subject
116
122
 
117
123
  do_publish(resolved_subject, envelope)
118
124
  rescue ArgumentError
@@ -128,6 +134,61 @@ module JetstreamBridge
128
134
  )
129
135
  end
130
136
 
137
+ # Publish a complete event envelope (advanced usage)
138
+ #
139
+ # Provides full control over the envelope structure. Use this when you need
140
+ # to manually construct the envelope or preserve all fields from an external source.
141
+ #
142
+ # @param envelope [Hash] Complete event envelope with all required fields
143
+ # @option envelope [String] 'event_id' Unique event identifier (required)
144
+ # @option envelope [Integer] 'schema_version' Schema version (required, typically 1)
145
+ # @option envelope [String] 'event_type' Event type (required)
146
+ # @option envelope [String] 'producer' Producer name (required)
147
+ # @option envelope [String] 'resource_type' Resource type (required)
148
+ # @option envelope [String] 'resource_id' Resource identifier (required)
149
+ # @option envelope [String] 'occurred_at' ISO8601 timestamp (required)
150
+ # @option envelope [String] 'trace_id' Trace identifier (required)
151
+ # @option envelope [Hash] 'payload' Event payload data (required)
152
+ # @param subject [String, nil] Optional NATS subject override
153
+ # @return [Models::PublishResult] Result object
154
+ #
155
+ # @raise [ArgumentError] If required envelope fields are missing
156
+ #
157
+ # @example Publishing a complete envelope
158
+ # envelope = {
159
+ # 'event_id' => SecureRandom.uuid,
160
+ # 'schema_version' => 1,
161
+ # 'event_type' => 'user.created',
162
+ # 'producer' => 'custom-producer',
163
+ # 'resource_type' => 'user',
164
+ # 'resource_id' => '123',
165
+ # 'occurred_at' => Time.now.utc.iso8601,
166
+ # 'trace_id' => 'trace-123',
167
+ # 'payload' => { id: 123, name: 'Alice' }
168
+ # }
169
+ #
170
+ # result = publisher.publish_envelope(envelope)
171
+ # puts "Published: #{result.event_id}"
172
+ def publish_envelope(envelope, subject: nil)
173
+ ensure_destination_app_configured!
174
+ validate_envelope!(envelope)
175
+
176
+ resolved_subject = subject || @config.source_subject
177
+
178
+ do_publish(resolved_subject, envelope)
179
+ rescue ArgumentError
180
+ # Re-raise validation errors for invalid envelope
181
+ raise
182
+ rescue StandardError => e
183
+ # Return failure result for publishing errors
184
+ Models::PublishResult.new(
185
+ success: false,
186
+ event_id: envelope&.[]('event_id') || 'unknown',
187
+ subject: resolved_subject || 'unknown',
188
+ error: e
189
+ )
190
+ end
191
+
131
192
  # Internal publish method that routes to appropriate publish strategy.
132
193
  #
133
194
  # Routes to outbox-based publishing if use_outbox is enabled, otherwise
@@ -138,7 +199,7 @@ module JetstreamBridge
138
199
  # @return [Models::PublishResult] Result object
139
200
  # @api private
140
201
  def do_publish(subject, envelope)
141
- if JetstreamBridge.config.use_outbox
202
+ if @config.use_outbox
142
203
  publish_via_outbox(subject, envelope)
143
204
  else
144
205
  with_retries { publish_to_nats(subject, envelope) }
@@ -147,52 +208,20 @@ module JetstreamBridge
147
208
 
148
209
  private
149
210
 
150
- # Routes publish parameters to appropriate envelope builder
151
- # @return [Array<Hash, String>] tuple of [envelope, subject]
152
- def route_publish_params(params)
153
- if structured_params?(params)
154
- build_from_structured_params(params)
155
- elsif keyword_or_hash_params?(params)
156
- build_from_keyword_or_hash(params)
157
- else
158
- raise ArgumentError, 'Either provide (resource_type:, event_type:, payload:) or an event hash'
159
- end
160
- end
161
-
162
- def structured_params?(params)
163
- params[:resource_type] && params[:event_type] && params[:payload]
164
- end
165
-
166
- def keyword_or_hash_params?(params)
167
- params[:event_type] || params[:payload] || params[:event_or_hash].is_a?(Hash)
168
- end
169
-
170
- def build_from_structured_params(params)
171
- envelope = build_envelope(params[:resource_type], params[:event_type], params[:payload], params[:options])
172
- resolved_subject = params[:subject] || JetstreamBridge.config.source_subject
173
- [envelope, resolved_subject]
174
- end
175
-
176
- def build_from_keyword_or_hash(params)
177
- envelope = if params[:event_or_hash].is_a?(Hash)
178
- normalize_envelope(params[:event_or_hash], params[:options])
179
- else
180
- build_from_keywords(params[:event_type], params[:payload], params[:options])
181
- end
211
+ # Validate envelope has all required fields
212
+ def validate_envelope!(envelope)
213
+ required_fields = %w[event_id schema_version event_type producer resource_type
214
+ resource_id occurred_at trace_id payload]
182
215
 
183
- resolved_subject = params[:subject] || params[:options][:subject] || JetstreamBridge.config.source_subject
184
- [envelope, resolved_subject]
185
- end
216
+ missing = required_fields.select { |field| envelope[field].nil? || envelope[field].to_s.strip.empty? }
186
217
 
187
- def build_from_keywords(event_type, payload, options)
188
- raise ArgumentError, 'event_type is required' unless event_type
189
- raise ArgumentError, 'payload is required' unless payload
218
+ return if missing.empty?
190
219
 
191
- normalize_envelope({ 'event_type' => event_type, 'payload' => payload }, options)
220
+ raise ArgumentError, "Envelope missing required fields: #{missing.join(', ')}"
192
221
  end
193
222
 
194
223
  def ensure_destination_app_configured!
195
- return unless JetstreamBridge.config.destination_app.to_s.empty?
224
+ return unless @config.destination_app.to_s.empty?
196
225
 
197
226
  raise ArgumentError, 'destination_app must be configured'
198
227
  end
@@ -231,7 +260,7 @@ module JetstreamBridge
231
260
 
232
261
  # ---- Outbox path ----
233
262
  def publish_via_outbox(subject, envelope)
234
- klass = ModelUtils.constantize(JetstreamBridge.config.outbox_model)
263
+ klass = ModelUtils.constantize(@config.outbox_model)
235
264
 
236
265
  unless ModelUtils.ar_class?(klass)
237
266
  Logging.warn(
@@ -288,64 +317,5 @@ module JetstreamBridge
288
317
  )
289
318
  raise
290
319
  end
291
-
292
- def build_envelope(resource_type, event_type, payload, options = {})
293
- {
294
- 'event_id' => options[:event_id] || SecureRandom.uuid,
295
- 'schema_version' => 1,
296
- 'event_type' => event_type,
297
- 'producer' => JetstreamBridge.config.app_name,
298
- 'resource_id' => extract_resource_id(payload),
299
- 'occurred_at' => (options[:occurred_at] || Time.now.utc).iso8601,
300
- 'trace_id' => options[:trace_id] || SecureRandom.hex(8),
301
- 'resource_type' => resource_type,
302
- 'payload' => payload
303
- }
304
- end
305
-
306
- # Normalize a hash to match envelope structure, allowing partial envelopes
307
- def normalize_envelope(hash, options = {})
308
- hash = hash.transform_keys(&:to_s)
309
- infer_resource_type_if_needed!(hash)
310
-
311
- {
312
- 'event_id' => envelope_event_id(hash, options),
313
- 'schema_version' => hash['schema_version'] || 1,
314
- 'event_type' => hash['event_type'] || raise(ArgumentError, 'event_type is required'),
315
- 'producer' => hash['producer'] || JetstreamBridge.config.app_name,
316
- 'resource_id' => hash['resource_id'] || extract_resource_id(hash['payload']),
317
- 'occurred_at' => envelope_occurred_at(hash, options),
318
- 'trace_id' => envelope_trace_id(hash, options),
319
- 'resource_type' => hash['resource_type'] || 'event',
320
- 'payload' => hash['payload'] || raise(ArgumentError, 'payload is required')
321
- }
322
- end
323
-
324
- def infer_resource_type_if_needed!(hash)
325
- return unless hash['event_type'] && hash['payload'] && !hash['resource_type']
326
-
327
- # Try to infer from dot notation (e.g., 'user.created' -> 'user')
328
- parts = hash['event_type'].split('.')
329
- hash['resource_type'] = parts[0] if parts.size > 1
330
- end
331
-
332
- def envelope_event_id(hash, options)
333
- hash['event_id'] || options[:event_id] || SecureRandom.uuid
334
- end
335
-
336
- def envelope_occurred_at(hash, options)
337
- hash['occurred_at'] || (options[:occurred_at] || Time.now.utc).iso8601
338
- end
339
-
340
- def envelope_trace_id(hash, options)
341
- hash['trace_id'] || options[:trace_id] || SecureRandom.hex(8)
342
- end
343
-
344
- def extract_resource_id(payload)
345
- return '' unless payload
346
-
347
- payload = payload.transform_keys(&:to_s) if payload.respond_to?(:transform_keys)
348
- (payload['id'] || payload[:id] || payload['resource_id'] || payload[:resource_id]).to_s
349
- end
350
320
  end
351
321
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require_relative '../core/model_codec_setup'
4
4
  require_relative '../core/logging'
5
- require_relative '../core/connection'
6
5
 
7
6
  module JetstreamBridge
8
7
  module Rails
@@ -38,8 +37,7 @@ module JetstreamBridge
38
37
  return
39
38
  end
40
39
 
41
- JetstreamBridge.config.validate!
42
- JetstreamBridge.startup!
40
+ JetstreamBridge.connect!
43
41
  log_started!
44
42
  log_development_connection_details! if rails_development?
45
43
  register_shutdown_hook!
@@ -105,7 +103,12 @@ module JetstreamBridge
105
103
  end
106
104
 
107
105
  def log_development_connection_details!
108
- conn_state = JetstreamBridge::Connection.instance.state
106
+ health = begin
107
+ JetstreamBridge.health
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ conn_state = health&.dig(:connection, :state) || 'unknown'
109
112
  active_logger&.info("[JetStream Bridge] Connection state: #{conn_state}")
110
113
  active_logger&.info("[JetStream Bridge] Connected to: #{JetstreamBridge.config.nats_urls}")
111
114
  active_logger&.info("[JetStream Bridge] Stream: #{JetstreamBridge.config.stream_name}")
@@ -116,7 +119,7 @@ module JetstreamBridge
116
119
  def register_shutdown_hook!
117
120
  return if @shutdown_hook_registered
118
121
 
119
- at_exit { JetstreamBridge.shutdown! }
122
+ at_exit { JetstreamBridge.disconnect! }
120
123
  @shutdown_hook_registered = true
121
124
  end
122
125
 
@@ -7,7 +7,7 @@ module JetstreamBridge
7
7
  #
8
8
  # This Railtie integrates JetStream Bridge with the Rails application lifecycle:
9
9
  # - Configuration: Logger is configured early in the Rails boot process
10
- # - Startup: Connection is established after user initializers load (explicit startup!)
10
+ # - Startup: Connection is established after user initializers load (connect!)
11
11
  # - Shutdown: Connection is closed when Rails shuts down (at_exit hook)
12
12
  # - Restart: Puma/Unicorn workers get fresh connections on fork
13
13
  #
@@ -33,10 +33,11 @@ module JetstreamBridge
33
33
  console do
34
34
  ::Rails.logger.info "[JetStream Bridge] Loaded v#{JetstreamBridge::VERSION}"
35
35
  ::Rails.logger.info '[JetStream Bridge] Console helpers available:'
36
- ::Rails.logger.info ' JetstreamBridge.health_check - Check connection status'
36
+ ::Rails.logger.info ' JetstreamBridge.health - Check connection status'
37
+ ::Rails.logger.info ' JetstreamBridge.healthy? - Simple boolean health check'
37
38
  ::Rails.logger.info ' JetstreamBridge.stream_info - View stream details'
38
39
  ::Rails.logger.info ' JetstreamBridge.connected? - Check if connected'
39
- ::Rails.logger.info ' JetstreamBridge.shutdown! - Gracefully disconnect'
40
+ ::Rails.logger.info ' JetstreamBridge.disconnect! - Gracefully disconnect'
40
41
  ::Rails.logger.info ' JetstreamBridge.reconnect! - Reconnect (useful after configuration changes)'
41
42
  end
42
43
 
@@ -62,7 +62,6 @@ namespace :jetstream_bridge do
62
62
  JetstreamBridge.config.validate!
63
63
  puts '✓ Configuration is valid'
64
64
  puts "\nCurrent settings:"
65
- puts " Environment: #{JetstreamBridge.config.env}"
66
65
  puts " App Name: #{JetstreamBridge.config.app_name}"
67
66
  puts " Destination: #{JetstreamBridge.config.destination_app}"
68
67
  puts " Stream: #{JetstreamBridge.config.stream_name}"
@@ -6,8 +6,13 @@ require_relative 'stream'
6
6
 
7
7
  module JetstreamBridge
8
8
  class Topology
9
- def self.ensure!(jts)
9
+ def self.ensure!(jts, force: false)
10
10
  cfg = JetstreamBridge.config
11
+ if cfg.disable_js_api && !force
12
+ Logging.info('Topology ensure skipped (JS API disabled).', tag: 'JetstreamBridge::Topology')
13
+ return
14
+ end
15
+
11
16
  subjects = [cfg.source_subject, cfg.destination_subject]
12
17
  subjects << cfg.dlq_subject if cfg.use_dlq
13
18
  Stream.ensure!(jts, cfg.stream_name, subjects)
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '4.4.0'
7
+ VERSION = '4.5.0'
8
8
  end