jetstream_bridge 3.0.1 → 4.0.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.
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ # Configuration presets for common scenarios
5
+ module ConfigPreset
6
+ # Development preset: minimal features for fast local development
7
+ #
8
+ # @param config [Config] Configuration object to apply preset to
9
+ def self.development(config)
10
+ config.use_outbox = false
11
+ config.use_inbox = false
12
+ config.use_dlq = false
13
+ config.max_deliver = 3
14
+ config.ack_wait = '10s'
15
+ config.backoff = %w[1s 2s 5s]
16
+ end
17
+
18
+ # Test preset: similar to development but with synchronous behavior
19
+ #
20
+ # @param config [Config] Configuration object to apply preset to
21
+ def self.test(config)
22
+ config.use_outbox = false
23
+ config.use_inbox = false
24
+ config.use_dlq = false
25
+ config.max_deliver = 2
26
+ config.ack_wait = '5s'
27
+ config.backoff = %w[0.1s 0.5s]
28
+ end
29
+
30
+ # Production preset: all reliability features enabled
31
+ #
32
+ # @param config [Config] Configuration object to apply preset to
33
+ def self.production(config)
34
+ config.use_outbox = true
35
+ config.use_inbox = true
36
+ config.use_dlq = true
37
+ config.max_deliver = 5
38
+ config.ack_wait = '30s'
39
+ config.backoff = %w[1s 5s 15s 30s 60s]
40
+ end
41
+
42
+ # Staging preset: production-like but with faster retries
43
+ #
44
+ # @param config [Config] Configuration object to apply preset to
45
+ def self.staging(config)
46
+ config.use_outbox = true
47
+ config.use_inbox = true
48
+ config.use_dlq = true
49
+ config.max_deliver = 3
50
+ config.ack_wait = '15s'
51
+ config.backoff = %w[1s 5s 15s]
52
+ end
53
+
54
+ # High throughput preset: optimized for volume over reliability
55
+ #
56
+ # @param config [Config] Configuration object to apply preset to
57
+ def self.high_throughput(config)
58
+ config.use_outbox = false # Skip DB writes
59
+ config.use_inbox = false # Skip deduplication
60
+ config.use_dlq = true # But keep DLQ for visibility
61
+ config.max_deliver = 3
62
+ config.ack_wait = '10s'
63
+ config.backoff = %w[1s 2s 5s]
64
+ end
65
+
66
+ # Maximum reliability preset: every safety feature enabled
67
+ #
68
+ # @param config [Config] Configuration object to apply preset to
69
+ def self.maximum_reliability(config)
70
+ config.use_outbox = true
71
+ config.use_inbox = true
72
+ config.use_dlq = true
73
+ config.max_deliver = 10
74
+ config.ack_wait = '60s'
75
+ config.backoff = %w[1s 5s 15s 30s 60s 120s 300s 600s 1200s 1800s]
76
+ end
77
+
78
+ # Get available preset names
79
+ #
80
+ # @return [Array<Symbol>] List of available preset names
81
+ def self.available_presets
82
+ [:development, :test, :production, :staging, :high_throughput, :maximum_reliability]
83
+ end
84
+
85
+ # Apply a preset by name
86
+ #
87
+ # @param config [Config] Configuration object
88
+ # @param preset_name [Symbol, String] Name of preset to apply
89
+ # @raise [ArgumentError] If preset name is unknown
90
+ def self.apply(config, preset_name)
91
+ preset_method = preset_name.to_sym
92
+ unless available_presets.include?(preset_method)
93
+ raise ArgumentError, "Unknown preset: #{preset_name}. Available: #{available_presets.join(', ')}"
94
+ end
95
+
96
+ public_send(preset_method, config)
97
+ end
98
+ end
99
+ end
@@ -81,7 +81,10 @@ module JetstreamBridge
81
81
  # Public API for checking connection status
82
82
  # @return [Boolean] true if NATS client is connected and JetStream is healthy
83
83
  def connected?
84
- @nc&.connected? && @jts && jetstream_healthy?
84
+ return false unless @nc&.connected?
85
+ return false unless @jts
86
+
87
+ jetstream_healthy?
85
88
  end
86
89
 
87
90
  # Public API for getting connection timestamp
@@ -141,7 +144,7 @@ module JetstreamBridge
141
144
  # Create JetStream context
142
145
  @jts = @nc.jetstream
143
146
 
144
- # --- Compatibility shim: ensure JetStream responds to #nc for older/newer clients ---
147
+ # Ensure JetStream responds to #nc
145
148
  return if @jts.respond_to?(:nc)
146
149
 
147
150
  nc_ref = @nc
@@ -89,7 +89,7 @@ module JetstreamBridge
89
89
  def create_jetstream(client)
90
90
  jts = client.jetstream
91
91
 
92
- # Ensure JetStream responds to #nc for compatibility
92
+ # Ensure JetStream responds to #nc
93
93
  jts.define_singleton_method(:nc) { client } unless jts.respond_to?(:nc)
94
94
 
95
95
  jts
@@ -5,17 +5,14 @@
5
5
  module JetstreamBridge
6
6
  # Utility for parsing human-friendly durations into milliseconds.
7
7
  #
8
- # Defaults to an :auto heuristic for Integer/Float values to preserve
9
- # backward compatibility:
10
- # - Integers < 1000 are treated as seconds (e.g., 30 -> 30_000ms)
11
- # - Integers >= 1000 are treated as milliseconds (e.g., 1500 -> 1500ms)
12
- # Prefer setting `default_unit:` to :s or :ms for unambiguous behavior.
8
+ # Uses auto-detection heuristic by default: integers <1000 are treated as
9
+ # seconds, >=1000 as milliseconds. Strings with unit suffixes are supported
10
+ # (e.g., "30s", "500ms", "1h").
13
11
  #
14
12
  # Examples:
15
- # Duration.to_millis(30) #=> 30000 (auto)
16
- # Duration.to_millis(1500) #=> 1500 (auto)
17
- # Duration.to_millis("1500") #=> 1500 (auto)
18
- # Duration.to_millis(1500, default_unit: :s) #=> 1_500_000
13
+ # Duration.to_millis(2) #=> 2000 (auto: seconds)
14
+ # Duration.to_millis(1500) #=> 1500 (auto: milliseconds)
15
+ # Duration.to_millis(30, default_unit: :s) #=> 30000
19
16
  # Duration.to_millis("30s") #=> 30000
20
17
  # Duration.to_millis("500ms") #=> 500
21
18
  # Duration.to_millis("250us") #=> 0
@@ -37,14 +34,15 @@ module JetstreamBridge
37
34
  'd' => 86_400_000 # days to ms
38
35
  }.freeze
39
36
 
40
- NUMBER_RE = /\A\d[\d_]*\z/.freeze
41
- TOKEN_RE = /\A(\d[\d_]*(?:\.\d+)?)\s*(ns|us|µs|ms|s|m|h|d)\z/i.freeze
37
+ NUMBER_RE = /\A\d[\d_]*\z/
38
+ TOKEN_RE = /\A(\d[\d_]*(?:\.\d+)?)\s*(ns|us|µs|ms|s|m|h|d)\z/i
42
39
 
43
40
  module_function
44
41
 
45
42
  # default_unit:
46
- # :auto (heuristic: int<1000 -> seconds, >=1000 -> ms)
47
- # :ns, :us, :ms, :s, :m, :h, :d (explicit)
43
+ # :auto (default: heuristic - <1000 => seconds, >=1000 => milliseconds)
44
+ # :ms (explicit milliseconds)
45
+ # :ns, :us, :s, :m, :h, :d (explicit units)
48
46
  def to_millis(val, default_unit: :auto)
49
47
  case val
50
48
  when Integer then int_to_ms(val, default_unit: default_unit)
@@ -69,20 +67,7 @@ module JetstreamBridge
69
67
  # --- internal helpers ---
70
68
 
71
69
  def int_to_ms(num, default_unit:)
72
- case default_unit
73
- when :auto
74
- # Preserve existing heuristic for compatibility but log deprecation warning
75
- if defined?(Logging) && num.positive? && num < 1_000
76
- Logging.debug(
77
- "Duration :auto heuristic treating #{num} as seconds. " \
78
- "Consider specifying default_unit: :s or :ms for clarity.",
79
- tag: 'JetstreamBridge::Duration'
80
- )
81
- end
82
- num >= 1_000 ? num : num * 1_000
83
- else
84
- coerce_numeric_to_ms(num.to_f, default_unit)
85
- end
70
+ coerce_numeric_to_ms(num.to_f, default_unit)
86
71
  end
87
72
 
88
73
  def float_to_ms(flt, default_unit:)
@@ -104,17 +89,16 @@ module JetstreamBridge
104
89
  end
105
90
 
106
91
  def coerce_numeric_to_ms(num, unit)
107
- case unit
108
- when :auto
109
- # For floats, :auto treats as seconds (common developer intent)
110
- (num * 1_000).round
111
- else
112
- u = unit.to_s
113
- mult = MULTIPLIER_MS[u]
114
- raise ArgumentError, "invalid unit for default_unit: #{unit.inspect}" unless mult
115
-
116
- (num * mult).round
92
+ # Handle :auto unit with heuristic: <1000 => seconds, >=1000 => milliseconds
93
+ if unit == :auto
94
+ return (num < 1000 ? num * 1_000 : num).round
117
95
  end
96
+
97
+ u = unit.to_s
98
+ mult = MULTIPLIER_MS[u]
99
+ raise ArgumentError, "invalid unit for default_unit: #{unit.inspect}" unless mult
100
+
101
+ (num * mult).round
118
102
  end
119
103
  end
120
104
  end
@@ -1,8 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JetstreamBridge
4
- # Base error for all JetStream Bridge errors
5
- class Error < StandardError; end
4
+ # Base error for all JetStream Bridge errors with context support
5
+ class Error < StandardError
6
+ attr_reader :context
7
+
8
+ def initialize(message = nil, context: {})
9
+ super(message)
10
+ @context = context.freeze
11
+ end
12
+ end
6
13
 
7
14
  # Configuration errors
8
15
  class ConfigurationError < Error; end
@@ -14,13 +21,47 @@ module JetstreamBridge
14
21
  class ConnectionNotEstablishedError < ConnectionError; end
15
22
  class HealthCheckFailedError < ConnectionError; end
16
23
 
17
- # Publisher errors
18
- class PublishError < Error; end
24
+ # Publisher errors with enriched context
25
+ class PublishError < Error
26
+ attr_reader :event_id, :subject
27
+
28
+ def initialize(message = nil, event_id: nil, subject: nil, context: {})
29
+ @event_id = event_id
30
+ @subject = subject
31
+ super(message, context: context.merge(event_id: event_id, subject: subject).compact)
32
+ end
33
+ end
34
+
19
35
  class PublishFailedError < PublishError; end
20
36
  class OutboxError < PublishError; end
21
37
 
22
- # Consumer errors
23
- class ConsumerError < Error; end
38
+ class BatchPublishError < PublishError
39
+ attr_reader :failed_events, :successful_count
40
+
41
+ def initialize(message = nil, failed_events: [], successful_count: 0, context: {})
42
+ @failed_events = failed_events
43
+ @successful_count = successful_count
44
+ super(
45
+ message,
46
+ context: context.merge(
47
+ failed_count: failed_events.size,
48
+ successful_count: successful_count
49
+ )
50
+ )
51
+ end
52
+ end
53
+
54
+ # Consumer errors with delivery context
55
+ class ConsumerError < Error
56
+ attr_reader :event_id, :deliveries
57
+
58
+ def initialize(message = nil, event_id: nil, deliveries: nil, context: {})
59
+ @event_id = event_id
60
+ @deliveries = deliveries
61
+ super(message, context: context.merge(event_id: event_id, deliveries: deliveries).compact)
62
+ end
63
+ end
64
+
24
65
  class HandlerError < ConsumerError; end
25
66
  class InboxError < ConsumerError; end
26
67
 
@@ -34,6 +75,17 @@ module JetstreamBridge
34
75
  class DlqError < Error; end
35
76
  class DlqPublishFailedError < DlqError; end
36
77
 
37
- # Retry errors (already defined in retry_strategy.rb)
38
- # class RetryExhausted < Error; end
78
+ # Retry errors
79
+ class RetryExhausted < Error
80
+ attr_reader :attempts, :original_error
81
+
82
+ def initialize(message = nil, attempts: 0, original_error: nil, context: {})
83
+ @attempts = attempts
84
+ @original_error = original_error
85
+ super(
86
+ message || "Failed after #{attempts} attempts: #{original_error&.message}",
87
+ context: context.merge(attempts: attempts).compact
88
+ )
89
+ end
90
+ end
39
91
  end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module JetstreamBridge
6
+ module Models
7
+ # Structured event object provided to consumers
8
+ #
9
+ # @example Accessing event data in consumer
10
+ # JetstreamBridge.subscribe do |event|
11
+ # puts event.type # "user.created"
12
+ # puts event.payload.id # 123
13
+ # puts event.resource_type # "user"
14
+ # puts event.deliveries # 1
15
+ # puts event.metadata.trace_id # "abc123"
16
+ # end
17
+ class Event
18
+ # Metadata associated with message delivery
19
+ Metadata = Struct.new(
20
+ :subject,
21
+ :deliveries,
22
+ :stream,
23
+ :sequence,
24
+ :consumer,
25
+ :timestamp,
26
+ keyword_init: true
27
+ ) do
28
+ def to_h
29
+ {
30
+ subject: subject,
31
+ deliveries: deliveries,
32
+ stream: stream,
33
+ sequence: sequence,
34
+ consumer: consumer,
35
+ timestamp: timestamp
36
+ }.compact
37
+ end
38
+ end
39
+
40
+ # Payload accessor with method-style access
41
+ class PayloadAccessor
42
+ def initialize(payload)
43
+ @payload = payload.is_a?(Hash) ? payload.transform_keys(&:to_s) : {}
44
+ end
45
+
46
+ def method_missing(method_name, *args)
47
+ return @payload[method_name.to_s] if args.empty? && @payload.key?(method_name.to_s)
48
+
49
+ super
50
+ end
51
+
52
+ def respond_to_missing?(method_name, _include_private = false)
53
+ @payload.key?(method_name.to_s) || super
54
+ end
55
+
56
+ def [](key)
57
+ @payload[key.to_s]
58
+ end
59
+
60
+ def dig(*keys)
61
+ @payload.dig(*keys.map(&:to_s))
62
+ end
63
+
64
+ def to_h
65
+ @payload
66
+ end
67
+
68
+ alias to_hash to_h
69
+ end
70
+
71
+ attr_reader :event_id, :type, :resource_type, :resource_id,
72
+ :producer, :occurred_at, :trace_id, :schema_version,
73
+ :metadata
74
+
75
+ # @param envelope [Hash] The raw event envelope
76
+ # @param metadata [Hash] Message delivery metadata
77
+ def initialize(envelope, metadata: {})
78
+ envelope = envelope.transform_keys(&:to_s) if envelope.respond_to?(:transform_keys)
79
+
80
+ @event_id = envelope['event_id']
81
+ @type = envelope['event_type']
82
+ @resource_type = envelope['resource_type']
83
+ @resource_id = envelope['resource_id']
84
+ @producer = envelope['producer']
85
+ @schema_version = envelope['schema_version'] || 1
86
+ @trace_id = envelope['trace_id']
87
+
88
+ @occurred_at = parse_time(envelope['occurred_at'])
89
+ @payload = PayloadAccessor.new(envelope['payload'] || {})
90
+ @metadata = build_metadata(metadata)
91
+ @raw_envelope = envelope
92
+
93
+ freeze
94
+ end
95
+
96
+ # Access payload with method-style syntax
97
+ #
98
+ # @example
99
+ # event.payload.user_id # Same as event.payload["user_id"]
100
+ # event.payload.to_h # Get raw payload hash
101
+ attr_reader :payload
102
+
103
+ # Get raw envelope hash
104
+ #
105
+ # @return [Hash] The original envelope
106
+ def to_envelope
107
+ @raw_envelope
108
+ end
109
+
110
+ # Get hash representation
111
+ #
112
+ # @return [Hash] Event as hash
113
+ def to_h
114
+ {
115
+ event_id: @event_id,
116
+ type: @type,
117
+ resource_type: @resource_type,
118
+ resource_id: @resource_id,
119
+ producer: @producer,
120
+ occurred_at: @occurred_at&.iso8601,
121
+ trace_id: @trace_id,
122
+ schema_version: @schema_version,
123
+ payload: @payload.to_h,
124
+ metadata: @metadata.to_h
125
+ }
126
+ end
127
+
128
+ alias to_hash to_h
129
+
130
+ # Number of times this message has been delivered
131
+ #
132
+ # @return [Integer] Delivery count
133
+ def deliveries
134
+ @metadata.deliveries || 1
135
+ end
136
+
137
+ # Subject this message was received on
138
+ #
139
+ # @return [String] NATS subject
140
+ def subject
141
+ @metadata.subject
142
+ end
143
+
144
+ # Stream this message came from
145
+ #
146
+ # @return [String, nil] Stream name
147
+ def stream
148
+ @metadata.stream
149
+ end
150
+
151
+ # Message sequence number in the stream
152
+ #
153
+ # @return [Integer, nil] Sequence number
154
+ def sequence
155
+ @metadata.sequence
156
+ end
157
+
158
+ def inspect
159
+ "#<#{self.class.name} id=#{@event_id} type=#{@type} deliveries=#{deliveries}>"
160
+ end
161
+
162
+ # Support hash-like access for backwards compatibility
163
+ def [](key)
164
+ case key.to_s
165
+ when 'event_id' then @event_id
166
+ when 'event_type' then @type
167
+ when 'resource_type' then @resource_type
168
+ when 'resource_id' then @resource_id
169
+ when 'producer' then @producer
170
+ when 'occurred_at' then @occurred_at&.iso8601
171
+ when 'trace_id' then @trace_id
172
+ when 'schema_version' then @schema_version
173
+ when 'payload' then @payload.to_h
174
+ else
175
+ @raw_envelope[key.to_s]
176
+ end
177
+ end
178
+
179
+ private
180
+
181
+ def build_metadata(meta)
182
+ Metadata.new(
183
+ subject: meta[:subject] || meta['subject'],
184
+ deliveries: meta[:deliveries] || meta['deliveries'] || 1,
185
+ stream: meta[:stream] || meta['stream'],
186
+ sequence: meta[:sequence] || meta['sequence'],
187
+ consumer: meta[:consumer] || meta['consumer'],
188
+ timestamp: Time.now.utc
189
+ )
190
+ end
191
+
192
+ def parse_time(value)
193
+ return nil if value.nil?
194
+ return value if value.is_a?(Time)
195
+
196
+ Time.parse(value.to_s)
197
+ rescue ArgumentError
198
+ nil
199
+ end
200
+ end
201
+ end
202
+ end
@@ -67,21 +67,79 @@ module JetstreamBridge
67
67
 
68
68
  # ---- Defaults that do not require schema at load time ----
69
69
  before_validation do
70
- self.status ||= 'received' if self.class.has_column?(:status) && status.blank?
70
+ self.status ||= JetstreamBridge::Config::Status::RECEIVED if self.class.has_column?(:status) && status.blank?
71
71
  self.received_at ||= Time.now.utc if self.class.has_column?(:received_at) && received_at.blank?
72
72
  end
73
73
 
74
- # ---- Helpers ----
74
+ # ---- Query Scopes ----
75
+ scope :received, -> { where(status: JetstreamBridge::Config::Status::RECEIVED) if has_column?(:status) }
76
+ scope :processing, -> { where(status: JetstreamBridge::Config::Status::PROCESSING) if has_column?(:status) }
77
+ scope :processed, -> { where(status: JetstreamBridge::Config::Status::PROCESSED) if has_column?(:status) }
78
+ scope :failed, -> { where(status: JetstreamBridge::Config::Status::FAILED) if has_column?(:status) }
79
+ scope :by_subject, lambda { |subject|
80
+ where(subject: subject) if has_column?(:subject)
81
+ }
82
+ scope :by_stream, lambda { |stream|
83
+ where(stream: stream) if has_column?(:stream)
84
+ }
85
+ scope :recent, lambda { |limit = 100|
86
+ order(received_at: :desc).limit(limit) if has_column?(:received_at)
87
+ }
88
+ scope :unprocessed, lambda {
89
+ where.not(status: JetstreamBridge::Config::Status::PROCESSED) if has_column?(:status)
90
+ }
91
+
92
+ # ---- Class Methods ----
93
+ class << self
94
+ # Clean up old processed events
95
+ #
96
+ # @param older_than [ActiveSupport::Duration] Age threshold
97
+ # @return [Integer] Number of records deleted
98
+ def cleanup_processed(older_than: 30.days)
99
+ return 0 unless has_column?(:status) && has_column?(:processed_at)
100
+
101
+ processed.where('processed_at < ?', older_than.ago).delete_all
102
+ end
103
+
104
+ # Get processing statistics
105
+ #
106
+ # @return [Hash] Statistics hash
107
+ def processing_stats
108
+ return {} unless has_column?(:status)
109
+
110
+ {
111
+ total: count,
112
+ processed: processed.count,
113
+ failed: failed.count,
114
+ pending: unprocessed.count
115
+ }
116
+ end
117
+ end
118
+
119
+ # ---- Instance Methods ----
75
120
  def processed?
76
121
  if self.class.has_column?(:processed_at)
77
122
  processed_at.present?
78
123
  elsif self.class.has_column?(:status)
79
- status == 'processed'
124
+ status == JetstreamBridge::Config::Status::PROCESSED
80
125
  else
81
126
  false
82
127
  end
83
128
  end
84
129
 
130
+ def mark_processed!
131
+ now = Time.now.utc
132
+ self.status = JetstreamBridge::Config::Status::PROCESSED if self.class.has_column?(:status)
133
+ self.processed_at = now if self.class.has_column?(:processed_at)
134
+ save!
135
+ end
136
+
137
+ def mark_failed!(err_msg)
138
+ self.status = JetstreamBridge::Config::Status::FAILED if self.class.has_column?(:status)
139
+ self.last_error = err_msg if self.class.has_column?(:last_error)
140
+ save!
141
+ end
142
+
85
143
  def payload_hash
86
144
  v = self[:payload]
87
145
  case v
@@ -99,7 +157,7 @@ module JetstreamBridge
99
157
  # Shim: loud failure if AR isn't present but someone calls the model.
100
158
  class InboxEvent
101
159
  class << self
102
- def method_missing(method_name, *_args, &_block)
160
+ def method_missing(method_name, *_args, &)
103
161
  raise_missing_ar!('Inbox', method_name)
104
162
  end
105
163