jetstream_bridge 4.4.1 → 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.
@@ -4,7 +4,7 @@ require 'rails/generators'
4
4
 
5
5
  module JetstreamBridge
6
6
  module Generators
7
- class HealthCheckGenerator < Rails::Generators::Base
7
+ class HealthCheckGenerator < ::Rails::Generators::Base
8
8
  source_root File.expand_path('templates', __dir__)
9
9
  desc 'Creates a health check endpoint for JetStream Bridge monitoring'
10
10
 
@@ -41,12 +41,12 @@ module JetstreamBridge
41
41
  "connected_at": "2025-11-22T20:00:00Z",
42
42
  "stream": {
43
43
  "exists": true,
44
- "name": "development-jetstream-bridge-stream",
45
- "subjects": ["dev.app1.sync.app2"],
44
+ "name": "jetstream-bridge-stream",
45
+ "subjects": ["app1.sync.app2"],
46
46
  "messages": 42
47
47
  },
48
48
  "config": {
49
- "env": "development",
49
+ "stream_name": "jetstream-bridge-stream",
50
50
  "app_name": "my_app",
51
51
  "destination_app": "other_app"
52
52
  },
@@ -4,7 +4,7 @@ require 'rails/generators'
4
4
 
5
5
  module JetstreamBridge
6
6
  module Generators
7
- class InitializerGenerator < Rails::Generators::Base
7
+ class InitializerGenerator < ::Rails::Generators::Base
8
8
  source_root File.expand_path('templates', __dir__)
9
9
  desc 'Creates config/initializers/jetstream_bridge.rb'
10
10
 
@@ -13,9 +13,8 @@ JetstreamBridge.configure do |config|
13
13
  # NATS server URLs (comma-separated for cluster)
14
14
  config.nats_urls = ENV.fetch('NATS_URLS', 'nats://localhost:4222')
15
15
 
16
- # Environment identifier (e.g., 'development', 'production')
17
- # Used in stream names and subject routing
18
- config.env = ENV.fetch('NATS_ENV', Rails.env)
16
+ # Stream name (required) - managed separately from runtime credentials
17
+ config.stream_name = ENV.fetch('JETSTREAM_STREAM_NAME', 'jetstream-bridge-stream')
19
18
 
20
19
  # Application name (used in subject routing)
21
20
  config.app_name = ENV.fetch('APP_NAME', Rails.application.class.module_parent_name.underscore)
@@ -5,16 +5,16 @@ require 'rails/generators'
5
5
  module JetstreamBridge
6
6
  module Generators
7
7
  # Install generator.
8
- class InstallGenerator < Rails::Generators::Base
8
+ class InstallGenerator < ::Rails::Generators::Base
9
9
  desc 'Creates JetstreamBridge initializer and migrations'
10
10
  def create_initializer
11
- Rails::Generators.invoke('jetstream_bridge:initializer', [], behavior: behavior,
12
- destination_root: destination_root)
11
+ ::Rails::Generators.invoke('jetstream_bridge:initializer', [], behavior: behavior,
12
+ destination_root: destination_root)
13
13
  end
14
14
 
15
15
  def create_migrations
16
- Rails::Generators.invoke('jetstream_bridge:migrations', [], behavior: behavior,
17
- destination_root: destination_root)
16
+ ::Rails::Generators.invoke('jetstream_bridge:migrations', [], behavior: behavior,
17
+ destination_root: destination_root)
18
18
  end
19
19
  end
20
20
  end
@@ -6,8 +6,8 @@ require 'rails/generators/active_record'
6
6
  module JetstreamBridge
7
7
  module Generators
8
8
  # Migrations generator.
9
- class MigrationsGenerator < Rails::Generators::Base
10
- include Rails::Generators::Migration
9
+ class MigrationsGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
11
 
12
12
  source_root File.expand_path('templates', __dir__)
13
13
  desc 'Creates Inbox/Outbox migrations for JetstreamBridge'
@@ -244,17 +244,16 @@ module JetstreamBridge
244
244
 
245
245
  private
246
246
 
247
- def ensure_destination_app_configured!
248
- return unless JetstreamBridge.config.destination_app.to_s.empty?
249
-
250
- raise ArgumentError, 'destination_app must be configured'
251
- end
252
-
253
247
  def ensure_subscription!
254
248
  @sub_mgr.ensure_consumer!
255
249
  @psub = @sub_mgr.subscribe!
256
250
  end
257
251
 
252
+ def ensure_destination_app_configured!
253
+ # Use subject builder to enforce required components and align with existing validation messages.
254
+ JetstreamBridge.config.destination_subject
255
+ end
256
+
258
257
  # Returns number of messages processed; 0 on timeout/idle or after recovery.
259
258
  def process_batch
260
259
  msgs = fetch_messages
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../core/logging'
4
4
  require_relative '../core/duration'
5
+ require_relative '../errors'
5
6
 
6
7
  module JetstreamBridge
7
8
  # Encapsulates durable ensure + subscribe for a pull consumer.
@@ -10,8 +11,7 @@ module JetstreamBridge
10
11
  @jts = jts
11
12
  @durable = durable
12
13
  @cfg = cfg
13
- @desired_cfg = build_consumer_config(@durable, filter_subject)
14
- @desired_cfg_norm = normalize_consumer_config(@desired_cfg)
14
+ @desired_cfg = build_consumer_config(@durable, filter_subject)
15
15
  end
16
16
 
17
17
  def stream_name
@@ -26,53 +26,69 @@ module JetstreamBridge
26
26
  @desired_cfg
27
27
  end
28
28
 
29
- def ensure_consumer!
30
- info = consumer_info_or_nil
31
- return create_consumer! unless info
32
-
33
- have_norm = normalize_consumer_config(info.config)
34
- if have_norm == @desired_cfg_norm
35
- log_consumer_ok
36
- else
37
- log_consumer_diff(have_norm)
38
- recreate_consumer!
29
+ def ensure_consumer!(force: false)
30
+ # Runtime path: never hit JetStream management APIs to avoid admin permissions.
31
+ unless force || @cfg.auto_provision
32
+ log_runtime_skip
33
+ return
39
34
  end
35
+
36
+ create_consumer!
40
37
  end
41
38
 
42
39
  # Bind a pull subscriber to the existing durable.
43
40
  def subscribe!
44
- @jts.pull_subscribe(
45
- filter_subject,
46
- @durable,
47
- stream: stream_name,
48
- config: desired_consumer_cfg
49
- )
50
- end
51
-
52
- private
53
-
54
- def consumer_info_or_nil
55
- @jts.consumer_info(stream_name, @durable)
56
- rescue NATS::JetStream::Error
57
- nil
58
- end
59
-
60
- # ---- comparison ----
61
-
62
- def log_consumer_diff(have_norm)
63
- want_norm = @desired_cfg_norm
41
+ # Always bypass consumer_info to avoid requiring JetStream API permissions at runtime.
42
+ subscribe_without_verification!
43
+ end
44
+
45
+ def subscribe_without_verification!
46
+ # Manually create a pull subscription without calling consumer_info
47
+ # This bypasses the permission check in nats-pure's pull_subscribe
48
+ nc = resolve_nc
49
+
50
+ if nc.respond_to?(:new_inbox) && nc.respond_to?(:subscribe)
51
+ prefix = @jts.instance_variable_get(:@prefix) || '$JS.API'
52
+ deliver = nc.new_inbox
53
+ sub = nc.subscribe(deliver)
54
+
55
+ # Extend with PullSubscription module to add fetch methods
56
+ sub.extend(NATS::JetStream::PullSubscription)
57
+
58
+ # Set up the JSI (JetStream Info) struct that PullSubscription expects
59
+ # This matches what nats-pure does in pull_subscribe
60
+ subject = "#{prefix}.CONSUMER.MSG.NEXT.#{stream_name}.#{@durable}"
61
+ sub.jsi = NATS::JetStream::JS::Sub.new(
62
+ js: @jts,
63
+ stream: stream_name,
64
+ consumer: @durable,
65
+ nms: subject
66
+ )
67
+
68
+ Logging.info(
69
+ "Created pull subscription without verification for consumer #{@durable} " \
70
+ "(stream=#{stream_name}, filter=#{filter_subject})",
71
+ tag: 'JetstreamBridge::Consumer'
72
+ )
73
+
74
+ return sub
75
+ end
64
76
 
65
- diffs = {}
66
- (have_norm.keys | want_norm.keys).each do |k|
67
- diffs[k] = { have: have_norm[k], want: want_norm[k] } unless have_norm[k] == want_norm[k]
77
+ # Fallback for environments (mocks/tests) where low-level NATS client is unavailable.
78
+ if @jts.respond_to?(:pull_subscribe)
79
+ Logging.info(
80
+ "Using pull_subscribe fallback for consumer #{@durable} (stream=#{stream_name})",
81
+ tag: 'JetstreamBridge::Consumer'
82
+ )
83
+ return @jts.pull_subscribe(filter_subject, @durable, stream: stream_name)
68
84
  end
69
85
 
70
- Logging.warn(
71
- "Consumer #{@durable} config mismatch (filter=#{filter_subject}) diff=#{diffs}",
72
- tag: 'JetstreamBridge::Consumer'
73
- )
86
+ raise JetstreamBridge::ConnectionError,
87
+ 'Unable to create subscription without verification: NATS client not available'
74
88
  end
75
89
 
90
+ private
91
+
76
92
  def build_consumer_config(durable, filter_subject)
77
93
  {
78
94
  durable_name: durable,
@@ -86,30 +102,6 @@ module JetstreamBridge
86
102
  }
87
103
  end
88
104
 
89
- # Normalize both server-returned config objects and our desired hash
90
- # into a common hash with consistent units/types for accurate comparison.
91
- def normalize_consumer_config(cfg)
92
- {
93
- filter_subject: sval(cfg, :filter_subject), # string
94
- ack_policy: sval(cfg, :ack_policy), # string
95
- deliver_policy: sval(cfg, :deliver_policy), # string
96
- 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
99
- }
100
- end
101
-
102
- # ---- lifecycle helpers ----
103
-
104
- def recreate_consumer!
105
- Logging.warn(
106
- "Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
107
- tag: 'JetstreamBridge::Consumer'
108
- )
109
- safe_delete_consumer
110
- create_consumer!
111
- end
112
-
113
105
  def create_consumer!
114
106
  @jts.add_consumer(stream_name, **desired_consumer_cfg)
115
107
  Logging.info(
@@ -118,22 +110,6 @@ module JetstreamBridge
118
110
  )
119
111
  end
120
112
 
121
- def log_consumer_ok
122
- Logging.info(
123
- "Consumer #{@durable} exists with desired config.",
124
- tag: 'JetstreamBridge::Consumer'
125
- )
126
- end
127
-
128
- def safe_delete_consumer
129
- @jts.delete_consumer(stream_name, @durable)
130
- rescue NATS::JetStream::Error => e
131
- Logging.warn(
132
- "Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
133
- tag: 'JetstreamBridge::Consumer'
134
- )
135
- end
136
-
137
113
  # ---- cfg access/normalization (struct-like or hash-like) ----
138
114
 
139
115
  def get(cfg, key)
@@ -200,5 +176,22 @@ module JetstreamBridge
200
176
  # Always round up to avoid zero-second waits when sub-second durations are provided.
201
177
  [(millis / 1000.0).ceil, 1].max
202
178
  end
179
+
180
+ def log_runtime_skip
181
+ Logging.info(
182
+ "Skipping consumer provisioning/verification for #{@durable} at runtime to avoid JetStream API usage. " \
183
+ 'Ensure it is pre-created via provisioning.',
184
+ tag: 'JetstreamBridge::Consumer'
185
+ )
186
+ end
187
+
188
+ def resolve_nc
189
+ return @jts.nc if @jts.respond_to?(:nc)
190
+ return @jts.instance_variable_get(:@nc) if @jts.instance_variable_defined?(:@nc)
191
+
192
+ return @cfg.mock_nats_client if @cfg.respond_to?(:mock_nats_client) && @cfg.mock_nats_client
193
+
194
+ nil
195
+ end
203
196
  end
204
197
  end
@@ -37,32 +37,17 @@ module JetstreamBridge
37
37
  end
38
38
 
39
39
  def fetch_stream_info
40
+ return skipped_stream_info unless config.auto_provision
41
+
40
42
  # Ensure we have an active connection before querying stream info
41
43
  connect_if_needed!
42
44
 
43
45
  jts = Connection.jetstream
44
46
  raise ConnectionNotEstablishedError, 'NATS connection not established' unless jts
45
47
 
46
- info = jts.stream_info(config.stream_name)
47
-
48
- # Handle both object-style and hash-style access for compatibility
49
- config_data = info.config
50
- state_data = info.state
51
- subjects = config_data.respond_to?(:subjects) ? config_data.subjects : config_data[:subjects]
52
- messages = state_data.respond_to?(:messages) ? state_data.messages : state_data[:messages]
53
-
54
- {
55
- exists: true,
56
- name: config.stream_name,
57
- subjects: subjects,
58
- messages: messages
59
- }
48
+ stream_info_payload(jts.stream_info(config.stream_name))
60
49
  rescue StandardError => e
61
- {
62
- exists: false,
63
- name: config.stream_name,
64
- error: "#{e.class}: #{e.message}"
65
- }
50
+ stream_error_payload(e)
66
51
  end
67
52
 
68
53
  def measure_nats_rtt
@@ -104,6 +89,39 @@ module JetstreamBridge
104
89
 
105
90
  cfg.public_send(setter, val)
106
91
  end
92
+
93
+ def stream_info_payload(info)
94
+ config_data = info.config
95
+ state_data = info.state
96
+
97
+ {
98
+ exists: true,
99
+ name: config.stream_name,
100
+ subjects: extract_field(config_data, :subjects),
101
+ messages: extract_field(state_data, :messages)
102
+ }
103
+ end
104
+
105
+ def extract_field(data, key)
106
+ data.respond_to?(key) ? data.public_send(key) : data[key]
107
+ end
108
+
109
+ def stream_error_payload(error)
110
+ {
111
+ exists: false,
112
+ name: config.stream_name,
113
+ error: "#{error.class}: #{error.message}"
114
+ }
115
+ end
116
+
117
+ def skipped_stream_info
118
+ {
119
+ exists: nil,
120
+ name: config.stream_name,
121
+ skipped: true,
122
+ reason: 'auto_provision=false (skip $JS.API.STREAM.INFO)'
123
+ }
124
+ end
107
125
  end
108
126
  end
109
127
  end
@@ -11,9 +11,9 @@ 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"
16
+ # config.stream_name = "jetstream-bridge-stream"
17
17
  # config.use_outbox = true
18
18
  # config.use_inbox = true
19
19
  # end
@@ -23,6 +23,7 @@ module JetstreamBridge
23
23
  # config.nats_urls = ENV["NATS_URLS"]
24
24
  # config.app_name = "api"
25
25
  # config.destination_app = "worker"
26
+ # config.stream_name = "jetstream-bridge-stream"
26
27
  # end
27
28
  #
28
29
  class Config
@@ -50,9 +51,9 @@ module JetstreamBridge
50
51
  # NATS server URL(s)
51
52
  # @return [String]
52
53
  attr_accessor :nats_urls
53
- # Environment namespace (development, staging, production)
54
+ # JetStream stream name (required)
54
55
  # @return [String]
55
- attr_accessor :env
56
+ attr_accessor :stream_name
56
57
  # Application name for subject routing
57
58
  # @return [String]
58
59
  attr_accessor :app_name
@@ -95,11 +96,15 @@ module JetstreamBridge
95
96
  # Enable lazy connection (connect on first use instead of during configure)
96
97
  # @return [Boolean]
97
98
  attr_accessor :lazy_connect
99
+ # Allow JetStream Bridge to create/update streams/consumers and call JetStream management APIs.
100
+ # Disable for locked-down environments and handle provisioning separately.
101
+ # @return [Boolean]
102
+ attr_accessor :auto_provision
98
103
 
99
104
  def initialize
100
105
  @nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
101
- @env = ENV['NATS_ENV'] || 'development'
102
- @app_name = ENV['APP_NAME'] || 'app'
106
+ @stream_name = ENV['JETSTREAM_STREAM_NAME'] || 'jetstream-bridge-stream'
107
+ @app_name = ENV['APP_NAME'] || 'app'
103
108
  @destination_app = ENV.fetch('DESTINATION_APP', nil)
104
109
 
105
110
  @max_deliver = 5
@@ -118,6 +123,7 @@ module JetstreamBridge
118
123
  @connect_retry_attempts = 3
119
124
  @connect_retry_delay = 2
120
125
  @lazy_connect = false
126
+ @auto_provision = true
121
127
  end
122
128
 
123
129
  # Apply a configuration preset
@@ -131,34 +137,22 @@ module JetstreamBridge
131
137
  self
132
138
  end
133
139
 
134
- # Get the JetStream stream name for this environment.
135
- #
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"
140
- def stream_name
141
- "#{env}-jetstream-bridge-stream"
142
- end
143
-
144
140
  # Get the NATS subject this application publishes to.
145
141
  #
146
- # Producer publishes to: {env}.{app}.sync.{dest}
147
- # Consumer subscribes to: {env}.{dest}.sync.{app}
142
+ # Producer publishes to: {app}.sync.{dest}
143
+ # Consumer subscribes to: {dest}.sync.{app}
148
144
  #
149
145
  # @return [String] Source subject for publishing
150
146
  # @raise [InvalidSubjectError] If components contain NATS wildcards
151
147
  # @raise [MissingConfigurationError] If required components empty
152
148
  # @example
153
- # config.env = "production"
154
149
  # config.app_name = "api"
155
150
  # config.destination_app = "worker"
156
- # config.source_subject # => "production.api.sync.worker"
151
+ # config.source_subject # => "api.sync.worker"
157
152
  def source_subject
158
- validate_subject_component!(env, 'env')
159
153
  validate_subject_component!(app_name, 'app_name')
160
154
  validate_subject_component!(destination_app, 'destination_app')
161
- "#{env}.#{app_name}.sync.#{destination_app}"
155
+ "#{app_name}.sync.#{destination_app}"
162
156
  end
163
157
 
164
158
  # Get the NATS subject this application subscribes to.
@@ -166,44 +160,28 @@ module JetstreamBridge
166
160
  # @return [String] Destination subject for consuming
167
161
  # @raise [InvalidSubjectError] If components contain NATS wildcards
168
162
  # @raise [MissingConfigurationError] If required components empty
169
- # @example
170
- # config.env = "production"
171
- # config.app_name = "api"
172
- # config.destination_app = "worker"
173
- # config.destination_subject # => "production.worker.sync.api"
174
163
  def destination_subject
175
- validate_subject_component!(env, 'env')
176
164
  validate_subject_component!(app_name, 'app_name')
177
165
  validate_subject_component!(destination_app, 'destination_app')
178
- "#{env}.#{destination_app}.sync.#{app_name}"
166
+ "#{destination_app}.sync.#{app_name}"
179
167
  end
180
168
 
181
169
  # Get the dead letter queue subject for this application.
182
170
  #
183
171
  # Each app has its own DLQ for better isolation and monitoring.
184
172
  #
185
- # @return [String] DLQ subject in format "{env}.{app_name}.sync.dlq"
173
+ # @return [String] DLQ subject in format "{app_name}.sync.dlq"
186
174
  # @raise [InvalidSubjectError] If components contain NATS wildcards
187
175
  # @raise [MissingConfigurationError] If required components are empty
188
- # @example
189
- # config.env = "production"
190
- # config.app_name = "api"
191
- # config.dlq_subject # => "production.api.sync.dlq"
192
176
  def dlq_subject
193
- validate_subject_component!(env, 'env')
194
177
  validate_subject_component!(app_name, 'app_name')
195
- "#{env}.#{app_name}.sync.dlq"
178
+ "#{app_name}.sync.dlq"
196
179
  end
197
180
 
198
181
  # Get the durable consumer name for this application.
199
182
  #
200
- # @return [String] Durable name in format "{env}-{app_name}-workers"
201
- # @example
202
- # config.env = "production"
203
- # config.app_name = "api"
204
- # config.durable_name # => "production-api-workers"
205
183
  def durable_name
206
- "#{env}-#{app_name}-workers"
184
+ "#{app_name}-workers"
207
185
  end
208
186
 
209
187
  # Validate all configuration settings.
@@ -219,7 +197,7 @@ module JetstreamBridge
219
197
  errors = []
220
198
  errors << 'destination_app is required' if destination_app.to_s.strip.empty?
221
199
  errors << 'nats_urls is required' if nats_urls.to_s.strip.empty?
222
- errors << 'env is required' if env.to_s.strip.empty?
200
+ errors << 'stream_name is required' if stream_name.to_s.strip.empty?
223
201
  errors << 'app_name is required' if app_name.to_s.strip.empty?
224
202
  errors << 'max_deliver must be >= 1' if max_deliver.to_i < 1
225
203
  errors << 'backoff must be an array' unless backoff.is_a?(Array)