jetstream_bridge 1.4.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.
data/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # Jetstream Bridge
2
+
3
+ **Production-safe realtime data bridge** between systems using **NATS JetStream**.
4
+ Includes durable consumers, backpressure, retries, **DLQ**, and optional **Inbox/Outbox** for end-to-end reliability.
5
+
6
+ ---
7
+
8
+ ## โœจ Features
9
+
10
+ - ๐Ÿ”Œ Simple **Publisher** and **Consumer** interfaces
11
+ - ๐Ÿ›ก **Outbox** (reliable send) & **Inbox** (idempotent receive)
12
+ - ๐Ÿงจ **DLQ** for poison messages
13
+ - โš™๏ธ Durable `pull_subscribe` with exponential backoff & `max_deliver`
14
+ - ๐ŸŽฏ Configurable **source** and **destination** applications
15
+ - ๐Ÿ“Š Built-in observability
16
+
17
+ ---
18
+
19
+ ## ๐Ÿ“ฆ Install
20
+
21
+ ```ruby
22
+ # Gemfile
23
+ gem "jetstream_bridge"
24
+ ```
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ ---
31
+
32
+ ## ๐Ÿ”ง Configure (Rails)
33
+
34
+ ```ruby
35
+ # config/initializers/jetstream_bridge.rb
36
+ JetstreamBridge.configure do |config|
37
+ # NATS Connection
38
+ config.nats_urls = ENV.fetch("NATS_URLS", "nats://localhost:4222")
39
+ config.env = ENV.fetch("NATS_ENV", "development")
40
+ config.app_name = ENV.fetch("APP_NAME", "app")
41
+ config.destination_app = ENV["DESTINATION_APP"]
42
+
43
+ # Consumer Tuning
44
+ config.max_deliver = 5
45
+ config.ack_wait = "30s"
46
+ config.backoff = %w[1s 5s 15s 30s 60s]
47
+
48
+ # Reliability Features
49
+ config.use_outbox = true
50
+ config.use_inbox = true
51
+ config.use_dlq = true
52
+
53
+ # Models (override if custom)
54
+ config.outbox_model = "JetstreamBridge::OutboxEvent"
55
+ config.inbox_model = "JetstreamBridge::InboxEvent"
56
+ end
57
+ ```
58
+
59
+ > **Note:**
60
+ > - `stream_name` defaults to `{env}-stream-bridge`
61
+ > - `dlq_subject` defaults to `data.sync.dlq`
62
+
63
+ ---
64
+
65
+ ## ๐Ÿ“ก Subject Conventions
66
+
67
+ | Direction | Subject Pattern |
68
+ |---------------|--------------------------------|
69
+ | **Publish** | `{env}.data.sync.{app}.{dest}` |
70
+ | **Subscribe** | `{env}.data.sync.{dest}.{app}` |
71
+ | **DLQ** | `{env}.data.sync.dlq` |
72
+
73
+ - `{app}`: Your `app_name`
74
+ - `{dest}`: Your `destination_app`
75
+ - `{env}`: Your `env``
76
+
77
+ ---
78
+
79
+ ## ๐Ÿ—ƒ Database Setup (Inbox/Outbox)
80
+
81
+ Run the installer:
82
+
83
+ ```bash
84
+ rails jetstream_bridge:install --all
85
+ ```
86
+
87
+ This creates:
88
+
89
+ 1. **Initializer** (`config/initializers/jetstream_bridge.rb`)
90
+ 2. **Migrations** (if enabled):
91
+
92
+ ```ruby
93
+ # Outbox
94
+ create_table :jetstream_outbox_events do |t|
95
+ t.string :resource_type, null: false
96
+ t.string :resource_id, null: false
97
+ t.string :event_type, null: false
98
+ t.jsonb :payload, null: false, default: {}
99
+ t.datetime :published_at
100
+ t.integer :attempts, default: 0
101
+ t.text :last_error
102
+ t.timestamps
103
+ end
104
+ add_index :jetstream_outbox_events, [:resource_type, :resource_id]
105
+
106
+ # Inbox
107
+ create_table :jetstream_inbox_events do |t|
108
+ t.string :event_id, null: false
109
+ t.string :subject, null: false
110
+ t.datetime :processed_at
111
+ t.text :error
112
+ t.timestamps
113
+ end
114
+ add_index :jetstream_inbox_events, :event_id, unique: true
115
+ ```
116
+
117
+ ---
118
+
119
+ ## ๐Ÿ“ค Publish Events
120
+
121
+ ```ruby
122
+ publisher = JetstreamBridge::Publisher.new
123
+ publisher.publish(
124
+ resource_type: "user",
125
+ resource_id: "01H1234567890ABCDEF",
126
+ event_type: "created",
127
+ payload: { id: "01H...", name: "Ada" }
128
+ )
129
+ ```
130
+
131
+ > **Ephemeral Mode** (for short-lived scripts):
132
+ > ```ruby
133
+ > JetstreamBridge::Publisher.new(persistent: false).publish(...)
134
+ > ```
135
+
136
+ ---
137
+
138
+ ## ๐Ÿ”„ Outbox (If Enabled)
139
+
140
+ Events are written to the Outbox table. Flush periodically:
141
+
142
+ ```ruby
143
+ # app/jobs/outbox_flush_job.rb
144
+ class OutboxFlushJob < ApplicationJob
145
+ def perform
146
+ JetstreamBridge::Publisher.new.flush_outbox
147
+ end
148
+ end
149
+ ```
150
+
151
+ Schedule this job to run every minute.
152
+
153
+ ---
154
+
155
+ ## ๐Ÿ“ฅ Consume Events
156
+
157
+ ```ruby
158
+ JetstreamBridge::Consumer.new(
159
+ durable_name: "#{Rails.env}-peerapp-events",
160
+ batch_size: 25
161
+ ) do |event, subject, deliveries|
162
+ # Your idempotent domain logic here
163
+ UserCreatedHandler.call(event.payload)
164
+ end.run!
165
+ ```
166
+
167
+ ---
168
+
169
+ ## ๐Ÿ“ฌ Envelope Format
170
+
171
+ Published events include:
172
+
173
+ ```json
174
+ {
175
+ "event_id": "01H1234567890ABCDEF",
176
+ "schema_version": 1,
177
+ "producer": "myapp",
178
+ "resource_type": "user",
179
+ "resource_id": "01H1234567890ABCDEF",
180
+ "event_type": "created",
181
+ "occurred_at": "2025-08-13T21:00:00Z",
182
+ "trace_id": "abc123",
183
+ "payload": { "id": "01H...", "name": "Ada" }
184
+ }
185
+ ```
186
+
187
+ ---
188
+
189
+ ## ๐Ÿ›  Operations Guide
190
+
191
+ ### Monitoring
192
+ - **Consumer Lag**: `nats consumer info <stream> <durable>`
193
+ - **Outbox Growth**: Alert if `jetstream_outbox_events` grows unexpectedly
194
+ - **DLQ Messages**: Monitor `data.sync.dlq` subscription
195
+
196
+ ### Scaling
197
+ - Run consumers in **separate processes/containers**
198
+ - Scale independently of web workers
199
+
200
+ ### When to Use
201
+ - **Inbox**: When replays or duplicates are possible
202
+ - **Outbox**: When "DB commit โ‡’ event published" guarantee is required
203
+
204
+ ---
205
+
206
+ ## ๐Ÿš€ Getting Started
207
+
208
+ 1. Install the gem
209
+ 2. Configure the initializer
210
+ 3. Run migrations: `rails db:migrate`
211
+ 4. Start publishing/consuming!
212
+
213
+ ---
214
+
215
+ ## ๐Ÿ“„ License
216
+
217
+ [MIT License](LICENSE)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/jetstream_bridge/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'jetstream_bridge'
7
+ spec.version = JetstreamBridge::VERSION
8
+ spec.authors = ['Mike Attara']
9
+ spec.email = ['mpyebattara@gmail.com']
10
+
11
+ # Clear, value-focused copy
12
+ spec.summary = 'Reliable realtime bridge over NATS JetStream for Rails/Ruby apps'
13
+ spec.description = <<~DESC.strip
14
+ Publisher/Consumer utilities for NATS JetStream with environment-scoped subjects,
15
+ overlap guards, DLQ routing, retries/backoff, and optional Inbox/Outbox patterns.
16
+ Includes topology setup helpers for production-safe operation.
17
+ DESC
18
+
19
+ spec.license = 'MIT'
20
+ spec.homepage = 'https://github.com/attaradev/jetstream_bridge'
21
+
22
+ # Ruby & RubyGems requirements
23
+ spec.required_ruby_version = '>= 2.7.0'
24
+ spec.required_rubygems_version = '>= 3.3.0'
25
+
26
+ # Rich metadata for RubyGems.org
27
+ spec.metadata = {
28
+ 'homepage_uri' => 'https://github.com/attaradev/jetstream_bridge',
29
+ 'source_code_uri' => 'https://github.com/attaradev/jetstream_bridge',
30
+ 'changelog_uri' => 'https://github.com/attaradev/jetstream_bridge/blob/main/CHANGELOG.md',
31
+ 'documentation_uri' => 'https://github.com/attaradev/jetstream_bridge#readme',
32
+ 'bug_tracker_uri' => 'https://github.com/attaradev/jetstream_bridge/issues',
33
+ 'github_repo' => 'ssh://github.com/attaradev/jetstream_bridge',
34
+ 'rubygems_mfa_required'=> 'true'
35
+ }
36
+
37
+ # Safer file list for published gem
38
+ # (falls back to Dir[] if not in a git repo โ€” e.g., CI tarballs)
39
+ spec.files = if system('git rev-parse --is-inside-work-tree > /dev/null 2>&1')
40
+ `git ls-files -z`.split("\x0").reject { |f| f.start_with?('spec/fixtures/') }
41
+ else
42
+ Dir['lib/**/*', 'README*', 'CHANGELOG*', 'LICENSE*']
43
+ end
44
+
45
+ spec.require_paths = ['lib']
46
+
47
+ # Runtime dependencies
48
+ spec.add_dependency 'activerecord', '>= 6.0'
49
+ spec.add_dependency 'activesupport', '>= 6.0'
50
+ spec.add_dependency 'nats-pure', '~> 2.4'
51
+ spec.add_dependency 'rails', '>= 6.0'
52
+
53
+ # Development / quality dependencies
54
+ spec.add_development_dependency 'rake', '>= 13.0'
55
+ spec.add_development_dependency 'rspec', '>= 3.12'
56
+ spec.add_development_dependency 'rubocop', '~> 1.66'
57
+ spec.add_development_dependency 'rubocop-performance', '~> 1.21'
58
+ spec.add_development_dependency 'rubocop-rake', '~> 0.6'
59
+ spec.add_development_dependency 'rubocop-packaging', '~> 0.5'
60
+ spec.add_development_dependency 'bundler-audit', '>= 0.9.1'
61
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ class Config
5
+ attr_accessor :destination_app, :nats_urls, :env, :app_name,
6
+ :max_deliver, :ack_wait, :backoff,
7
+ :use_outbox, :use_inbox, :inbox_model, :outbox_model,
8
+ :use_dlq
9
+
10
+ def initialize
11
+ @nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
12
+ @env = ENV['NATS_ENV'] || 'development'
13
+ @app_name = ENV['APP_NAME'] || 'app'
14
+ @destination_app = ENV['DESTINATION_APP']
15
+
16
+ @max_deliver = 5
17
+ @ack_wait = '30s'
18
+ @backoff = %w[1s 5s 15s 30s 60s]
19
+
20
+ @use_outbox = false
21
+ @use_inbox = false
22
+ @use_dlq = true
23
+ @outbox_model = 'JetstreamBridge::OutboxEvent'
24
+ @inbox_model = 'JetstreamBridge::InboxEvent'
25
+ end
26
+
27
+ # Single stream name per env
28
+ def stream_name
29
+ "#{env}-jetstream-bridge-stream"
30
+ end
31
+
32
+ # Base subjects
33
+ # Producer publishes to: {env}.data.sync.{app}.{dest}
34
+ # Consumer subscribes to: {env}.data.sync.{dest}.{app}
35
+ def source_subject
36
+ "#{env}.data.sync.#{app_name}.#{destination_app}"
37
+ end
38
+
39
+ def destination_subject
40
+ "#{env}.data.sync.#{destination_app}.#{app_name}"
41
+ end
42
+
43
+ # DLQ
44
+ def dlq_subject
45
+ "#{env}.data.sync.dlq"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nats/io/client'
4
+ require 'singleton'
5
+ require 'json'
6
+ require_relative 'duration'
7
+ require_relative 'logging'
8
+ require_relative 'topology'
9
+ require_relative 'config'
10
+
11
+ module JetstreamBridge
12
+ # Singleton connection to NATS.
13
+ class Connection
14
+ include Singleton
15
+
16
+ DEFAULT_CONN_OPTS = {
17
+ reconnect: true,
18
+ reconnect_time_wait: 2,
19
+ max_reconnect_attempts: 10,
20
+ connect_timeout: 5
21
+ }.freeze
22
+
23
+ class << self
24
+ # Thread-safe delegator to the singleton instance
25
+ def connect!
26
+ @__mutex ||= Mutex.new
27
+ @__mutex.synchronize { instance.connect! }
28
+ end
29
+ end
30
+
31
+ # Idempotent: returns an existing, healthy JetStream context or establishes one.
32
+ def connect!
33
+ return @jts if connected?
34
+
35
+ servers = nats_servers
36
+ raise 'No NATS URLs configured' if servers.empty?
37
+
38
+ establish_connection(servers)
39
+ Logging.info(
40
+ "Connected to NATS (#{servers.size} server#{servers.size == 1 ? '' : 's'}): #{sanitize_urls(servers).join(',')}",
41
+ tag: 'JetstreamBridge::Connection'
42
+ )
43
+
44
+ Topology.ensure!(@jts)
45
+ @jts
46
+ end
47
+
48
+ private
49
+
50
+ def connected?
51
+ @nc&.connected?
52
+ end
53
+
54
+ def nats_servers
55
+ JetstreamBridge.config.nats_urls
56
+ .to_s
57
+ .split(',')
58
+ .map(&:strip)
59
+ .reject(&:empty?)
60
+ end
61
+
62
+ def establish_connection(servers)
63
+ @nc = NATS::IO::Client.new
64
+ @nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS))
65
+ @jts = @nc.jetstream
66
+ end
67
+
68
+ # Mask credentials in NATS URLs:
69
+ # - "nats://user:pass@host:4222" -> "nats://user:***@host:4222"
70
+ # - "nats://token@host:4222" -> "nats://***@host:4222"
71
+ def sanitize_urls(urls)
72
+ urls.map { |u| Logging.sanitize_url(u) }
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require_relative 'connection'
6
+ require_relative 'duration'
7
+ require_relative 'logging'
8
+ require_relative 'consumer_config'
9
+ require_relative 'message_processor'
10
+ require_relative 'config'
11
+
12
+ module JetstreamBridge
13
+ # Subscribes to "{env}.data.sync.{dest}.{app}" and processes messages.
14
+ class Consumer
15
+ DEFAULT_BATCH_SIZE = 25
16
+ FETCH_TIMEOUT_SECS = 5
17
+ IDLE_SLEEP_SECS = 0.05
18
+
19
+ # @param durable_name [String] Consumer name
20
+ # @param batch_size [Integer] Max messages per fetch
21
+ # @yield [event, subject, deliveries] Message handler
22
+ def initialize(durable_name:, batch_size: DEFAULT_BATCH_SIZE, &block)
23
+ @handler = block
24
+ @batch_size = batch_size
25
+ @durable = durable_name
26
+ @jts = Connection.connect!
27
+
28
+ ensure_destination!
29
+ ensure_consumer!
30
+ subscribe!
31
+ @processor = MessageProcessor.new(@jts, @handler)
32
+ end
33
+
34
+ # Starts the consumer loop.
35
+ def run!
36
+ Logging.info("Consumer #{@durable} startedโ€ฆ", tag: 'JetstreamBridge::Consumer')
37
+ loop do
38
+ processed = process_batch
39
+ sleep(IDLE_SLEEP_SECS) if processed.zero?
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def ensure_destination!
46
+ return unless JetstreamBridge.config.destination_app.to_s.empty?
47
+ raise ArgumentError, 'destination_app must be configured'
48
+ end
49
+
50
+ def stream_name
51
+ JetstreamBridge.config.stream_name
52
+ end
53
+
54
+ def filter_subject
55
+ JetstreamBridge.config.destination_subject
56
+ end
57
+
58
+ def ensure_consumer!
59
+ @jts.consumer_info(stream_name, @durable)
60
+ Logging.info("Consumer #{@durable} exists.", tag: 'JetstreamBridge::Consumer')
61
+ rescue NATS::JetStream::Error
62
+ @jts.add_consumer(stream_name, **ConsumerConfig.consumer_config(@durable, filter_subject))
63
+ Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
64
+ tag: 'JetstreamBridge::Consumer')
65
+ end
66
+
67
+ def subscribe!
68
+ @psub = @jts.pull_subscribe(
69
+ filter_subject,
70
+ @durable,
71
+ stream: stream_name,
72
+ config: ConsumerConfig.subscribe_config
73
+ )
74
+ Logging.info("Subscribed to #{filter_subject} (durable=#{@durable})",
75
+ tag: 'JetstreamBridge::Consumer')
76
+ end
77
+
78
+ # Returns number of messages processed; 0 on timeout/idle or after recovery.
79
+ def process_batch
80
+ msgs = @psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
81
+ msgs.each { |m| @processor.handle_message(m) }
82
+ msgs.size
83
+ rescue NATS::Timeout, NATS::IO::Timeout
84
+ 0
85
+ rescue NATS::JetStream::Error => e
86
+ # Handle common recoverable states by re-ensuring consumer & subscription.
87
+ if recoverable_consumer_error?(e)
88
+ Logging.warn("Recovering subscription after error: #{e.class} #{e.message}",
89
+ tag: 'JetstreamBridge::Consumer')
90
+ ensure_consumer!
91
+ subscribe!
92
+ 0
93
+ else
94
+ Logging.error("Fetch failed: #{e.class} #{e.message}",
95
+ tag: 'JetstreamBridge::Consumer')
96
+ 0
97
+ end
98
+ end
99
+
100
+ def recoverable_consumer_error?(error)
101
+ msg = error.message.to_s
102
+ msg =~ /consumer.*(not\s+found|deleted)/i ||
103
+ msg =~ /no\s+responders/i
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'duration'
4
+ require_relative 'logging'
5
+ require_relative 'config'
6
+
7
+ module JetstreamBridge
8
+ # Consumer configuration helpers.
9
+ module ConsumerConfig
10
+ module_function
11
+
12
+ def consumer_config(durable, filter_subject)
13
+ {
14
+ durable_name: durable,
15
+ filter_subject: filter_subject,
16
+ ack_policy: 'explicit',
17
+ max_deliver: JetstreamBridge.config.max_deliver,
18
+ ack_wait: Duration.to_millis(JetstreamBridge.config.ack_wait),
19
+ backoff: Array(JetstreamBridge.config.backoff).map { |d| Duration.to_millis(d) }
20
+ }
21
+ end
22
+
23
+ def subscribe_config
24
+ {
25
+ ack_policy: 'explicit',
26
+ max_deliver: JetstreamBridge.config.max_deliver,
27
+ ack_wait: Duration.to_millis(JetstreamBridge.config.ack_wait),
28
+ backoff: Array(JetstreamBridge.config.backoff).map { |d| Duration.to_millis(d) }
29
+ }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'overlap_guard'
4
+ require_relative 'logging'
5
+ require_relative 'subject_matcher'
6
+
7
+ module JetstreamBridge
8
+ # Ensures the DLQ subject is added to the stream.
9
+ class DLQ
10
+ def self.ensure!(jts)
11
+ name = JetstreamBridge.config.stream_name
12
+ info = jts.stream_info(name)
13
+ subs = Array(info.config.subjects || [])
14
+ dlq = JetstreamBridge.config.dlq_subject
15
+ return if SubjectMatcher.covered?(subs, dlq)
16
+
17
+ desired = (subs + [dlq]).uniq
18
+ OverlapGuard.check!(jts, name, desired)
19
+
20
+ jts.update_stream(name: name, subjects: desired)
21
+ Logging.info("Added DLQ subject #{dlq} to stream #{name}", tag: 'JetstreamBridge::DLQ')
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # JetstreamBridge
4
+ #
5
+ module JetstreamBridge
6
+ # Utility for parsing human-friendly durations into milliseconds.
7
+ # Examples:
8
+ # Duration.to_millis(30) #=> 30000
9
+ # Duration.to_millis("30s") #=> 30000
10
+ # Duration.to_millis("500ms") #=> 500
11
+ # Duration.to_millis(0.5) #=> 500
12
+ module Duration
13
+ MULTIPLIER = { 'ms' => 1, 's' => 1_000, 'm' => 60_000, 'h' => 3_600_000 }.freeze
14
+ NUMBER_RE = /\A\d+\z/.freeze
15
+ TOKEN_RE = /\A(\d+(?:\.\d+)?)\s*(ms|s|m|h)\z/i.freeze
16
+
17
+ module_function
18
+
19
+ def to_millis(val)
20
+ return int_to_ms(val) if val.is_a?(Integer)
21
+ return float_to_ms(val) if val.is_a?(Float)
22
+ return string_to_ms(val) if val.is_a?(String)
23
+ return float_to_ms(val.to_f) if val.respond_to?(:to_f)
24
+
25
+ raise ArgumentError, "invalid duration type: #{val.class}"
26
+ end
27
+
28
+ def int_to_ms(i)
29
+ i >= 1_000 ? i : i * 1_000
30
+ end
31
+
32
+ def float_to_ms(f)
33
+ (f * 1_000).round
34
+ end
35
+
36
+ def string_to_ms(str)
37
+ s = str.strip
38
+ return int_to_ms(s.to_i) if NUMBER_RE.match?(s)
39
+
40
+ m = TOKEN_RE.match(s)
41
+ raise ArgumentError, "invalid duration: #{str.inspect}" unless m
42
+
43
+ (m[1].to_f * MULTIPLIER.fetch(m[2].downcase)).round
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # If ActiveRecord is not available, a shim class is defined that raises
4
+ # a helpful error when used.
5
+ begin
6
+ require 'active_record'
7
+ rescue LoadError
8
+ # Ignore; we handle the lack of AR below.
9
+ end
10
+
11
+ module JetstreamBridge
12
+ # InboxEvent is the default ActiveRecord model used by the gem when
13
+ # `use_inbox` is enabled.
14
+ # It records processed event IDs for idempotency.
15
+ if defined?(ActiveRecord::Base)
16
+ class InboxEvent < ActiveRecord::Base
17
+ self.table_name = 'jetstream_inbox_events'
18
+
19
+ validates :event_id, presence: true, uniqueness: true
20
+ validates :subject, presence: true
21
+ end
22
+ else
23
+ # Shim that fails loudly if the app misconfigures the gem without AR.
24
+ class InboxEvent
25
+ class << self
26
+ def method_missing(method_name, *_args, &_block)
27
+ raise_missing_ar!('Inbox', method_name)
28
+ end
29
+
30
+ def respond_to_missing?(_method_name, _include_private = false)
31
+ false
32
+ end
33
+
34
+ private
35
+
36
+ def raise_missing_ar!(which, method_name)
37
+ raise(
38
+ "#{which} requires ActiveRecord (tried to call ##{method_name}). " \
39
+ 'Enable `use_inbox` only in apps with ActiveRecord, or add ' \
40
+ '`gem "activerecord"` to your Gemfile.'
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end