listenable 0.1.1 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebe46a89c412b54c70f0af2f7851f46176dfd08c3dc5a611ef61c35d0bea1717
4
- data.tar.gz: c19ed7b192adc50833b432058cfcdf652add0401bbbca28e869c282186ef7e88
3
+ metadata.gz: a40496b36e34ee9be039eafd3ca5ce235d7fff2c2ccd93dfb1041bd75fa5b87f
4
+ data.tar.gz: 89505e376d1f337dd5e9d7172383a07501846613fcf556fa94682383b2a42d85
5
5
  SHA512:
6
- metadata.gz: 6083b9a19eb74cceaec96942846f3a7b7754bf0ed27f42686f67351e8b6bd7246392c485a7b0ad4e35d909d85eb2b0f4f2cf33ebf0529ff475c9ed11b9857e68
7
- data.tar.gz: 2b4c5d0b93694b35f3431f9cef48794182a621e8f937fc01bb745b895d3edbff5b38ce27c42a78c69622fecd2d2e5e1d35d5b2e171f5080dd2fd4344619c6ebd
6
+ metadata.gz: 7b2a55fee58e33e6f9f3fe1354d67a86668c93294e9e94274fba575ebe415805952f5e53179bf0fd041d00ab361e4c01b07390df3ac4a1f1eebd82eddf50505b
7
+ data.tar.gz: ef07a946e9bfc66eb038d09b765977cf44e766e2209295d2427bb7f3ad24e35d9827a3c473905d2baecb10f28814e344600ba857650e453c976622604f65074b
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Listenable is a Rails DSL that connects your ActiveRecord models to dedicated listener classes using `ActiveSupport::Notifications`.
4
4
 
5
- Instead of cluttering your models with callbacks, you declare listeners in `app/listeners`. Listenable automatically wires up the callbacks, instruments events, and runs your listener methods.
5
+ Instead of cluttering your models with callbacks, you declare listeners in `app/listeners`. Listenable automatically wires up the callbacks, instruments events, and runs your listener methods. It supports both synchronous (blocking) and asynchronous (non-blocking) execution modes.
6
6
 
7
7
  ## Installation
8
8
 
@@ -18,6 +18,8 @@ If bundler is not being used to manage dependencies, install the gem by executin
18
18
  gem install listenable
19
19
  ```
20
20
 
21
+ **Note**: For asynchronous listener support, make sure you have the `concurrent-ruby` gem installed (usually included with Rails by default).
22
+
21
23
  ## Usage
22
24
 
23
25
  #### 1. Define a model
@@ -36,19 +38,19 @@ class UserListener
36
38
  listen :on_created, :on_updated, :on_deleted
37
39
 
38
40
  # Handle user creation
39
- def on_created(record)
41
+ def self.on_created(record)
40
42
  Rails.logger.info "User created: #{user.id}"
41
43
  SendWelcomeEmailJob.perform_later(user)
42
44
  end
43
45
 
44
46
  # Handle user update
45
- def on_updated(record)
47
+ def self.on_updated(record)
46
48
  Rails.logger.info "User updated: #{user.id}"
47
49
  SendProfileUpdateNotificationJob.perform_later(user)
48
50
  end
49
51
 
50
52
  # Handle user deletion
51
- def on_deleted(record)
53
+ def self.on_deleted(record)
52
54
  Rails.logger.info "User deleted: #{user.id}"
53
55
  ArchiveUserDataJob.perform_later(user)
54
56
  end
@@ -65,14 +67,205 @@ Under the hood:
65
67
  * `ActiveSupport::Notifications.instrument` fires events like `user.created`.
66
68
  * The Railtie subscribes your listener methods to those events.
67
69
 
68
- ## Supported hooks
69
- | Listener hook | Model callback |
70
- |-----------------------|-----------------------|
71
- | `on_created` | `after_create` |
72
- | `on_updated` | `after_update` |
73
- | `on_deleted` | `after_destroy` |
70
+ ## Synchronous vs Asynchronous Execution
71
+
72
+ Listenable supports both synchronous (blocking) and asynchronous (non-blocking) listener execution:
73
+
74
+ ### Synchronous Listeners (Default)
75
+ By default, listeners execute synchronously in the same thread as your model operations:
76
+
77
+ ```ruby
78
+ class UserListener
79
+ include Listenable
80
+
81
+ # Synchronous execution (default)
82
+ listen :on_created, :on_updated, :on_deleted
83
+
84
+ def self.on_created(user)
85
+ Rails.logger.info "User created: #{user.id}"
86
+ # This runs in the same request thread
87
+ end
88
+ end
89
+ ```
90
+
91
+ ### Asynchronous Listeners
92
+ For non-blocking execution, use the `async: true` option:
93
+
94
+ ```ruby
95
+ class UserListener
96
+ include Listenable
97
+
98
+ # Asynchronous execution - runs in background thread
99
+ listen :on_created, :on_updated, :on_deleted, async: true
100
+
101
+ def self.on_created(user)
102
+ Rails.logger.info "User created: #{user.id}"
103
+ # This runs in a separate thread, doesn't block the request
104
+ SendWelcomeEmailService.call(user) # Safe for heavier operations
105
+ end
106
+ end
107
+ ```
108
+
109
+ ### Mixed Execution Modes
110
+ You can mix synchronous and asynchronous listeners by calling `listen` multiple times:
111
+
112
+ ```ruby
113
+ class UserListener
114
+ include Listenable
115
+
116
+ # Some listeners run synchronously
117
+ listen :on_created
118
+
119
+ # Others run asynchronously
120
+ listen :on_updated, :on_deleted, async: true
121
+
122
+ def self.on_created(user)
123
+ # Runs synchronously - blocks request
124
+ user.update!(status: 'active')
125
+ end
126
+
127
+ def self.on_updated(user)
128
+ # Runs asynchronously - doesn't block request
129
+ UserAnalyticsService.new(user).calculate_metrics
130
+ end
131
+
132
+ def self.on_deleted(user)
133
+ # Also runs asynchronously
134
+ CleanupUserDataService.call(user)
135
+ end
136
+ end
137
+ ```
138
+
139
+ ## ⚠️ Important: Execution Modes and Performance
140
+
141
+ ### Synchronous Listeners (Default Behavior)
142
+
143
+ **Synchronous listeners execute in the same thread and will block the current request.** This means that all synchronous listener methods run in the same request/transaction as your model operations, which can impact performance and response times.
144
+
145
+ **For synchronous listeners**: Always queue heavy operations in background jobs to maintain application performance:
146
+
147
+ ```ruby
148
+ class UserListener
149
+ include Listenable
150
+
151
+ # Synchronous listeners (default)
152
+ listen :on_created, :on_updated
153
+
154
+ def self.on_created(user)
155
+ # ✅ Good - Lightweight operations or queue background jobs
156
+ SendWelcomeEmailJob.perform_later(user)
157
+ NotifyAdminsJob.perform_later(user)
158
+ end
159
+
160
+ def self.on_updated(user)
161
+ # ❌ Avoid - Heavy synchronous operations that block requests
162
+ # UserAnalyticsService.new(user).calculate_metrics # This blocks!
163
+
164
+ # ✅ Better - Queue in background
165
+ CalculateUserMetricsJob.perform_later(user)
166
+ end
167
+ end
168
+ ```
169
+
170
+ ### Asynchronous Listeners (Non-blocking)
171
+
172
+ **Asynchronous listeners execute in separate threads and don't block requests.** This allows for heavier operations without impacting response times:
173
+
174
+ ```ruby
175
+ class UserListener
176
+ include Listenable
177
+
178
+ # Asynchronous listeners - safe for heavier operations
179
+ listen :on_created, :on_updated, async: true
180
+
181
+ def self.on_created(user)
182
+ # ✅ Safe - Runs in background thread
183
+ UserAnalyticsService.new(user).calculate_metrics
184
+ SendWelcomeEmailService.call(user)
185
+ end
186
+
187
+ def self.on_updated(user)
188
+ # ✅ Safe - Heavy operations won't block requests
189
+ ExternalApiService.notify_user_update(user)
190
+ GenerateUserReportService.call(user)
191
+ end
192
+ end
193
+ ```
194
+
195
+ **Note**: Asynchronous listeners use `Concurrent::Promises` for thread-safe execution. Errors in async listeners are logged but won't affect the main request flow.
196
+
197
+ #### Database Connection Management
198
+
199
+ Asynchronous listeners are automatically wrapped in ActiveRecord's connection pool management (`connection_pool.with_connection`) to prevent connection pool exhaustion. The record is reloaded in the async thread to ensure thread-safe database access:
200
+
201
+ ```ruby
202
+ class UserListener
203
+ include Listenable
204
+
205
+ listen :on_updated, async: true
74
206
 
75
- ## Runtime Toggle
207
+ def self.on_updated(user)
208
+ # The record is automatically reloaded in this thread
209
+ # Safe to access attributes and associations
210
+ user.name # ✅ Safe
211
+ user.orders.count # ✅ Safe - proper connection management
212
+
213
+ # Heavy operations that query the database
214
+ UserAnalyticsService.new(user).calculate_metrics # ✅ Safe
215
+ end
216
+ end
217
+ ```
218
+
219
+ **Important**: The record passed to async listeners is reloaded from the database in the async thread. If the record is deleted before the async listener executes, the listener will receive `nil` and a warning will be logged.
220
+
221
+ #### Thread Pool & Bulk Operations
222
+
223
+ Async listeners use a **bounded thread pool** that automatically scales based on your database connection pool size:
224
+
225
+ ```ruby
226
+ # Automatically configured based on your connection pool
227
+ Listenable.async_executor # Auto-scales intelligently
228
+ ```
229
+
230
+ **Auto-Scaling Formula** (Very Conservative):
231
+ - `max_threads`: **25% of connection pool size** (minimum 1, maximum 3)
232
+ - `max_queue`: 10,000 tasks can be queued
233
+ - `fallback_policy`: Falls back to synchronous execution if queue is full
234
+
235
+ **Examples**:
236
+ - Connection pool of 5 → **1 thread** for async listeners
237
+ - Connection pool of 10 → **2 threads** for async listeners
238
+ - Connection pool of 20 → **3 threads** for async listeners (capped at 3)
239
+
240
+ **Why 25%?** Your main application needs most connections for handling requests. By using only 25% of the pool for async work, we ensure:
241
+ - Bulk operations never exhaust the connection pool
242
+ - Your main app always has connections available
243
+ - Async work is processed reliably through queuing
244
+
245
+ This ensures that bulk operations (updating thousands of records) don't exhaust your database connection pool:
246
+
247
+ ```ruby
248
+ # This works safely even with thousands of records
249
+ CSV.foreach('users.csv') do |row|
250
+ user = User.find(row['id'])
251
+ user.update!(name: row['name']) # Async listeners execute without exhausting pool
252
+ end
253
+ ```
254
+
255
+ **Manual Override (if needed)**:
256
+
257
+ If you want to override the auto-scaling, you can reset the executor with a custom size:
258
+
259
+ ```ruby
260
+ # config/initializers/listenable.rb
261
+
262
+ # Increase for larger connection pools
263
+ Listenable.reset_async_executor! # Reset existing executor
264
+ # Then create a custom executor if needed
265
+ # (Note: Auto-scaling is recommended for most cases)
266
+ ```
267
+
268
+ **⚠️ Warning**: The auto-scaling is conservative by design. Only override if you have a very large connection pool (20+) and understand the implications.
76
269
  By default, listeners are always active in development and production.
77
270
 
78
271
  You can enable/disable them dynamically at runtime using:
@@ -115,14 +308,13 @@ RSpec.describe User do
115
308
  User.create!(name: 'Pedro')
116
309
  end
117
310
 
118
- it 'fires listeners when enabled', listenable: true do
311
+ it 'fires synchronous listeners when enabled', listenable: true do
119
312
  expect(UserListener).to receive(:on_created)
120
313
  User.create!(name: 'Pedro')
121
314
  end
122
315
  end
123
316
  ```
124
317
 
125
-
126
318
  ## Development
127
319
 
128
320
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -131,7 +323,6 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
131
323
 
132
324
  ## Todo:
133
325
  * Create rake tasks to generate listener files.
134
- * RSpec tests for Railtie and integration tests.
135
326
 
136
327
  ## Contributing
137
328
 
data/Rakefile CHANGED
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- require "rubocop/rake_task"
8
+ require 'rubocop/rake_task'
9
9
 
10
10
  RuboCop::RakeTask.new
11
11
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Listenable
4
4
  CALLBACK_MAP = {
5
- "created" => :after_create,
6
- "updated" => :after_update,
7
- "deleted" => :after_destroy
5
+ 'created' => :after_create,
6
+ 'updated' => :after_update,
7
+ 'deleted' => :after_destroy
8
8
  }.freeze
9
9
 
10
10
  def self.included(base)
@@ -12,9 +12,12 @@ module Listenable
12
12
  end
13
13
 
14
14
  module ClassMethods
15
- def listen(*hooks)
15
+ def listen(*hooks, async: false)
16
16
  @pending_hooks ||= []
17
- @pending_hooks.concat(hooks.map(&:to_s))
17
+
18
+ hooks.each do |hook|
19
+ @pending_hooks << { name: hook.to_s, async: async }
20
+ end
18
21
  end
19
22
 
20
23
  def pending_hooks
@@ -2,50 +2,127 @@
2
2
 
3
3
  module Listenable
4
4
  class Railtie < Rails::Railtie
5
- initializer "listenable.load" do
5
+ # Cleanup on Rails reload to prevent memory leaks
6
+ config.to_prepare do
7
+ Listenable.cleanup!
8
+ end
9
+
10
+ # Graceful shutdown on Rails exit
11
+ config.after_initialize do
12
+ at_exit do
13
+ Listenable.shutdown_async_executor!
14
+ end
15
+ end
16
+
17
+ initializer 'listenable.load' do
6
18
  Rails.application.config.to_prepare do
7
19
  # Load all listeners (recursive, supports namespaced)
8
- Dir[Rails.root.join("app/listeners/**/*.rb")].each { |f| require_dependency f }
20
+ listener_files = Dir[Rails.root.join('app/listeners/**/*.rb')]
21
+ listener_files.each { |f| require_dependency f }
9
22
 
10
23
  # Find all listener classes
11
- ObjectSpace.each_object(Class).select { |klass| klass < Listenable }.each do |listener_class|
12
- model_class_name = listener_class.name.sub("Listener", "")
24
+ listener_classes = ObjectSpace.each_object(Class).select { |klass| klass < Listenable }
25
+ listener_classes.each do |listener_class|
26
+ model_class_name = listener_class.name.sub('Listener', '')
13
27
  model_class = model_class_name.safe_constantize
14
28
  next unless model_class
15
29
 
16
- listener_class.pending_hooks.each do |hook|
17
- action = hook.sub("on_", "")
30
+ listener_class.pending_hooks.each do |hook_info|
31
+ hook = hook_info[:name]
32
+ async = hook_info[:async]
33
+ action = hook.sub('on_', '')
18
34
  callback = Listenable::CALLBACK_MAP[action] or next
19
35
  method = "on_#{action}"
20
36
  event = "#{model_class_name.underscore}.#{action}"
21
37
 
22
- # Unsubscribe duplicates
38
+ # unsubscribe old subscribers
23
39
  ActiveSupport::Notifications.notifier.listeners_for(event).each do |subscriber|
24
40
  ActiveSupport::Notifications.unsubscribe(subscriber)
25
41
  end
26
42
 
27
- # Inject AR callback once per model/event
28
43
  injected_events = model_class.instance_variable_get(:@_listenable_injected_events) || []
29
44
  unless injected_events.include?(event)
30
45
  model_class.send(callback) do
31
46
  next unless Listenable.enabled
47
+
32
48
  ActiveSupport::Notifications.instrument(event, record: self)
33
49
  end
34
50
  injected_events << event
35
51
  model_class.instance_variable_set(:@_listenable_injected_events, injected_events)
36
52
  end
37
53
 
38
- # Subscribe listener (runtime-guarded)
39
- if listener_class.respond_to?(method)
40
- ActiveSupport::Notifications.subscribe(event) do |*args|
41
- next unless Listenable.enabled
42
- _name, _start, _finish, _id, payload = args
43
- listener_class.public_send(method, payload[:record])
54
+ next unless listener_class.respond_to?(method)
55
+
56
+ # Subscribe and track subscriber for cleanup
57
+ subscriber = ActiveSupport::Notifications.subscribe(event) do |*args|
58
+ next unless Listenable.enabled
59
+
60
+ _name, _start, _finish, _id, payload = args
61
+ record = payload[:record]
62
+
63
+ if async
64
+ Railtie.handle_async_listener(listener_class, method, record)
65
+ else
66
+ Railtie.handle_sync_listener(listener_class, method, record)
44
67
  end
45
68
  end
69
+
70
+ # Track subscriber for cleanup on reload
71
+ Listenable.subscribers << subscriber
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ class << self
78
+ # Handle async listener with proper error handling and connection management
79
+ def handle_async_listener(listener_class, method, record)
80
+ # Extract minimal data to pass to thread
81
+ record_id = record.id
82
+ record_class = record.class
83
+
84
+ # Use bounded thread pool to prevent spawning unlimited threads
85
+ Concurrent::Promises.future_on(Listenable.async_executor) do
86
+ # Wrap in connection pool management to prevent connection exhaustion
87
+ ActiveRecord::Base.connection_pool.with_connection do
88
+ execute_listener(listener_class, method, record_class, record_id)
89
+ rescue StandardError => e
90
+ log_error(listener_class, method, e)
46
91
  end
92
+ end.rescue do |e|
93
+ Rails.logger&.error(
94
+ "[Listenable] Promise failed for #{listener_class}##{method}: #{e.message}"
95
+ )
47
96
  end
48
97
  end
98
+
99
+ # Handle sync listener with proper error handling
100
+ def handle_sync_listener(listener_class, method, record)
101
+ listener_class.public_send(method, record)
102
+ rescue StandardError => e
103
+ log_error(listener_class, method, e)
104
+ raise # Re-raise for sync listeners to maintain transaction integrity
105
+ end
106
+
107
+ private
108
+
109
+ def execute_listener(listener_class, method, record_class, record_id)
110
+ reloaded_record = record_class.find_by(id: record_id)
111
+
112
+ if reloaded_record
113
+ listener_class.public_send(method, reloaded_record)
114
+ else
115
+ Rails.logger&.warn(
116
+ "[Listenable] Record #{record_class}##{record_id} not found for #{listener_class}##{method}"
117
+ )
118
+ end
119
+ end
120
+
121
+ def log_error(listener_class, method, error)
122
+ Rails.logger&.error(
123
+ "[Listenable] #{listener_class}##{method} failed: #{error.message}\n#{error.backtrace.first(5).join("\n")}"
124
+ )
125
+ end
49
126
  end
50
127
  end
51
128
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Listenable
4
- VERSION = "0.1.1"
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/listenable.rb CHANGED
@@ -1,16 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support"
4
- require "active_support/concern"
5
- require "active_support/notifications"
3
+ require 'active_support'
4
+ require 'active_support/concern'
5
+ require 'active_support/notifications'
6
+ require 'concurrent'
6
7
 
7
- require_relative "listenable/version"
8
- require_relative "listenable/concern"
9
- require_relative "listenable/railtie" if defined?(Rails)
8
+ require_relative 'listenable/version'
9
+ require_relative 'listenable/concern'
10
+ require_relative 'listenable/railtie' if defined?(Rails)
10
11
 
11
12
  module Listenable
12
13
  mattr_accessor :enabled, default: true
13
14
 
14
15
  class Error < StandardError; end
15
- # Your code goes here...
16
+
17
+ class << self
18
+ attr_writer :async_executor
19
+
20
+ # Track active subscribers to prevent memory leaks on reload
21
+ def subscribers
22
+ @subscribers ||= []
23
+ end
24
+
25
+ # Calculate a safe thread pool size based on connection pool
26
+ # Very conservative: use only 1/4 of pool (min 1, max 3)
27
+ def default_thread_pool_size
28
+ return 2 unless defined?(ActiveRecord::Base)
29
+
30
+ pool_size = ActiveRecord::Base.connection_pool.size
31
+ # Use 25% of pool, but at least 1 thread, max 3 threads
32
+ [[pool_size / 4, 1].max, 3].min
33
+ end
34
+
35
+ # Thread pool executor for async listeners
36
+ # Auto-scales to 25% of connection pool (very conservative)
37
+ def async_executor
38
+ @async_executor ||= Concurrent::ThreadPoolExecutor.new(
39
+ min_threads: 0,
40
+ max_threads: default_thread_pool_size,
41
+ max_queue: 10_000,
42
+ fallback_policy: :caller_runs,
43
+ idletime: 60 # Threads idle for 60s are cleaned up
44
+ )
45
+ end
46
+
47
+ # Cleanup all subscribers and shutdown thread pool
48
+ # Called on Rails reload to prevent memory leaks
49
+ def cleanup!
50
+ # Unsubscribe all tracked subscribers
51
+ subscribers.each do |subscriber|
52
+ ActiveSupport::Notifications.unsubscribe(subscriber)
53
+ rescue StandardError => e
54
+ Rails.logger&.warn("[Listenable] Failed to unsubscribe: #{e.message}")
55
+ end
56
+ @subscribers = []
57
+
58
+ # Shutdown thread pool gracefully
59
+ shutdown_async_executor!
60
+ end
61
+
62
+ # Graceful shutdown of thread pool
63
+ def shutdown_async_executor!
64
+ return unless @async_executor
65
+
66
+ @async_executor.shutdown
67
+ unless @async_executor.wait_for_termination(10)
68
+ Rails.logger&.warn('[Listenable] Thread pool shutdown timeout, forcing kill')
69
+ @async_executor.kill
70
+ end
71
+ @async_executor = nil
72
+ end
73
+
74
+ # For testing - immediate shutdown
75
+ def reset_async_executor!
76
+ return unless @async_executor
77
+
78
+ @async_executor.shutdown
79
+ @async_executor.kill unless @async_executor.wait_for_termination(5)
80
+ @async_executor = nil
81
+ end
82
+ end
16
83
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: listenable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Den Meralpis
@@ -23,6 +23,62 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: concurrent-ruby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activerecord
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '6.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '6.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rails
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '6.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '6.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: sqlite3
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.1'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.1'
26
82
  description: Listenable makes it easy to wire ActiveRecord models to listener classes.
27
83
  Define <Model>Listener classes in app/listeners, declare listen :on_created, :on_updated,
28
84
  etc., and Listenable automatically injects callbacks and subscribes to ActiveSupport::Notifications.
@@ -62,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
62
118
  - !ruby/object:Gem::Version
63
119
  version: '0'
64
120
  requirements: []
65
- rubygems_version: 3.7.1
121
+ rubygems_version: 3.6.9
66
122
  specification_version: 4
67
123
  summary: A Rails DSL for model event listeners using ActiveSupport::Notifications.
68
124
  test_files: []