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
@@ -2,15 +2,13 @@
2
2
 
3
3
  require_relative 'jetstream_bridge/version'
4
4
  require_relative 'jetstream_bridge/core'
5
- require_relative 'jetstream_bridge/core/connection_manager'
6
- require_relative 'jetstream_bridge/publisher/event_envelope_builder'
7
5
  require_relative 'jetstream_bridge/publisher/publisher'
8
6
  require_relative 'jetstream_bridge/publisher/batch_publisher'
9
7
  require_relative 'jetstream_bridge/consumer/consumer'
10
8
  require_relative 'jetstream_bridge/consumer/middleware'
11
9
  require_relative 'jetstream_bridge/models/publish_result'
12
10
  require_relative 'jetstream_bridge/models/event'
13
- require_relative 'jetstream_bridge/facade'
11
+ require_relative 'jetstream_bridge/provisioner'
14
12
 
15
13
  # Rails-specific entry point (lifecycle helpers + Railtie)
16
14
  require_relative 'jetstream_bridge/rails' if defined?(Rails::Railtie)
@@ -21,19 +19,30 @@ require_relative 'jetstream_bridge/models/outbox_event'
21
19
 
22
20
  # JetStream Bridge - Production-safe realtime data bridge using NATS JetStream.
23
21
  #
22
+ # JetStream Bridge provides a reliable, production-ready way to publish and consume
23
+ # events using NATS JetStream with features like:
24
+ #
25
+ # - Transactional Outbox pattern for guaranteed event publishing
26
+ # - Idempotent Inbox pattern for exactly-once message processing
27
+ # - Dead Letter Queue (DLQ) for poison message handling
28
+ # - Automatic stream provisioning and overlap detection
29
+ # - Built-in health checks and monitoring
30
+ # - Middleware support for cross-cutting concerns
31
+ # - Rails integration with generators and migrations
32
+ # - Graceful startup/shutdown lifecycle management
33
+ #
24
34
  # @example Quick start
25
35
  # # Configure
26
36
  # JetstreamBridge.configure do |config|
27
37
  # config.nats_urls = "nats://localhost:4222"
28
38
  # config.app_name = "my_app"
29
39
  # config.destination_app = "other_app"
30
- # config.stream_name = "MY_STREAM"
31
40
  # config.use_outbox = true
32
41
  # config.use_inbox = true
33
42
  # end
34
43
  #
35
- # # Connect (validates config and establishes connection)
36
- # JetstreamBridge.connect!
44
+ # # Explicitly start connection (or use Rails railtie for automatic startup)
45
+ # JetstreamBridge.startup!
37
46
  #
38
47
  # # Publish events
39
48
  # JetstreamBridge.publish(
@@ -48,282 +57,416 @@ require_relative 'jetstream_bridge/models/outbox_event'
48
57
  # consumer.run!
49
58
  #
50
59
  # # Graceful shutdown
51
- # at_exit { JetstreamBridge.disconnect! }
60
+ # at_exit { JetstreamBridge.shutdown! }
61
+ #
62
+ # @see Publisher For publishing events
63
+ # @see Consumer For consuming events
64
+ # @see Config For configuration options
65
+ # @see TestHelpers For testing utilities
52
66
  #
53
67
  module JetstreamBridge
54
68
  class << self
55
- # Get configuration instance
56
- #
57
- # @return [Config] Configuration object
69
+ include Core::BridgeHelpers
70
+
58
71
  def config
59
- facade.config
72
+ @config ||= Config.new
60
73
  end
61
74
 
62
75
  # Configure JetStream Bridge settings
63
76
  #
64
- # @yield [Config] Configuration object for block-based configuration
65
- # @return [Config] The configured instance
77
+ # This method sets configuration WITHOUT automatically establishing a connection.
78
+ # Connection must be established explicitly via startup! or will be established
79
+ # automatically on first use (publish/subscribe) or via Rails railtie initialization.
66
80
  #
67
- # @example
81
+ # @example Basic configuration
68
82
  # JetstreamBridge.configure do |config|
69
83
  # config.nats_urls = "nats://localhost:4222"
70
84
  # config.app_name = "my_app"
71
85
  # config.destination_app = "worker"
72
- # config.stream_name = "MY_STREAM"
73
86
  # end
74
- def configure(&)
75
- facade.configure(&)
87
+ # JetstreamBridge.startup! # Explicitly start connection
88
+ #
89
+ # @example With hash overrides
90
+ # JetstreamBridge.configure(app_name: 'my_app')
91
+ #
92
+ # @param overrides [Hash] Configuration key-value pairs to set
93
+ # @yield [Config] Configuration object for block-based configuration
94
+ # @return [Config] The configured instance
95
+ def configure(overrides = {}, **extra_overrides)
96
+ # Merge extra keyword arguments into overrides hash
97
+ all_overrides = overrides.nil? ? extra_overrides : overrides.merge(extra_overrides)
98
+
99
+ cfg = config
100
+ all_overrides.each { |k, v| assign_config_option!(cfg, k, v) } unless all_overrides.empty?
101
+ yield(cfg) if block_given?
102
+
103
+ cfg
76
104
  end
77
105
 
78
- # Connect to NATS and ensure stream topology
106
+ # Configure with a preset
79
107
  #
80
- # Validates configuration and establishes connection.
81
- # Idempotent - safe to call multiple times.
82
- #
83
- # @return [void]
84
- # @raise [ConfigurationError] If configuration is invalid
85
- # @raise [ConnectionError] If unable to connect
108
+ # This method applies a configuration preset. Connection must be
109
+ # established separately via startup! or via Rails railtie.
86
110
  #
87
111
  # @example
88
- # JetstreamBridge.connect!
89
- def connect!
90
- facade.connect!
112
+ # JetstreamBridge.configure_for(:production) do |config|
113
+ # config.nats_urls = ENV["NATS_URLS"]
114
+ # config.app_name = "my_app"
115
+ # config.destination_app = "worker"
116
+ # end
117
+ # JetstreamBridge.startup! # Explicitly start connection
118
+ #
119
+ # @param preset [Symbol] Preset name (:development, :test, :production, etc.)
120
+ # @yield [Config] Configuration object
121
+ # @return [Config] Configured instance
122
+ def configure_for(preset)
123
+ configure do |cfg|
124
+ cfg.apply_preset(preset)
125
+ yield(cfg) if block_given?
126
+ end
91
127
  end
92
128
 
93
- # Disconnect from NATS
129
+ def reset!
130
+ @config = nil
131
+ @connection_initialized = false
132
+ end
133
+
134
+ # Initialize the JetStream Bridge connection and topology
94
135
  #
95
- # Closes the NATS connection and cleans up resources.
136
+ # This method can be called explicitly if needed. It's idempotent and safe to call multiple times.
96
137
  #
97
138
  # @return [void]
98
- #
99
- # @example
100
- # at_exit { JetstreamBridge.disconnect! }
101
- def disconnect!
102
- facade.disconnect!
139
+ def startup!
140
+ return if @connection_initialized
141
+
142
+ config.validate!
143
+ connect_and_ensure_stream!
144
+ @connection_initialized = true
145
+ Logging.info('JetStream Bridge started successfully', tag: 'JetstreamBridge')
103
146
  end
104
147
 
105
- # Reconnect to NATS (disconnect + connect)
148
+ # Reconnect to NATS
106
149
  #
107
- # Useful for:
150
+ # Closes existing connection and establishes a new one. Useful for:
108
151
  # - Forking web servers (Puma, Unicorn) after worker boot
109
152
  # - Recovering from connection issues
153
+ # - Configuration changes that require reconnection
110
154
  #
111
- # @return [void]
112
- #
113
- # @example In Puma configuration
155
+ # @example In Puma configuration (config/puma.rb)
114
156
  # on_worker_boot do
115
157
  # JetstreamBridge.reconnect! if defined?(JetstreamBridge)
116
158
  # end
159
+ #
160
+ # @return [void]
161
+ # @raise [ConnectionError] If unable to reconnect to NATS
117
162
  def reconnect!
118
- facade.reconnect!
163
+ Logging.info('Reconnecting to NATS...', tag: 'JetstreamBridge')
164
+ shutdown! if @connection_initialized
165
+ startup!
119
166
  end
120
167
 
121
- # Publish an event
122
- #
123
- # Simplified API with single pattern:
124
- # - event_type: required (e.g., "user.created")
125
- # - payload: required event data
126
- # - resource_type: optional (inferred from event_type if dotted notation)
127
- # - All other fields are optional
168
+ # Gracefully shutdown the JetStream Bridge connection
128
169
  #
129
- # @param event_type [String] Event type (required)
130
- # @param payload [Hash] Event payload data (required)
131
- # @param resource_type [String, nil] Resource type (optional, inferred if nil)
132
- # @param subject [String, nil] Optional NATS subject override
133
- # @param options [Hash] Additional options (event_id, occurred_at, trace_id, etc.)
134
- # @return [Models::PublishResult] Result object with success status and metadata
170
+ # Closes the NATS connection and cleans up resources. Should be called
171
+ # during application shutdown (e.g., in at_exit or signal handlers).
135
172
  #
136
- # @example Basic publishing
137
- # result = JetstreamBridge.publish(
138
- # event_type: "user.created",
139
- # payload: { id: 1, email: "ada@example.com" }
140
- # )
141
- # puts "Success!" if result.success?
142
- #
143
- # @example With options
144
- # result = JetstreamBridge.publish(
145
- # event_type: "order.updated",
146
- # payload: { id: 123, status: "shipped" },
147
- # resource_type: "order",
148
- # trace_id: request_id
149
- # )
150
- def publish(event_type:, payload:, **)
151
- facade.publish(event_type: event_type, payload: payload, **)
173
+ # @return [void]
174
+ def shutdown!
175
+ return unless @connection_initialized
176
+
177
+ begin
178
+ nc = Connection.nc
179
+ nc&.close if nc&.connected?
180
+ Logging.info('JetStream Bridge shut down gracefully', tag: 'JetstreamBridge')
181
+ rescue StandardError => e
182
+ Logging.error("Error during shutdown: #{e.message}", tag: 'JetstreamBridge')
183
+ ensure
184
+ @connection_initialized = false
185
+ end
152
186
  end
153
187
 
154
- # Publish variant that raises on error
155
- #
156
- # @param (see #publish)
157
- # @return [Models::PublishResult] Result object
158
- # @raise [PublishError] If publishing fails
159
- #
160
- # @example
161
- # JetstreamBridge.publish!(event_type: "user.created", payload: { id: 1 })
162
- def publish!(event_type:, payload:, **)
163
- result = publish(event_type: event_type, payload: payload, **)
164
- if result.failure?
165
- raise PublishError.new(result.error&.message, event_id: result.event_id, subject: result.subject)
188
+ def use_outbox?
189
+ config.use_outbox
190
+ end
191
+
192
+ def use_inbox?
193
+ config.use_inbox
194
+ end
195
+
196
+ def use_dlq?
197
+ config.use_dlq
198
+ end
199
+
200
+ # Establishes a connection and ensures stream topology.
201
+ #
202
+ # @return [Object] JetStream context
203
+ def connect_and_ensure_stream!
204
+ config.validate!
205
+ provision = config.auto_provision
206
+ Connection.connect!(verify_js: provision)
207
+ jts = Connection.jetstream
208
+ raise ConnectionNotEstablishedError, 'JetStream connection not available' unless jts
209
+
210
+ if provision
211
+ Provisioner.new(config: config).ensure_stream!(jts: jts)
212
+ else
213
+ Logging.info(
214
+ 'auto_provision=false: skipping stream provisioning and JetStream account_info. ' \
215
+ 'Run `bundle exec rake jetstream_bridge:provision` with admin credentials to create/update topology.',
216
+ tag: 'JetstreamBridge'
217
+ )
166
218
  end
167
219
 
168
- result
220
+ jts
169
221
  end
170
222
 
171
- # Publish a complete event envelope (advanced usage)
172
- #
173
- # Provides full control over the envelope structure. Use this when you need to
174
- # manually construct the envelope or preserve all fields from an external source.
175
- #
176
- # @param envelope [Hash] Complete event envelope with all required fields
177
- # @param subject [String, nil] Optional NATS subject override
178
- # @return [Models::PublishResult] Result object
223
+ # Provision stream/consumer using management credentials (out of band from runtime).
179
224
  #
180
- # @raise [ArgumentError] If required envelope fields are missing
181
- #
182
- # @example Publishing a complete envelope
183
- # envelope = {
184
- # 'event_id' => SecureRandom.uuid,
185
- # 'schema_version' => 1,
186
- # 'event_type' => 'user.created',
187
- # 'producer' => 'custom-producer',
188
- # 'resource_type' => 'user',
189
- # 'resource_id' => '123',
190
- # 'occurred_at' => Time.now.utc.iso8601,
191
- # 'trace_id' => 'trace-123',
192
- # 'payload' => { id: 123, name: 'Alice' }
193
- # }
194
- #
195
- # result = JetstreamBridge.publish_envelope(envelope)
196
- # puts "Published: #{result.event_id}"
197
- #
198
- # @example Forwarding events from another system
199
- # # Receive event from external system
200
- # external_event = external_api.get_event
201
- #
202
- # # Publish as-is, preserving all metadata
203
- # JetstreamBridge.publish_envelope(external_event)
204
- def publish_envelope(envelope, subject: nil)
205
- facade.publish_envelope(envelope, subject: subject)
225
+ # @param ensure_consumer [Boolean] Whether to create/align the consumer along with the stream.
226
+ # @return [Object] JetStream context
227
+ def provision!(ensure_consumer: true)
228
+ config.validate!
229
+ Provisioner.new(config: config).ensure!(ensure_consumer: ensure_consumer)
206
230
  end
207
231
 
208
- # Subscribe to events
232
+ # Backwards-compatible alias for the previous method name
233
+ def ensure_topology!
234
+ connect_and_ensure_stream!
235
+ end
236
+
237
+ # Active health check for monitoring and readiness probes
209
238
  #
210
- # @param handler [Proc, #call, nil] Message handler (optional if block given)
211
- # @param durable_name [String, nil] Optional durable consumer name override
212
- # @param batch_size [Integer, nil] Optional batch size override
213
- # @yield [event] Yields Models::Event object to block
214
- # @return [Consumer] Consumer instance (call run! to start processing)
239
+ # Performs actual operations to verify system health:
240
+ # - Checks NATS connection (active: calls account_info API)
241
+ # - Verifies stream exists and is accessible (active: queries stream info)
242
+ # - Tests NATS round-trip communication (active: RTT measurement)
215
243
  #
216
- # @example With block
217
- # consumer = JetstreamBridge.subscribe do |event|
218
- # puts "Received: #{event.type} - #{event.payload.to_h}"
219
- # end
220
- # consumer.run! # Blocking
221
- #
222
- # @example With handler
223
- # handler = ->(event) { EventProcessor.process(event) }
224
- # consumer = JetstreamBridge.subscribe(handler)
225
- # consumer.run!
226
- def subscribe(handler = nil, durable_name: nil, batch_size: nil, &block)
227
- raise ArgumentError, 'Handler or block required' unless handler || block
228
-
229
- facade.subscribe(
230
- handler || block,
231
- durable_name: durable_name,
232
- batch_size: batch_size
233
- )
244
+ # Rate Limiting: To prevent abuse, uncached health checks are limited to once every 5 seconds.
245
+ # Cached results (within 30s TTL) bypass this limit via Connection.instance.connected?.
246
+ #
247
+ # @param skip_cache [Boolean] Force fresh health check, bypass connection cache (rate limited)
248
+ # @return [Hash] Health status including NATS connection, stream, and version
249
+ # @raise [HealthCheckFailedError] If skip_cache requested too frequently
250
+ def health_check(skip_cache: false)
251
+ # Rate limit uncached requests to prevent abuse (max 1 per 5 seconds)
252
+ enforce_health_check_rate_limit! if skip_cache
253
+
254
+ start_time = Time.now
255
+ conn_status = connection_snapshot(Connection.instance, skip_cache: skip_cache)
256
+ stream_info = stream_status(conn_status[:connected])
257
+ rtt_ms = measure_nats_rtt if conn_status[:connected]
258
+ health_check_duration_ms = elapsed_ms(start_time)
259
+
260
+ {
261
+ healthy: health_flag(conn_status[:connected], stream_info),
262
+ connection: connection_payload(conn_status),
263
+ stream: stream_info,
264
+ performance: {
265
+ nats_rtt_ms: rtt_ms,
266
+ health_check_duration_ms: health_check_duration_ms
267
+ },
268
+ config: config_summary,
269
+ version: JetstreamBridge::VERSION
270
+ }
271
+ rescue StandardError => e
272
+ {
273
+ healthy: false,
274
+ connection: {
275
+ state: :failed,
276
+ connected: false
277
+ },
278
+ error: "#{e.class}: #{e.message}"
279
+ }
234
280
  end
235
281
 
236
282
  # Check if connected to NATS
237
283
  #
238
- # @param skip_cache [Boolean] Force fresh health check
239
284
  # @return [Boolean] true if connected and healthy
285
+ def connected?
286
+ Connection.instance.connected?
287
+ rescue StandardError
288
+ false
289
+ end
290
+
291
+ # Get stream information for the configured stream
240
292
  #
241
- # @example
242
- # if JetstreamBridge.connected?
243
- # puts "Ready to publish"
244
- # end
245
- def connected?(skip_cache: false)
246
- facade.connected?(skip_cache: skip_cache)
293
+ # @return [Hash] Stream information including subjects and message count
294
+ def stream_info
295
+ fetch_stream_info
247
296
  end
248
297
 
249
- # Get comprehensive health status (primary method)
298
+ # Convenience method to publish events
250
299
  #
251
- # Provides complete system health including connection state, stream info,
252
- # performance metrics, and configuration. Use this for monitoring and
253
- # readiness probes.
300
+ # Automatically establishes connection on first use if not already connected.
254
301
  #
255
- # Rate limited to once every 5 seconds for uncached checks.
302
+ # Supports three usage patterns:
256
303
  #
257
- # @param skip_cache [Boolean] Force fresh health check (rate limited)
258
- # @return [Hash] Health status including connection, stream, performance, config, and version
304
+ # 1. Structured parameters (recommended):
305
+ # JetstreamBridge.publish(resource_type: 'user', event_type: 'created', payload: { id: 1, name: 'Ada' })
259
306
  #
260
- # @example Basic health check
261
- # health = JetstreamBridge.health
262
- # puts "Healthy: #{health[:healthy]}"
263
- # puts "State: #{health[:connection][:state]}"
264
- # puts "RTT: #{health[:performance][:nats_rtt_ms]}ms"
307
+ # 2. Simplified hash (infers resource_type from event_type):
308
+ # JetstreamBridge.publish(event_type: 'user.created', payload: { id: 1, name: 'Ada' })
265
309
  #
266
- # @example Kubernetes readiness probe
267
- # def ready
268
- # health = JetstreamBridge.health
269
- # if health[:healthy]
270
- # render json: health, status: :ok
271
- # else
272
- # render json: health, status: :service_unavailable
273
- # end
310
+ # 3. Complete envelope (advanced):
311
+ # JetstreamBridge.publish({ event_type: 'created', resource_type: 'user', payload: {...}, event_id: '...' })
312
+ #
313
+ # @param event_or_hash [Hash, nil] Event hash or first positional argument
314
+ # @param resource_type [String, nil] Resource type (e.g., 'user', 'order')
315
+ # @param event_type [String, nil] Event type (e.g., 'created', 'updated', 'user.created')
316
+ # @param payload [Hash, nil] Event payload data
317
+ # @param subject [String, nil] Optional subject override
318
+ # @param options [Hash] Additional options (event_id, occurred_at, trace_id)
319
+ # @return [Models::PublishResult] Result object with success status and metadata
320
+ #
321
+ # @example Check result status
322
+ # result = JetstreamBridge.publish(event_type: "user.created", payload: { id: 1 })
323
+ # if result.success?
324
+ # puts "Published event #{result.event_id}"
325
+ # else
326
+ # logger.error("Publish failed: #{result.error}")
274
327
  # end
275
- def health(skip_cache: false)
276
- facade.health(skip_cache: skip_cache)
328
+ def publish(event_or_hash = nil, resource_type: nil, event_type: nil, payload: nil, subject: nil, **)
329
+ connect_if_needed!
330
+ publisher = Publisher.new
331
+ publisher.publish(event_or_hash, resource_type: resource_type, event_type: event_type, payload: payload,
332
+ subject: subject, **)
277
333
  end
278
334
 
279
- # Backward compatibility alias for health
335
+ # Publish variant that raises on error
280
336
  #
281
- # @deprecated Use {#health} instead
282
- # @param skip_cache [Boolean] Force fresh health check
283
- # @return [Hash] Health status
284
- def health_check(skip_cache: false)
285
- health(skip_cache: skip_cache)
337
+ # @example
338
+ # JetstreamBridge.publish!(event_type: "user.created", payload: { id: 1 })
339
+ # # Raises PublishError if publishing fails
340
+ #
341
+ # @param (see #publish)
342
+ # @return [Models::PublishResult] Result object
343
+ # @raise [PublishError] If publishing fails
344
+ def publish!(...)
345
+ result = publish(...)
346
+ if result.failure?
347
+ raise PublishError.new(result.error&.message, event_id: result.event_id,
348
+ subject: result.subject)
349
+ end
350
+
351
+ result
286
352
  end
287
353
 
288
- # Check if system is healthy (convenience method)
289
- #
290
- # Returns a simple boolean indicating overall health.
291
- # Equivalent to `health[:healthy]` but more convenient.
292
- #
293
- # @return [Boolean] true if connected and stream exists
354
+ # Batch publish multiple events efficiently
294
355
  #
295
356
  # @example
296
- # if JetstreamBridge.healthy?
297
- # JetstreamBridge.publish(...)
298
- # else
299
- # logger.warn "JetStream not healthy, skipping publish"
357
+ # results = JetstreamBridge.publish_batch do |batch|
358
+ # users.each do |user|
359
+ # batch.add(event_type: "user.created", payload: { id: user.id })
360
+ # end
300
361
  # end
301
- def healthy?
302
- facade.healthy?
362
+ # puts "Success: #{results.successful_count}, Failed: #{results.failed_count}"
363
+ #
364
+ # @yield [BatchPublisher] Batch publisher instance
365
+ # @return [BatchPublisher::BatchResult] Result with success/failure counts
366
+ def publish_batch
367
+ batch = BatchPublisher.new
368
+ yield(batch) if block_given?
369
+ batch.publish
303
370
  end
304
371
 
305
- # Get stream information
372
+ # Convenience method to start consuming messages
306
373
  #
307
- # @return [Hash] Stream information including subjects and message count
374
+ # Automatically establishes connection on first use if not already connected.
308
375
  #
309
- # @example
310
- # info = JetstreamBridge.stream_info
311
- # puts "Messages: #{info[:messages]}"
312
- def stream_info
313
- facade.stream_info
314
- end
315
-
316
- # Reset facade (for testing)
376
+ # Supports two usage patterns:
317
377
  #
318
- # @api private
319
- def reset!
320
- @facade = nil
378
+ # 1. With a block (recommended):
379
+ # consumer = JetstreamBridge.subscribe do |event|
380
+ # puts "Received: #{event.type} on #{event.subject} (attempt #{event.deliveries})"
381
+ # end
382
+ # consumer.run!
383
+ #
384
+ # 2. With auto-run (returns Thread):
385
+ # thread = JetstreamBridge.subscribe(run: true) do |event|
386
+ # puts "Received: #{event.type}"
387
+ # end
388
+ # thread.join # Wait for consumer to finish
389
+ #
390
+ # 3. With a handler object:
391
+ # handler = ->(event) { puts event.type }
392
+ # consumer = JetstreamBridge.subscribe(handler)
393
+ # consumer.run!
394
+ #
395
+ # @param handler [Proc, #call, nil] Message handler (optional if block given)
396
+ # @param run [Boolean] If true, automatically runs consumer in a background thread
397
+ # @param durable_name [String, nil] Optional durable consumer name override
398
+ # @param batch_size [Integer, nil] Optional batch size override
399
+ # @yield [event] Yields Models::Event object to block
400
+ # @return [Consumer, Thread] Consumer instance or Thread if run: true
401
+ def subscribe(handler = nil, run: false, durable_name: nil, batch_size: nil, &block)
402
+ connect_if_needed!
403
+ handler ||= block
404
+ raise ArgumentError, 'Handler or block required' unless handler
405
+
406
+ consumer = Consumer.new(handler, durable_name: durable_name, batch_size: batch_size)
407
+
408
+ if run
409
+ thread = Thread.new { consumer.run! }
410
+ thread.abort_on_exception = true
411
+ thread
412
+ else
413
+ consumer
414
+ end
321
415
  end
322
416
 
323
417
  private
324
418
 
325
- def facade
326
- @facade ||= Facade.new
419
+ def connection_snapshot(conn_instance, skip_cache:)
420
+ {
421
+ connected: conn_instance.connected?(skip_cache: skip_cache),
422
+ connected_at: conn_instance.connected_at,
423
+ state: conn_instance.state,
424
+ last_error: conn_instance.last_reconnect_error,
425
+ last_error_at: conn_instance.last_reconnect_error_at
426
+ }
427
+ end
428
+
429
+ def stream_status(connected)
430
+ return stream_missing unless connected
431
+ return skipped_stream_info unless config.auto_provision
432
+
433
+ fetch_stream_info
434
+ end
435
+
436
+ def stream_missing
437
+ { exists: false, name: config.stream_name }
438
+ end
439
+
440
+ def health_flag(connected, stream_info)
441
+ return connected unless config.auto_provision
442
+
443
+ connected && stream_info&.fetch(:exists, false)
444
+ end
445
+
446
+ def connection_payload(status)
447
+ {
448
+ state: status[:state],
449
+ connected: status[:connected],
450
+ connected_at: status[:connected_at]&.iso8601,
451
+ last_error: status[:last_error]&.message,
452
+ last_error_at: status[:last_error_at]&.iso8601
453
+ }
454
+ end
455
+
456
+ def config_summary
457
+ {
458
+ app_name: config.app_name,
459
+ destination_app: config.destination_app,
460
+ stream_name: config.stream_name,
461
+ auto_provision: config.auto_provision,
462
+ use_outbox: config.use_outbox,
463
+ use_inbox: config.use_inbox,
464
+ use_dlq: config.use_dlq
465
+ }
466
+ end
467
+
468
+ def elapsed_ms(start_time)
469
+ ((Time.now - start_time) * 1000).round(2)
327
470
  end
328
471
  end
329
472
  end