jetstream_bridge 4.5.0 → 4.5.2

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 +525 -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 +59 -12
  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
@@ -5,12 +5,11 @@ module JetstreamBridge
5
5
  # Value object representing a NATS subject
6
6
  #
7
7
  # @example Creating a subject
8
- # subject = Subject.source(env: "production", app_name: "api", dest: "worker")
9
- # subject.to_s # => "production.api.sync.worker"
8
+ # subject = Subject.source(app_name: "api", dest: "worker")
9
+ # subject.to_s # => "api.sync.worker"
10
10
  #
11
11
  # @example Parsing a subject string
12
- # subject = Subject.parse("production.api.sync.worker")
13
- # subject.env # => "production"
12
+ # subject = Subject.parse("api.sync.worker")
14
13
  # subject.source_app # => "api"
15
14
  # subject.dest_app # => "worker"
16
15
  class Subject
@@ -31,16 +30,16 @@ module JetstreamBridge
31
30
  end
32
31
 
33
32
  # Factory methods
34
- def self.source(env:, app_name:, dest:)
35
- new("#{env}.#{app_name}.sync.#{dest}")
33
+ def self.source(app_name:, dest:)
34
+ new("#{app_name}.sync.#{dest}")
36
35
  end
37
36
 
38
- def self.destination(env:, source:, app_name:)
39
- new("#{env}.#{source}.sync.#{app_name}")
37
+ def self.destination(source:, app_name:)
38
+ new("#{source}.sync.#{app_name}")
40
39
  end
41
40
 
42
- def self.dlq(env:, app_name:)
43
- new("#{env}.#{app_name}.sync.dlq")
41
+ def self.dlq(app_name:)
42
+ new("#{app_name}.sync.dlq")
44
43
  end
45
44
 
46
45
  # Parse a subject string into a Subject object with metadata
@@ -51,37 +50,30 @@ module JetstreamBridge
51
50
  new(string)
52
51
  end
53
52
 
54
- # Get environment from subject (first token)
55
- #
56
- # @return [String, nil] Environment
57
- def env
58
- @tokens[0]
59
- end
60
-
61
53
  # Get source application from subject
62
54
  #
63
- # For regular subjects: {env}.{source_app}.sync.{dest}
64
- # For DLQ subjects: {env}.{app_name}.sync.dlq
55
+ # For regular subjects: {source_app}.sync.{dest}
56
+ # For DLQ subjects: {app_name}.sync.dlq
65
57
  #
66
58
  # @return [String, nil] Source application
67
59
  def source_app
68
- @tokens[1]
60
+ @tokens[0]
69
61
  end
70
62
 
71
63
  # Get destination application from subject
72
64
  #
73
65
  # @return [String, nil] Destination application
74
66
  def dest_app
75
- @tokens[3]
67
+ @tokens[2]
76
68
  end
77
69
 
78
70
  # Check if this is a DLQ subject
79
71
  #
80
- # DLQ subjects follow the pattern: {env}.{app}.sync.dlq
72
+ # DLQ subjects follow the pattern: {app}.sync.dlq
81
73
  #
82
74
  # @return [Boolean] True if this is a DLQ subject
83
75
  def dlq?
84
- @tokens.length == 4 && @tokens[2] == 'sync' && @tokens[3] == 'dlq'
76
+ @tokens.length == 3 && @tokens[1] == 'sync' && @tokens[2] == 'dlq'
85
77
  end
86
78
 
87
79
  # Check if this subject matches a pattern
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'topology/topology'
4
+ require_relative 'consumer/subscription_manager'
5
+ require_relative 'core/logging'
6
+ require_relative 'core/config'
7
+ require_relative 'core/connection'
8
+
9
+ module JetstreamBridge
10
+ # Dedicated provisioning orchestrator to keep connection concerns separate.
11
+ #
12
+ # Handles creating/updating stream topology and consumers. Can be used at
13
+ # deploy-time with admin credentials or during runtime when auto_provision
14
+ # is enabled.
15
+ class Provisioner
16
+ def initialize(config: JetstreamBridge.config)
17
+ @config = config
18
+ end
19
+
20
+ # Ensure stream (and optionally consumer) exist with desired config.
21
+ #
22
+ # @param jts [Object, nil] Existing JetStream context (optional)
23
+ # @param ensure_consumer [Boolean] Whether to create/align the consumer too
24
+ # @return [Object] JetStream context used for provisioning
25
+ def ensure!(jts: nil, ensure_consumer: true)
26
+ js = jts || Connection.connect!(verify_js: true)
27
+
28
+ ensure_stream!(js)
29
+ ensure_consumer!(js) if ensure_consumer
30
+
31
+ Logging.info(
32
+ "Provisioned stream=#{@config.stream_name} consumer=#{@config.durable_name if ensure_consumer}",
33
+ tag: 'JetstreamBridge::Provisioner'
34
+ )
35
+
36
+ js
37
+ end
38
+
39
+ # Ensure stream only.
40
+ #
41
+ # @param jts [Object, nil] Existing JetStream context (optional)
42
+ # @return [Object] JetStream context used
43
+ def ensure_stream!(jts: nil)
44
+ js = jts || Connection.connect!(verify_js: true)
45
+ Topology.ensure!(js)
46
+ Logging.info(
47
+ "Stream ensured: #{@config.stream_name}",
48
+ tag: 'JetstreamBridge::Provisioner'
49
+ )
50
+ js
51
+ end
52
+
53
+ # Ensure durable consumer only.
54
+ #
55
+ # @param jts [Object, nil] Existing JetStream context (optional)
56
+ # @return [Object] JetStream context used
57
+ def ensure_consumer!(jts: nil)
58
+ js = jts || Connection.connect!(verify_js: true)
59
+ SubscriptionManager.new(js, @config.durable_name, @config).ensure_consumer!(force: true)
60
+ Logging.info(
61
+ "Consumer ensured: #{@config.durable_name}",
62
+ tag: 'JetstreamBridge::Provisioner'
63
+ )
64
+ js
65
+ end
66
+ end
67
+ end
@@ -2,18 +2,18 @@
2
2
 
3
3
  require 'oj'
4
4
  require 'securerandom'
5
+ require_relative '../core/connection'
5
6
  require_relative '../core/logging'
6
7
  require_relative '../core/config'
7
8
  require_relative '../core/model_utils'
8
9
  require_relative '../core/retry_strategy'
9
10
  require_relative '../models/publish_result'
10
11
  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.
15
15
  #
16
- # Publishes events to "{env}.{app}.sync.{dest}" subject pattern.
16
+ # Publishes events to "{app}.sync.{dest}" subject pattern.
17
17
  # Supports optional transactional outbox pattern for guaranteed delivery.
18
18
  #
19
19
  # @example Basic publishing
@@ -34,20 +34,19 @@ 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 with dependency injection.
37
+ # Initialize a new Publisher instance.
38
38
  #
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
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
+
49
49
  @retry_strategy = retry_strategy || PublisherRetryStrategy.new
50
- @envelope_builder = EventEnvelopeBuilder
51
50
  end
52
51
 
53
52
  # Publishes an event to NATS JetStream.
@@ -108,76 +107,16 @@ module JetstreamBridge
108
107
  # logger.error "Failed to publish: #{result.error.message}"
109
108
  # end
110
109
  #
111
- def publish(event_type:, payload:, resource_type: nil, subject: nil, **)
110
+ def publish(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, subject: nil, **options)
112
111
  ensure_destination_app_configured!
113
112
 
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
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)
122
116
 
123
117
  do_publish(resolved_subject, envelope)
124
- rescue ArgumentError
125
- # Re-raise validation errors for invalid parameters
126
- raise
127
- rescue StandardError => e
128
- # Return failure result for publishing errors
129
- Models::PublishResult.new(
130
- success: false,
131
- event_id: envelope&.[]('event_id') || 'unknown',
132
- subject: resolved_subject || 'unknown',
133
- error: e
134
- )
135
- end
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
118
+ rescue ArgumentError, JetstreamBridge::ConfigurationError
119
+ # Re-raise validation/configuration errors (e.g., missing destination_app)
181
120
  raise
182
121
  rescue StandardError => e
183
122
  # Return failure result for publishing errors
@@ -199,7 +138,7 @@ module JetstreamBridge
199
138
  # @return [Models::PublishResult] Result object
200
139
  # @api private
201
140
  def do_publish(subject, envelope)
202
- if @config.use_outbox
141
+ if JetstreamBridge.config.use_outbox
203
142
  publish_via_outbox(subject, envelope)
204
143
  else
205
144
  with_retries { publish_to_nats(subject, envelope) }
@@ -208,22 +147,48 @@ module JetstreamBridge
208
147
 
209
148
  private
210
149
 
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]
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
215
165
 
216
- missing = required_fields.select { |field| envelope[field].nil? || envelope[field].to_s.strip.empty? }
166
+ def keyword_or_hash_params?(params)
167
+ params[:event_type] || params[:payload] || params[:event_or_hash].is_a?(Hash)
168
+ end
217
169
 
218
- return if missing.empty?
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
219
182
 
220
- raise ArgumentError, "Envelope missing required fields: #{missing.join(', ')}"
183
+ resolved_subject = params[:subject] || params[:options][:subject] || JetstreamBridge.config.source_subject
184
+ [envelope, resolved_subject]
221
185
  end
222
186
 
223
- def ensure_destination_app_configured!
224
- return unless @config.destination_app.to_s.empty?
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
225
190
 
226
- raise ArgumentError, 'destination_app must be configured'
191
+ normalize_envelope({ 'event_type' => event_type, 'payload' => payload }, options)
227
192
  end
228
193
 
229
194
  def publish_to_nats(subject, envelope)
@@ -260,7 +225,7 @@ module JetstreamBridge
260
225
 
261
226
  # ---- Outbox path ----
262
227
  def publish_via_outbox(subject, envelope)
263
- klass = ModelUtils.constantize(@config.outbox_model)
228
+ klass = ModelUtils.constantize(JetstreamBridge.config.outbox_model)
264
229
 
265
230
  unless ModelUtils.ar_class?(klass)
266
231
  Logging.warn(
@@ -317,5 +282,69 @@ module JetstreamBridge
317
282
  )
318
283
  raise
319
284
  end
285
+
286
+ def build_envelope(resource_type, event_type, payload, options = {})
287
+ {
288
+ 'event_id' => options[:event_id] || SecureRandom.uuid,
289
+ 'schema_version' => 1,
290
+ 'event_type' => event_type,
291
+ 'producer' => JetstreamBridge.config.app_name,
292
+ 'resource_id' => extract_resource_id(payload),
293
+ 'occurred_at' => (options[:occurred_at] || Time.now.utc).iso8601,
294
+ 'trace_id' => options[:trace_id] || SecureRandom.hex(8),
295
+ 'resource_type' => resource_type,
296
+ 'payload' => payload
297
+ }
298
+ end
299
+
300
+ # Normalize a hash to match envelope structure, allowing partial envelopes
301
+ def normalize_envelope(hash, options = {})
302
+ hash = hash.transform_keys(&:to_s)
303
+ infer_resource_type_if_needed!(hash)
304
+
305
+ {
306
+ 'event_id' => envelope_event_id(hash, options),
307
+ 'schema_version' => hash['schema_version'] || 1,
308
+ 'event_type' => hash['event_type'] || raise(ArgumentError, 'event_type is required'),
309
+ 'producer' => hash['producer'] || JetstreamBridge.config.app_name,
310
+ 'resource_id' => hash['resource_id'] || extract_resource_id(hash['payload']),
311
+ 'occurred_at' => envelope_occurred_at(hash, options),
312
+ 'trace_id' => envelope_trace_id(hash, options),
313
+ 'resource_type' => hash['resource_type'] || 'event',
314
+ 'payload' => hash['payload'] || raise(ArgumentError, 'payload is required')
315
+ }
316
+ end
317
+
318
+ def infer_resource_type_if_needed!(hash)
319
+ return unless hash['event_type'] && hash['payload'] && !hash['resource_type']
320
+
321
+ # Try to infer from dot notation (e.g., 'user.created' -> 'user')
322
+ parts = hash['event_type'].split('.')
323
+ hash['resource_type'] = parts[0] if parts.size > 1
324
+ end
325
+
326
+ def envelope_event_id(hash, options)
327
+ hash['event_id'] || options[:event_id] || SecureRandom.uuid
328
+ end
329
+
330
+ def envelope_occurred_at(hash, options)
331
+ hash['occurred_at'] || (options[:occurred_at] || Time.now.utc).iso8601
332
+ end
333
+
334
+ def envelope_trace_id(hash, options)
335
+ hash['trace_id'] || options[:trace_id] || SecureRandom.hex(8)
336
+ end
337
+
338
+ def extract_resource_id(payload)
339
+ return '' unless payload
340
+
341
+ payload = payload.transform_keys(&:to_s) if payload.respond_to?(:transform_keys)
342
+ (payload['id'] || payload[:id] || payload['resource_id'] || payload[:resource_id]).to_s
343
+ end
344
+
345
+ def ensure_destination_app_configured!
346
+ # Leverage subject builder to enforce required components and surface consistent error messaging.
347
+ JetstreamBridge.config.source_subject
348
+ end
320
349
  end
321
350
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../core/model_codec_setup'
4
4
  require_relative '../core/logging'
5
+ require_relative '../core/connection'
5
6
 
6
7
  module JetstreamBridge
7
8
  module Rails
@@ -37,7 +38,8 @@ module JetstreamBridge
37
38
  return
38
39
  end
39
40
 
40
- JetstreamBridge.connect!
41
+ JetstreamBridge.config.validate!
42
+ JetstreamBridge.startup!
41
43
  log_started!
42
44
  log_development_connection_details! if rails_development?
43
45
  register_shutdown_hook!
@@ -103,12 +105,7 @@ module JetstreamBridge
103
105
  end
104
106
 
105
107
  def log_development_connection_details!
106
- health = begin
107
- JetstreamBridge.health
108
- rescue StandardError
109
- nil
110
- end
111
- conn_state = health&.dig(:connection, :state) || 'unknown'
108
+ conn_state = JetstreamBridge::Connection.instance.state
112
109
  active_logger&.info("[JetStream Bridge] Connection state: #{conn_state}")
113
110
  active_logger&.info("[JetStream Bridge] Connected to: #{JetstreamBridge.config.nats_urls}")
114
111
  active_logger&.info("[JetStream Bridge] Stream: #{JetstreamBridge.config.stream_name}")
@@ -119,7 +116,7 @@ module JetstreamBridge
119
116
  def register_shutdown_hook!
120
117
  return if @shutdown_hook_registered
121
118
 
122
- at_exit { JetstreamBridge.disconnect! }
119
+ at_exit { JetstreamBridge.shutdown! }
123
120
  @shutdown_hook_registered = true
124
121
  end
125
122
 
@@ -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 (connect!)
10
+ # - Startup: Connection is established after user initializers load (explicit startup!)
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,11 +33,10 @@ 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 connection status'
37
- ::Rails.logger.info ' JetstreamBridge.healthy? - Simple boolean health check'
36
+ ::Rails.logger.info ' JetstreamBridge.health_check - Check connection status'
38
37
  ::Rails.logger.info ' JetstreamBridge.stream_info - View stream details'
39
38
  ::Rails.logger.info ' JetstreamBridge.connected? - Check if connected'
40
- ::Rails.logger.info ' JetstreamBridge.disconnect! - Gracefully disconnect'
39
+ ::Rails.logger.info ' JetstreamBridge.shutdown! - Gracefully disconnect'
41
40
  ::Rails.logger.info ' JetstreamBridge.reconnect! - Reconnect (useful after configuration changes)'
42
41
  end
43
42
 
@@ -36,7 +36,7 @@ namespace :jetstream_bridge do
36
36
 
37
37
  if health[:config]
38
38
  puts "\nConfiguration:"
39
- puts " Environment: #{health[:config][:env]}"
39
+ puts " Stream: #{health[:config][:stream_name]}"
40
40
  puts " App Name: #{health[:config][:app_name]}"
41
41
  puts " Destination: #{health[:config][:destination_app] || 'NOT SET'}"
42
42
  puts " Outbox: #{health[:config][:use_outbox] ? 'Enabled' : 'Disabled'}"
@@ -62,6 +62,7 @@ namespace :jetstream_bridge do
62
62
  JetstreamBridge.config.validate!
63
63
  puts '✓ Configuration is valid'
64
64
  puts "\nCurrent settings:"
65
+ puts " Stream: #{JetstreamBridge.config.stream_name}"
65
66
  puts " App Name: #{JetstreamBridge.config.app_name}"
66
67
  puts " Destination: #{JetstreamBridge.config.destination_app}"
67
68
  puts " Stream: #{JetstreamBridge.config.stream_name}"
@@ -79,27 +80,73 @@ namespace :jetstream_bridge do
79
80
  JetstreamBridge::DebugHelper.debug_info
80
81
  end
81
82
 
83
+ desc 'Provision stream and consumer (requires JetStream admin permissions)'
84
+ task provision: :environment do
85
+ puts '[jetstream_bridge] Provisioning JetStream stream and consumer...'
86
+
87
+ begin
88
+ provisioner = JetstreamBridge::Provisioner.new
89
+ provisioner.ensure!(ensure_consumer: true)
90
+ puts "✓ Provisioned stream=#{JetstreamBridge.config.stream_name} consumer=#{JetstreamBridge.config.durable_name}"
91
+ exit 0
92
+ rescue StandardError => e
93
+ puts "✗ Provisioning failed: #{e.message}"
94
+ exit 1
95
+ end
96
+ end
97
+
82
98
  desc 'Test connection to NATS'
83
99
  task test_connection: :environment do
84
100
  puts '[jetstream_bridge] Testing NATS connection...'
85
101
 
102
+ permission_violation = lambda do |err|
103
+ current = err
104
+ while current
105
+ message = current.message.to_s
106
+ return true if message.include?('Permissions Violation for Publish to "$JS.API')
107
+ return true if message.match?(/permission(?:s)? violation/i)
108
+
109
+ current = current.cause if current.respond_to?(:cause)
110
+ end
111
+ false
112
+ end
113
+
86
114
  begin
115
+ provision_enabled = JetstreamBridge.config.auto_provision
87
116
  jts = JetstreamBridge.connect_and_ensure_stream!
88
- puts '✓ Successfully connected to NATS'
89
- puts '✓ JetStream is available'
90
- puts '✓ Stream topology ensured'
91
-
92
- # Check if we can get account info
93
- info = jts.account_info
94
- puts "\nAccount Info:"
95
- puts " Memory: #{info.memory}"
96
- puts " Storage: #{info.storage}"
97
- puts " Streams: #{info.streams}"
98
- puts " Consumers: #{info.consumers}"
117
+
118
+ if provision_enabled
119
+ puts '✓ Successfully connected to NATS'
120
+ puts '✓ JetStream is available'
121
+ puts '✓ Stream topology ensured'
122
+
123
+ # Check if we can get account info
124
+ info = jts.account_info
125
+ puts "\nAccount Info:"
126
+ puts " Memory: #{info.memory}"
127
+ puts " Storage: #{info.storage}"
128
+ puts " Streams: #{info.streams}"
129
+ puts " Consumers: #{info.consumers}"
130
+ else
131
+ nc = jts.nc if jts.respond_to?(:nc)
132
+ nc ||= JetstreamBridge::Connection.nc
133
+ nc&.flush(0.5)
134
+
135
+ puts '✓ Successfully connected to NATS (ping-only: auto_provision=false)'
136
+ puts '✓ JetStream client initialized (skipped $JS.API.* calls)'
137
+ puts "✓ Stream provisioning/verification skipped for '#{JetstreamBridge.config.stream_name}' " \
138
+ '(assumes pre-provisioned via admin credentials)'
139
+ end
99
140
 
100
141
  exit 0
101
142
  rescue StandardError => e
102
143
  puts "✗ Connection failed: #{e.message}"
144
+ if permission_violation.call(e)
145
+ puts "\nHint: current NATS credentials cannot call JetStream API subjects ($JS.API.*). " \
146
+ 'Set config.auto_provision = false and pre-provision using ' \
147
+ '`bundle exec rake jetstream_bridge:provision` with admin credentials. ' \
148
+ 'See docs/RESTRICTED_PERMISSIONS.md.'
149
+ end
103
150
  puts "\nBacktrace:" if ENV['VERBOSE']
104
151
  puts e.backtrace.first(10).map { |line| " #{line}" }.join("\n") if ENV['VERBOSE']
105
152
  exit 1
@@ -6,13 +6,8 @@ require_relative 'stream'
6
6
 
7
7
  module JetstreamBridge
8
8
  class Topology
9
- def self.ensure!(jts, force: false)
9
+ def self.ensure!(jts)
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
-
16
11
  subjects = [cfg.source_subject, cfg.destination_subject]
17
12
  subjects << cfg.dlq_subject if cfg.use_dlq
18
13
  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.5.0'
7
+ VERSION = '4.5.2'
8
8
  end