jetstream_bridge 4.0.4 → 4.2.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 +106 -0
  3. data/README.md +22 -1402
  4. data/docs/GETTING_STARTED.md +92 -0
  5. data/docs/PRODUCTION.md +503 -0
  6. data/docs/TESTING.md +414 -0
  7. data/lib/jetstream_bridge/consumer/consumer.rb +101 -5
  8. data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +17 -3
  9. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +19 -7
  10. data/lib/jetstream_bridge/consumer/message_processor.rb +88 -52
  11. data/lib/jetstream_bridge/consumer/subscription_manager.rb +24 -15
  12. data/lib/jetstream_bridge/core/bridge_helpers.rb +85 -0
  13. data/lib/jetstream_bridge/core/config.rb +27 -4
  14. data/lib/jetstream_bridge/core/connection.rb +162 -13
  15. data/lib/jetstream_bridge/core.rb +8 -0
  16. data/lib/jetstream_bridge/models/inbox_event.rb +13 -7
  17. data/lib/jetstream_bridge/models/outbox_event.rb +2 -2
  18. data/lib/jetstream_bridge/publisher/publisher.rb +10 -5
  19. data/lib/jetstream_bridge/rails/integration.rb +153 -0
  20. data/lib/jetstream_bridge/rails/railtie.rb +53 -0
  21. data/lib/jetstream_bridge/rails.rb +5 -0
  22. data/lib/jetstream_bridge/tasks/install.rake +1 -1
  23. data/lib/jetstream_bridge/test_helpers/fixtures.rb +41 -0
  24. data/lib/jetstream_bridge/test_helpers/integration_helpers.rb +77 -0
  25. data/lib/jetstream_bridge/test_helpers/matchers.rb +98 -0
  26. data/lib/jetstream_bridge/test_helpers/mock_nats.rb +524 -0
  27. data/lib/jetstream_bridge/test_helpers.rb +85 -121
  28. data/lib/jetstream_bridge/topology/overlap_guard.rb +46 -0
  29. data/lib/jetstream_bridge/topology/stream.rb +7 -4
  30. data/lib/jetstream_bridge/version.rb +1 -1
  31. data/lib/jetstream_bridge.rb +138 -63
  32. metadata +32 -12
  33. data/lib/jetstream_bridge/railtie.rb +0 -49
@@ -27,6 +27,15 @@ module JetstreamBridge
27
27
  class Connection
28
28
  include Singleton
29
29
 
30
+ # Connection states for observability
31
+ module State
32
+ DISCONNECTED = :disconnected
33
+ CONNECTING = :connecting
34
+ CONNECTED = :connected
35
+ RECONNECTING = :reconnecting
36
+ FAILED = :failed
37
+ end
38
+
30
39
  DEFAULT_CONN_OPTS = {
31
40
  reconnect: true,
32
41
  reconnect_time_wait: 2,
@@ -36,16 +45,21 @@ module JetstreamBridge
36
45
 
37
46
  VALID_NATS_SCHEMES = %w[nats nats+tls].freeze
38
47
 
48
+ # Class-level mutex for thread-safe connection initialization
49
+ # Using class variable to avoid race condition in mutex creation
50
+ # rubocop:disable Style/ClassVars
51
+ @@connection_lock = Mutex.new
52
+ # rubocop:enable Style/ClassVars
53
+
39
54
  class << self
40
55
  # Thread-safe delegator to the singleton instance.
41
56
  # Returns a live JetStream context.
42
57
  #
43
- # Safe to call from multiple threads - uses mutex for synchronization.
58
+ # Safe to call from multiple threads - uses class-level mutex for synchronization.
44
59
  #
45
60
  # @return [NATS::JetStream::JS] JetStream context
46
61
  def connect!
47
- @__mutex ||= Mutex.new
48
- @__mutex.synchronize { instance.connect! }
62
+ @@connection_lock.synchronize { instance.connect! }
49
63
  end
50
64
 
51
65
  # Optional accessors if callers need raw handles
@@ -60,12 +74,14 @@ module JetstreamBridge
60
74
 
61
75
  # Idempotent: returns an existing, healthy JetStream context or establishes one.
62
76
  def connect!
63
- return @jts if connected?
77
+ # Check if already connected without acquiring mutex (for performance)
78
+ return @jts if @jts && @nc&.connected?
64
79
 
65
80
  servers = nats_servers
66
81
  raise 'No NATS URLs configured' if servers.empty?
67
82
 
68
- establish_connection(servers)
83
+ @state = State::CONNECTING
84
+ establish_connection_with_retry(servers)
69
85
 
70
86
  Logging.info(
71
87
  "Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
@@ -77,22 +93,59 @@ module JetstreamBridge
77
93
  Topology.ensure!(@jts)
78
94
 
79
95
  @connected_at = Time.now.utc
96
+ @state = State::CONNECTED
80
97
  @jts
98
+ rescue StandardError
99
+ @state = State::FAILED
100
+ cleanup_connection!
101
+ raise
81
102
  end
82
103
 
83
104
  # Public API for checking connection status
105
+ #
106
+ # Uses cached health check result to avoid excessive network calls.
107
+ # Cache expires after 30 seconds.
108
+ #
109
+ # Thread-safe: Cache updates are synchronized to prevent race conditions.
110
+ #
111
+ # @param skip_cache [Boolean] Force fresh health check, bypass cache
84
112
  # @return [Boolean] true if NATS client is connected and JetStream is healthy
85
- def connected?
113
+ def connected?(skip_cache: false)
86
114
  return false unless @nc&.connected?
87
115
  return false unless @jts
88
116
 
89
- jetstream_healthy?
117
+ # Use cached result if available and fresh
118
+ now = Time.now.to_i
119
+ return @cached_health_status if !skip_cache && @last_health_check && (now - @last_health_check) < 30
120
+
121
+ # Thread-safe cache update to prevent race conditions
122
+ @@connection_lock.synchronize do
123
+ # Double-check after acquiring lock (another thread may have updated)
124
+ now = Time.now.to_i
125
+ return @cached_health_status if !skip_cache && @last_health_check && (now - @last_health_check) < 30
126
+
127
+ # Perform actual health check
128
+ @cached_health_status = jetstream_healthy?
129
+ @last_health_check = now
130
+ @cached_health_status
131
+ end
90
132
  end
91
133
 
92
134
  # Public API for getting connection timestamp
93
135
  # @return [Time, nil] timestamp when connection was established
94
136
  attr_reader :connected_at
95
137
 
138
+ # Get current connection state
139
+ #
140
+ # @return [Symbol] Current connection state (see State module)
141
+ def state
142
+ return State::DISCONNECTED unless @nc
143
+ return State::FAILED if @last_reconnect_error && !@nc.connected?
144
+ return State::RECONNECTING if @reconnecting
145
+
146
+ @nc.connected? ? (@state || State::CONNECTED) : State::DISCONNECTED
147
+ end
148
+
96
149
  private
97
150
 
98
151
  def jetstream_healthy?
@@ -118,19 +171,60 @@ module JetstreamBridge
118
171
  servers
119
172
  end
120
173
 
174
+ def establish_connection_with_retry(servers)
175
+ attempts = 0
176
+ max_attempts = JetstreamBridge.config.connect_retry_attempts
177
+ retry_delay = JetstreamBridge.config.connect_retry_delay
178
+
179
+ begin
180
+ attempts += 1
181
+ establish_connection(servers)
182
+ rescue ConnectionError => e
183
+ if attempts < max_attempts
184
+ delay = retry_delay * attempts
185
+ Logging.warn(
186
+ "Connection attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
187
+ "Retrying in #{delay}s...",
188
+ tag: 'JetstreamBridge::Connection'
189
+ )
190
+ sleep(delay)
191
+ retry
192
+ else
193
+ Logging.error(
194
+ "Failed to establish connection after #{attempts} attempts",
195
+ tag: 'JetstreamBridge::Connection'
196
+ )
197
+ cleanup_connection!
198
+ raise
199
+ end
200
+ end
201
+ end
202
+
121
203
  def establish_connection(servers)
122
- @nc = NATS::IO::Client.new
204
+ # Use mock NATS client if explicitly enabled for testing
205
+ # This allows test helpers to inject a mock without affecting normal operation
206
+ @nc = if defined?(JetstreamBridge::TestHelpers) &&
207
+ JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
208
+ JetstreamBridge::TestHelpers.test_mode? &&
209
+ JetstreamBridge.instance_variable_defined?(:@mock_nats_client)
210
+ JetstreamBridge.instance_variable_get(:@mock_nats_client)
211
+ else
212
+ NATS::IO::Client.new
213
+ end
123
214
 
124
215
  # Setup reconnect handler to refresh JetStream context
125
216
  @nc.on_reconnect do
217
+ @reconnecting = true
126
218
  Logging.info(
127
219
  'NATS reconnected, refreshing JetStream context',
128
220
  tag: 'JetstreamBridge::Connection'
129
221
  )
130
222
  refresh_jetstream_context
223
+ @reconnecting = false
131
224
  end
132
225
 
133
226
  @nc.on_disconnect do |reason|
227
+ @state = State::DISCONNECTED
134
228
  Logging.warn(
135
229
  "NATS disconnected: #{reason}",
136
230
  tag: 'JetstreamBridge::Connection'
@@ -144,7 +238,14 @@ module JetstreamBridge
144
238
  )
145
239
  end
146
240
 
147
- @nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS))
241
+ # Only connect if not already connected (mock may be pre-connected)
242
+ # Note: For test helpers mock, skip connect. For RSpec mocks, always call connect
243
+ skip_connect = @nc.connected? &&
244
+ defined?(JetstreamBridge::TestHelpers) &&
245
+ JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
246
+ JetstreamBridge::TestHelpers.test_mode?
247
+
248
+ @nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS)) unless skip_connect
148
249
 
149
250
  # Verify connection is established
150
251
  verify_connection!
@@ -255,11 +356,17 @@ module JetstreamBridge
255
356
  # Verify JetStream is enabled by checking account info
256
357
  account_info = @jts.account_info
257
358
 
359
+ # Handle both object-style and hash-style access for compatibility
360
+ streams = account_info.respond_to?(:streams) ? account_info.streams : account_info[:streams]
361
+ consumers = account_info.respond_to?(:consumers) ? account_info.consumers : account_info[:consumers]
362
+ memory = account_info.respond_to?(:memory) ? account_info.memory : account_info[:memory]
363
+ storage = account_info.respond_to?(:storage) ? account_info.storage : account_info[:storage]
364
+
258
365
  Logging.info(
259
- "JetStream verified - Streams: #{account_info.streams}, " \
260
- "Consumers: #{account_info.consumers}, " \
261
- "Memory: #{format_bytes(account_info.memory)}, " \
262
- "Storage: #{format_bytes(account_info.storage)}",
366
+ "JetStream verified - Streams: #{streams}, " \
367
+ "Consumers: #{consumers}, " \
368
+ "Memory: #{format_bytes(memory)}, " \
369
+ "Storage: #{format_bytes(storage)}",
263
370
  tag: 'JetstreamBridge::Connection'
264
371
  )
265
372
  rescue NATS::IO::NoRespondersError
@@ -292,13 +399,40 @@ module JetstreamBridge
292
399
 
293
400
  # Re-ensure topology after reconnect
294
401
  Topology.ensure!(@jts)
402
+
403
+ # Invalidate health check cache on successful reconnect
404
+ @cached_health_status = nil
405
+ @last_health_check = nil
406
+
407
+ # Clear error state on successful reconnect
408
+ @last_reconnect_error = nil
409
+ @last_reconnect_error_at = nil
410
+ @state = State::CONNECTED
411
+
412
+ Logging.info(
413
+ 'JetStream context refreshed successfully after reconnect',
414
+ tag: 'JetstreamBridge::Connection'
415
+ )
295
416
  rescue StandardError => e
417
+ # Store error state for diagnostics
418
+ @last_reconnect_error = e
419
+ @last_reconnect_error_at = Time.now
420
+ @state = State::FAILED
421
+ cleanup_connection!(close_nc: false)
296
422
  Logging.error(
297
423
  "Failed to refresh JetStream context: #{e.class} #{e.message}",
298
424
  tag: 'JetstreamBridge::Connection'
299
425
  )
426
+
427
+ # Invalidate health check cache to force re-check
428
+ @cached_health_status = false
429
+ @last_health_check = Time.now.to_i
300
430
  end
301
431
 
432
+ # Get last reconnection error for diagnostics
433
+ # @return [StandardError, nil] Last error during reconnection
434
+ attr_reader :last_reconnect_error, :last_reconnect_error_at
435
+
302
436
  # Expose for class-level helpers (not part of public API)
303
437
  attr_reader :nc
304
438
 
@@ -312,5 +446,20 @@ module JetstreamBridge
312
446
  def sanitize_urls(urls)
313
447
  urls.map { |u| Logging.sanitize_url(u) }
314
448
  end
449
+
450
+ def cleanup_connection!(close_nc: true)
451
+ begin
452
+ # Avoid touching RSpec doubles used in unit tests
453
+ is_rspec_double = defined?(RSpec::Mocks::Double) && @nc.is_a?(RSpec::Mocks::Double)
454
+ @nc.close if !is_rspec_double && close_nc && @nc.respond_to?(:close) && @nc.connected?
455
+ rescue StandardError
456
+ # ignore cleanup errors
457
+ end
458
+ @nc = nil
459
+ @jts = nil
460
+ @cached_health_status = nil
461
+ @last_health_check = nil
462
+ @connected_at = nil
463
+ end
315
464
  end
316
465
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Core building blocks for JetstreamBridge
4
+ require_relative 'core/config'
5
+ require_relative 'core/duration'
6
+ require_relative 'core/logging'
7
+ require_relative 'core/connection'
8
+ require_relative 'core/bridge_helpers'
@@ -101,15 +101,21 @@ module JetstreamBridge
101
101
 
102
102
  # Get processing statistics
103
103
  #
104
- # @return [Hash] Statistics hash
104
+ # Uses a single aggregated query to avoid N+1 problem.
105
+ #
106
+ # @return [Hash] Statistics hash with counts by status
105
107
  def processing_stats
106
108
  return {} unless has_column?(:status)
107
109
 
110
+ # Single aggregated query instead of 4 separate queries
111
+ stats_by_status = group(:status).count
112
+ total_count = stats_by_status.values.sum
113
+
108
114
  {
109
- total: count,
110
- processed: processed.count,
111
- failed: failed.count,
112
- pending: unprocessed.count
115
+ total: total_count,
116
+ processed: stats_by_status['processed'] || 0,
117
+ failed: stats_by_status['failed'] || 0,
118
+ pending: stats_by_status['pending'] || stats_by_status[nil] || 0
113
119
  }
114
120
  end
115
121
  end
@@ -168,8 +174,8 @@ module JetstreamBridge
168
174
  def raise_missing_ar!(which, method_name)
169
175
  raise(
170
176
  "#{which} requires ActiveRecord (tried to call ##{method_name}). " \
171
- "Enable `use_inbox` only in apps with ActiveRecord, or add " \
172
- "`gem \"activerecord\"` to your Gemfile."
177
+ 'Enable `use_inbox` only in apps with ActiveRecord, or add ' \
178
+ '`gem "activerecord"` to your Gemfile.'
173
179
  )
174
180
  end
175
181
  end
@@ -169,8 +169,8 @@ module JetstreamBridge
169
169
  def raise_missing_ar!(which, method_name)
170
170
  raise(
171
171
  "#{which} requires ActiveRecord (tried to call ##{method_name}). " \
172
- "Enable `use_outbox` only in apps with ActiveRecord, or add " \
173
- "`gem \"activerecord\"` to your Gemfile."
172
+ 'Enable `use_outbox` only in apps with ActiveRecord, or add ' \
173
+ '`gem "activerecord"` to your Gemfile.'
174
174
  )
175
175
  end
176
176
  end
@@ -36,11 +36,16 @@ module JetstreamBridge
36
36
  class Publisher
37
37
  # Initialize a new Publisher instance.
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
+ #
39
42
  # @param retry_strategy [RetryStrategy, nil] Optional custom retry strategy for handling transient failures.
40
43
  # Defaults to PublisherRetryStrategy with exponential backoff.
41
- # @raise [ConnectionError] If unable to connect to NATS server
44
+ # @raise [ConnectionError] If unable to get JetStream connection
42
45
  def initialize(retry_strategy: nil)
43
- @jts = Connection.connect!
46
+ @jts = Connection.jetstream
47
+ raise ConnectionError, 'JetStream connection not available. Call JetstreamBridge.startup! first.' unless @jts
48
+
44
49
  @retry_strategy = retry_strategy || PublisherRetryStrategy.new
45
50
  end
46
51
 
@@ -103,7 +108,7 @@ module JetstreamBridge
103
108
  # end
104
109
  #
105
110
  def publish(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, subject: nil, **options)
106
- ensure_destination!
111
+ ensure_destination_app_configured!
107
112
 
108
113
  params = { event_or_hash: event_or_hash, resource_type: resource_type, event_type: event_type,
109
114
  payload: payload, subject: subject, options: options }
@@ -186,7 +191,7 @@ module JetstreamBridge
186
191
  normalize_envelope({ 'event_type' => event_type, 'payload' => payload }, options)
187
192
  end
188
193
 
189
- def ensure_destination!
194
+ def ensure_destination_app_configured!
190
195
  return unless JetstreamBridge.config.destination_app.to_s.empty?
191
196
 
192
197
  raise ArgumentError, 'destination_app must be configured'
@@ -290,7 +295,7 @@ module JetstreamBridge
290
295
  'schema_version' => 1,
291
296
  'event_type' => event_type,
292
297
  'producer' => JetstreamBridge.config.app_name,
293
- 'resource_id' => (payload['id'] || payload[:id]).to_s,
298
+ 'resource_id' => extract_resource_id(payload),
294
299
  'occurred_at' => (options[:occurred_at] || Time.now.utc).iso8601,
295
300
  'trace_id' => options[:trace_id] || SecureRandom.hex(8),
296
301
  'resource_type' => resource_type,
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/model_codec_setup'
4
+ require_relative '../core/logging'
5
+ require_relative '../core/connection'
6
+
7
+ module JetstreamBridge
8
+ module Rails
9
+ # Rails-specific lifecycle helpers for JetStream Bridge.
10
+ #
11
+ # Keeps the Railtie thin and makes lifecycle decisions easy to test and reason about.
12
+ module Integration
13
+ module_function
14
+
15
+ # Configure logger to use Rails.logger when available.
16
+ def configure_logger!
17
+ JetstreamBridge.configure do |config|
18
+ config.logger ||= ::Rails.logger if defined?(::Rails.logger)
19
+ end
20
+ end
21
+
22
+ # Attach ActiveRecord hooks for serializer setup on reload.
23
+ def attach_active_record_hooks!
24
+ ActiveSupport.on_load(:active_record) do
25
+ ActiveSupport::Reloader.to_prepare { JetstreamBridge::ModelCodecSetup.apply! }
26
+ end
27
+ end
28
+
29
+ # Validate config, enable test mode if appropriate, and start the bridge unless auto-start is disabled.
30
+ def boot_bridge!
31
+ auto_enable_test_mode!
32
+
33
+ if autostart_disabled?
34
+ message = "Auto-start skipped (reason: #{autostart_skip_reason}; " \
35
+ 'enable via lazy_connect=false, unset JETSTREAM_BRIDGE_DISABLE_AUTOSTART, ' \
36
+ 'or set JETSTREAM_BRIDGE_FORCE_AUTOSTART=1)'
37
+ Logging.info(message, tag: 'JetstreamBridge::Railtie')
38
+ return
39
+ end
40
+
41
+ JetstreamBridge.config.validate!
42
+ JetstreamBridge.startup!
43
+ log_started!
44
+ log_development_connection_details! if rails_development?
45
+ register_shutdown_hook!
46
+ end
47
+
48
+ # Auto-enable test mode in test environment when NATS is not configured.
49
+ def auto_enable_test_mode!
50
+ return unless auto_enable_test_mode?
51
+
52
+ Logging.info(
53
+ '[JetStream Bridge] Auto-enabling test mode (NATS_URLS not set)',
54
+ tag: 'JetstreamBridge::Railtie'
55
+ )
56
+
57
+ require_relative '../test_helpers'
58
+ JetstreamBridge::TestHelpers.enable_test_mode!
59
+ end
60
+
61
+ def auto_enable_test_mode?
62
+ rails_test? &&
63
+ ENV['NATS_URLS'].to_s.strip.empty? &&
64
+ !(defined?(JetstreamBridge::TestHelpers) &&
65
+ JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
66
+ JetstreamBridge::TestHelpers.test_mode?)
67
+ end
68
+
69
+ def autostart_disabled?
70
+ return false if force_autostart?
71
+
72
+ JetstreamBridge.config.lazy_connect ||
73
+ env_disables_autostart? ||
74
+ skip_autostart_for_rails_tooling?
75
+ end
76
+
77
+ def autostart_skip_reason
78
+ return 'lazy_connect enabled' if JetstreamBridge.config.lazy_connect
79
+ return 'JETSTREAM_BRIDGE_DISABLE_AUTOSTART set' if env_disables_autostart?
80
+ return 'Rails console' if rails_console?
81
+ return 'rake task' if rake_task?
82
+
83
+ 'unknown'
84
+ end
85
+
86
+ def env_disables_autostart?
87
+ value = ENV.fetch('JETSTREAM_BRIDGE_DISABLE_AUTOSTART', nil)
88
+ return false if value.nil?
89
+
90
+ normalized = value.to_s.strip.downcase
91
+ return false if normalized.empty?
92
+
93
+ !%w[false 0 no off].include?(normalized)
94
+ end
95
+
96
+ def force_autostart?
97
+ value = ENV.fetch('JETSTREAM_BRIDGE_FORCE_AUTOSTART', nil)
98
+ return false if value.nil?
99
+
100
+ normalized = value.to_s.strip.downcase
101
+ %w[true 1 yes on].include?(normalized)
102
+ end
103
+
104
+ def log_started!
105
+ active_logger&.info('[JetStream Bridge] Started successfully')
106
+ end
107
+
108
+ def log_development_connection_details!
109
+ conn_state = JetstreamBridge::Connection.instance.state
110
+ active_logger&.info("[JetStream Bridge] Connection state: #{conn_state}")
111
+ active_logger&.info("[JetStream Bridge] Connected to: #{JetstreamBridge.config.nats_urls}")
112
+ active_logger&.info("[JetStream Bridge] Stream: #{JetstreamBridge.config.stream_name}")
113
+ active_logger&.info("[JetStream Bridge] Publishing to: #{JetstreamBridge.config.source_subject}")
114
+ active_logger&.info("[JetStream Bridge] Consuming from: #{JetstreamBridge.config.destination_subject}")
115
+ end
116
+
117
+ def register_shutdown_hook!
118
+ return if @shutdown_hook_registered
119
+
120
+ at_exit { JetstreamBridge.shutdown! }
121
+ @shutdown_hook_registered = true
122
+ end
123
+
124
+ def rails_test?
125
+ defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.test?
126
+ end
127
+
128
+ def rails_development?
129
+ defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.development?
130
+ end
131
+
132
+ def rails_console?
133
+ !!defined?(::Rails::Console)
134
+ end
135
+
136
+ def rake_task?
137
+ !!defined?(::Rake) || File.basename($PROGRAM_NAME) == 'rake'
138
+ end
139
+
140
+ def skip_autostart_for_rails_tooling?
141
+ rails_console? || rake_task?
142
+ end
143
+
144
+ def active_logger
145
+ if defined?(::Rails) && ::Rails.respond_to?(:logger)
146
+ ::Rails.logger
147
+ else
148
+ Logging.logger
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'integration'
4
+
5
+ module JetstreamBridge
6
+ # Rails integration for JetStream Bridge.
7
+ #
8
+ # This Railtie integrates JetStream Bridge with the Rails application lifecycle:
9
+ # - Configuration: Logger is configured early in the Rails boot process
10
+ # - Startup: Connection is established after user initializers load (explicit startup!)
11
+ # - Shutdown: Connection is closed when Rails shuts down (at_exit hook)
12
+ # - Restart: Puma/Unicorn workers get fresh connections on fork
13
+ #
14
+ class Railtie < ::Rails::Railtie
15
+ # Set up logger to use Rails.logger by default
16
+ # Note: This only configures the logger, does NOT establish connection
17
+ initializer 'jetstream_bridge.logger', before: :initialize_logger do
18
+ JetstreamBridge::Rails::Integration.configure_logger!
19
+ end
20
+
21
+ # Load ActiveRecord model tweaks after ActiveRecord is loaded
22
+ initializer 'jetstream_bridge.active_record', after: 'active_record.initialize_database' do
23
+ JetstreamBridge::Rails::Integration.attach_active_record_hooks!
24
+ end
25
+
26
+ # Establish connection after Rails initialization is complete
27
+ # This runs after all user initializers have loaded
28
+ config.after_initialize do
29
+ JetstreamBridge::Rails::Integration.boot_bridge!
30
+ end
31
+
32
+ # Add console helper methods
33
+ console do
34
+ ::Rails.logger.info "[JetStream Bridge] Loaded v#{JetstreamBridge::VERSION}"
35
+ ::Rails.logger.info '[JetStream Bridge] Console helpers available:'
36
+ ::Rails.logger.info ' JetstreamBridge.health_check - Check connection status'
37
+ ::Rails.logger.info ' JetstreamBridge.stream_info - View stream details'
38
+ ::Rails.logger.info ' JetstreamBridge.connected? - Check if connected'
39
+ ::Rails.logger.info ' JetstreamBridge.shutdown! - Gracefully disconnect'
40
+ ::Rails.logger.info ' JetstreamBridge.reconnect! - Reconnect (useful after configuration changes)'
41
+ end
42
+
43
+ # Load rake tasks
44
+ rake_tasks do
45
+ load File.expand_path('../tasks/install.rake', __dir__)
46
+ end
47
+
48
+ # Add generators
49
+ generators do
50
+ require 'generators/jetstream_bridge/health_check/health_check_generator'
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for Rails-specific integration (lifecycle helpers + railtie)
4
+ require_relative 'rails/integration'
5
+ require_relative 'rails/railtie' if defined?(Rails::Railtie)
@@ -85,7 +85,7 @@ namespace :jetstream_bridge do
85
85
  puts '[jetstream_bridge] Testing NATS connection...'
86
86
 
87
87
  begin
88
- jts = JetstreamBridge.ensure_topology!
88
+ jts = JetstreamBridge.connect_and_ensure_stream!
89
89
  puts '✓ Successfully connected to NATS'
90
90
  puts '✓ JetStream is available'
91
91
  puts '✓ Stream topology ensured'
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ module TestHelpers
5
+ # Common fixtures for quickly building events in specs.
6
+ module Fixtures
7
+ module_function
8
+
9
+ # Build a user.created event
10
+ def user_created_event(attrs = {})
11
+ JetstreamBridge::TestHelpers.build_jetstream_event(
12
+ event_type: 'user.created',
13
+ payload: {
14
+ id: attrs[:id] || 1,
15
+ email: attrs[:email] || 'test@example.com',
16
+ name: attrs[:name] || 'Test User'
17
+ }.merge(attrs[:payload] || {})
18
+ )
19
+ end
20
+
21
+ # Build multiple sample events
22
+ def sample_events(count = 3, type: 'test.event')
23
+ Array.new(count) do |i|
24
+ JetstreamBridge::TestHelpers.build_jetstream_event(
25
+ event_type: type,
26
+ payload: { id: i + 1, sequence: i }
27
+ )
28
+ end
29
+ end
30
+
31
+ # Build a generic event with custom attributes
32
+ def event(event_type:, payload: {}, **attrs)
33
+ JetstreamBridge::TestHelpers.build_jetstream_event(
34
+ event_type: event_type,
35
+ payload: payload,
36
+ **attrs
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end