rails_simple_event_sourcing 1.0.10 → 1.0.12

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: 363c7b48bf081711402a94d5173386ba8e1ffd509ac33c89ce7bde1a853060c9
4
- data.tar.gz: 24d5817b59610902c2aae1a1e7b843163b0a07970c6c26cceab78ba9cb006453
3
+ metadata.gz: d389be62e627ddf2443c6e7d781d1abbab7aaacf7b908741a753e4dbe370c978
4
+ data.tar.gz: 4dea2cb2546bd903a566edff8f0fb192be5b02ae1f45f36004cf58ba7d30a64a
5
5
  SHA512:
6
- metadata.gz: 1e3265492aec66a1a4fc6727e528dcc0116682c292a6cb84b40fe8498ac87c930db16fec5c00dca3294ec840834ff18cd6795baabd6ab59e58bb9e6a54e5401a
7
- data.tar.gz: 0d4b556f0c895d4d029aedfb80ba397e78bfcf1abb050c7d10a6b9ddc1177da92658cfa0f7d7ab245583cdfdbd1af9188b61dbe2b37619895df2c11204c12b5a
6
+ metadata.gz: 2e7671967605ab8714cdb39dec920b7fc1449bf4c4adedd77cf409fa89db7497e2f462c1176decb149d9191b01cdd973e88708de836c8f7f88bef98328246608
7
+ data.tar.gz: a3543ef2e0dd176bb85099be1b4d65e9cc0f148644eb5011435920325447114218f84fff9fa6a4721d462e35c2d1568f9ba4665648ff27b26fc7d3cdab550526
data/README.md CHANGED
@@ -22,6 +22,7 @@ If you need a more comprehensive solution, check out:
22
22
  - [Metadata Tracking](#metadata-tracking)
23
23
  - [Event Querying](#event-querying)
24
24
  - [Events Viewer](#events-viewer)
25
+ - [Event Subscriptions](#event-subscriptions)
25
26
  - [Testing](#testing)
26
27
  - [Limitations](#limitations)
27
28
  - [Troubleshooting](#troubleshooting)
@@ -40,12 +41,13 @@ If you need a more comprehensive solution, check out:
40
41
  - **Simple Command Pattern** - Clear command → handler → event flow
41
42
  - **PostgreSQL JSONB Storage** - Efficient JSON storage for event payloads and metadata
42
43
  - **Built-in Events Viewer** - Web UI for browsing, searching, and inspecting events
44
+ - **Event Subscriptions** - React to events after they are committed (send emails, send webhooks, etc.)
43
45
  - **Minimal Configuration** - Convention over configuration approach
44
46
 
45
47
  ## Requirements
46
48
 
47
- - **Ruby**: 2.7 or higher
48
- - **Rails**: 6.0 or higher
49
+ - **Ruby**: 3.2 or higher
50
+ - **Rails**: 7.1.2 or higher
49
51
  - **Database**: PostgreSQL 9.4+ (requires JSONB support)
50
52
 
51
53
  ## Installation
@@ -175,6 +177,7 @@ Handlers can be discovered in two ways:
175
177
  **Result Object:**
176
178
  The `Result` class has three fields:
177
179
  - `success?` - Boolean indicating if the operation succeeded
180
+ - `failure?` - Boolean indicating if the operation failed (inverse of `success?`)
178
181
  - `data` - Data to return (usually the aggregate/model instance)
179
182
  - `errors` - Array or hash of error messages when `success?` is false
180
183
 
@@ -192,7 +195,7 @@ It supports a chainable API for use in controllers:
192
195
  - `on_success { |data| }` - Executes the block (yielding `data`) if the result is successful
193
196
  - `on_failure { |errors| }` - Executes the block (yielding `errors`) if the result failed
194
197
 
195
- Both methods return `self`, so they can be chained. The predicate `success?` remains available for use in conditionals and tests.
198
+ Both methods return `self`, so they can be chained. The predicates `success?` and `failure?` remain available for use in conditionals and tests.
196
199
 
197
200
  ```ruby
198
201
  result = RailsSimpleEventSourcing::Result.success(data: customer)
@@ -203,9 +206,10 @@ result
203
206
  .on_failure { |errors| render json: { errors: }, status: :unprocessable_entity }
204
207
 
205
208
  # Predicate (preferred in tests)
206
- result.success? # => true
207
- result.data # => #<Customer ...>
208
- result.errors # => nil
209
+ result.success? # => true
210
+ result.failure? # => false
211
+ result.data # => #<Customer ...>
212
+ result.errors # => nil
209
213
  ```
210
214
 
211
215
  **Helper Methods in Handlers:**
@@ -221,9 +225,9 @@ class Customer
221
225
  class Create < RailsSimpleEventSourcing::CommandHandlers::Base
222
226
  def call
223
227
  event = Customer::Events::CustomerCreated.create(
224
- first_name: @command.first_name,
225
- last_name: @command.last_name,
226
- email: @command.email,
228
+ first_name: command.first_name,
229
+ last_name: command.last_name,
230
+ email: command.email,
227
231
  created_at: Time.zone.now,
228
232
  updated_at: Time.zone.now
229
233
  )
@@ -243,9 +247,9 @@ class Customer
243
247
  class Create < RailsSimpleEventSourcing::CommandHandlers::Base
244
248
  def call
245
249
  event = Customer::Events::CustomerCreated.create(
246
- first_name: @command.first_name,
247
- last_name: @command.last_name,
248
- email: @command.email,
250
+ first_name: command.first_name,
251
+ last_name: command.last_name,
252
+ email: command.email,
249
253
  created_at: Time.zone.now,
250
254
  updated_at: Time.zone.now
251
255
  )
@@ -667,6 +671,86 @@ class Customer::Events::CustomerDeleted < RailsSimpleEventSourcing::Event
667
671
  end
668
672
  ```
669
673
 
674
+ ### Event Subscriptions
675
+
676
+ The `EventBus` lets you react to events after they are persisted and committed to the database. Subscribers run **after the transaction commits**, so they never execute against data that could later be rolled back.
677
+
678
+ **Registering subscribers:**
679
+
680
+ ```ruby
681
+ # config/initializers/rails_simple_event_sourcing.rb
682
+ Rails.application.config.after_initialize do
683
+ RailsSimpleEventSourcing::EventBus.subscribe(
684
+ Customer::Events::CustomerCreated,
685
+ Subscribers::SendWelcomeEmail
686
+ )
687
+
688
+ RailsSimpleEventSourcing::EventBus.subscribe(
689
+ Customer::Events::CustomerCreated,
690
+ Subscribers::CreateStripeCustomer
691
+ )
692
+
693
+ RailsSimpleEventSourcing::EventBus.subscribe(
694
+ Customer::Events::CustomerDeleted,
695
+ Subscribers::CancelStripeSubscription
696
+ )
697
+ end
698
+ ```
699
+
700
+ **Writing a subscriber:**
701
+
702
+ Any object that responds to `call(event)` works — a class with `.call`, a lambda, or a proc:
703
+
704
+ ```ruby
705
+ module Subscribers
706
+ class SendWelcomeEmail
707
+ def self.call(event)
708
+ WelcomeMailer.with(email: event.email).deliver_later
709
+ end
710
+ end
711
+ end
712
+ ```
713
+
714
+ **Subscribing to all events:**
715
+
716
+ Subscribe to `RailsSimpleEventSourcing::Event` to receive every event regardless of type — useful for audit loggers or metrics:
717
+
718
+ ```ruby
719
+ RailsSimpleEventSourcing::EventBus.subscribe(
720
+ RailsSimpleEventSourcing::Event,
721
+ Subscribers::AuditLogger
722
+ )
723
+ ```
724
+
725
+ If you subscribe the same callable to both a specific event class and `RailsSimpleEventSourcing::Event`, it will be called twice — once for each subscription. This is intentional and consistent with standard pub/sub behaviour.
726
+
727
+ **Testing with EventBus:**
728
+
729
+ Call `RailsSimpleEventSourcing::EventBus.reset!` in your test `setup` to clear all subscriptions between tests:
730
+
731
+ ```ruby
732
+ class MyTest < ActiveSupport::TestCase
733
+ setup do
734
+ RailsSimpleEventSourcing::EventBus.reset!
735
+ end
736
+
737
+ test "sends welcome email on customer created" do
738
+ emails = []
739
+ RailsSimpleEventSourcing::EventBus.subscribe(
740
+ Customer::Events::CustomerCreated,
741
+ ->(event) { emails << event.email }
742
+ )
743
+
744
+ Customer::Events::CustomerCreated.create!(
745
+ first_name: "John", last_name: "Doe", email: "john@example.com",
746
+ created_at: Time.zone.now, updated_at: Time.zone.now
747
+ )
748
+
749
+ assert_includes emails, "john@example.com"
750
+ end
751
+ end
752
+ ```
753
+
670
754
  ## Testing
671
755
 
672
756
  ### Setting Up Tests with Command Handler Registry
@@ -6,7 +6,7 @@ module RailsSimpleEventSourcing
6
6
 
7
7
  included do
8
8
  def readonly?
9
- super || !write_access_enabled
9
+ super || !write_access_enabled?
10
10
  end
11
11
 
12
12
  def enable_write_access!
@@ -19,8 +19,8 @@ module RailsSimpleEventSourcing
19
19
 
20
20
  private
21
21
 
22
- def write_access_enabled
23
- @write_access_enabled ||= false
22
+ def write_access_enabled?
23
+ @write_access_enabled == true
24
24
  end
25
25
  end
26
26
  end
@@ -20,6 +20,7 @@ module RailsSimpleEventSourcing
20
20
  before_validation :setup_for_create, on: :create
21
21
  before_save :persist_aggregate, if: :aggregate_defined?
22
22
  after_create :disable_write_access!
23
+ after_commit :dispatch_to_event_bus, on: :create
23
24
 
24
25
  def apply(aggregate)
25
26
  payload.each do |key, value|
@@ -81,5 +82,9 @@ module RailsSimpleEventSourcing
81
82
  def aggregate_repository
82
83
  @aggregate_repository ||= AggregateRepository.new(aggregate_class)
83
84
  end
85
+
86
+ def dispatch_to_event_bus
87
+ EventBus.dispatch(self)
88
+ end
84
89
  end
85
90
  end
@@ -7,7 +7,7 @@ class CreateRailsSimpleEventSourcingEvents < ActiveRecord::Migration[7.1]
7
7
  t.string :type, null: false
8
8
  t.string :event_type, null: false
9
9
  t.string :aggregate_id
10
- t.integer :version
10
+ t.bigint :version
11
11
  t.jsonb :payload
12
12
  t.jsonb :metadata
13
13
 
@@ -15,7 +15,9 @@ class CreateRailsSimpleEventSourcingEvents < ActiveRecord::Migration[7.1]
15
15
 
16
16
  t.index :type
17
17
  t.index :event_type
18
- t.index %i[aggregate_id version], unique: true, name: 'index_events_on_aggregate_id_and_version'
18
+ t.index %i[eventable_type aggregate_id version],
19
+ unique: true,
20
+ name: 'index_events_on_eventable_type_and_aggregate_id_and_version'
19
21
  t.index :payload, using: :gin
20
22
  t.index :metadata, using: :gin
21
23
  end
@@ -21,7 +21,7 @@ module RailsSimpleEventSourcing
21
21
  private
22
22
 
23
23
  def find_with_lock(aggregate_id)
24
- @aggregate_class.find(aggregate_id).lock!
24
+ @aggregate_class.lock.find(aggregate_id)
25
25
  end
26
26
 
27
27
  def build_new
@@ -3,8 +3,6 @@
3
3
  module RailsSimpleEventSourcing
4
4
  module CommandHandlers
5
5
  class Base
6
- delegate :success, :failure, to: 'RailsSimpleEventSourcing::Result'
7
-
8
6
  def initialize(command:)
9
7
  @command = command
10
8
  end
@@ -12,6 +10,18 @@ module RailsSimpleEventSourcing
12
10
  def call
13
11
  raise NotImplementedError, "You must implement #{self.class}#call"
14
12
  end
13
+
14
+ private
15
+
16
+ attr_reader :command
17
+
18
+ def success(data: nil)
19
+ Result.success(data:)
20
+ end
21
+
22
+ def failure(errors:)
23
+ Result.failure(errors:)
24
+ end
15
25
  end
16
26
  end
17
27
  end
@@ -10,6 +10,7 @@ require_relative 'event_search'
10
10
  require_relative 'paginator'
11
11
  require_relative 'result'
12
12
  require_relative 'command_handler_registry'
13
+ require_relative 'event_bus'
13
14
 
14
15
  module RailsSimpleEventSourcing
15
16
  class Engine < ::Rails::Engine
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSimpleEventSourcing
4
+ class EventBus
5
+ @subscriptions = Concurrent::Map.new { |h, k| h[k] = Concurrent::Array.new }
6
+
7
+ class << self
8
+ def subscribe(event_class, subscriber)
9
+ @subscriptions[event_class.to_s] << subscriber
10
+ end
11
+
12
+ def dispatch(event)
13
+ ancestors_with_subscriptions(event).each do |subscriber|
14
+ subscriber.call(event)
15
+ end
16
+ end
17
+
18
+ def reset!
19
+ @subscriptions = Concurrent::Map.new { |h, k| h[k] = Concurrent::Array.new }
20
+ end
21
+
22
+ private
23
+
24
+ def ancestors_with_subscriptions(event)
25
+ event.class.ancestors
26
+ .select { |ancestor| @subscriptions.key?(ancestor.to_s) }
27
+ .flat_map { |ancestor| @subscriptions[ancestor.to_s] }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -37,33 +37,31 @@ module RailsSimpleEventSourcing
37
37
 
38
38
  def fetch_records
39
39
  if @cursor.nil?
40
- fetch_first_page
40
+ fetch_forward(@scope.order(id: :desc), has_prev: false)
41
41
  elsif @direction == :prev
42
- fetch_prev_page
42
+ fetch_backward(@scope.where('id > ?', @cursor).order(id: :asc))
43
43
  else
44
- fetch_next_page
44
+ fetch_forward(@scope.where(id: ...@cursor).order(id: :desc), has_prev: true)
45
45
  end
46
46
  end
47
47
 
48
- def fetch_first_page
49
- rows = @scope.order(id: :desc).limit(@per_page + 1).to_a
50
- @has_prev = false
51
- @has_next = rows.size > @per_page
52
- rows.first(@per_page)
48
+ def fetch_forward(scoped_query, has_prev:)
49
+ rows, has_more = paginate(scoped_query)
50
+ @has_prev = has_prev
51
+ @has_next = has_more
52
+ rows
53
53
  end
54
54
 
55
- def fetch_next_page
56
- rows = @scope.where(id: ...@cursor).order(id: :desc).limit(@per_page + 1).to_a
57
- @has_prev = true
58
- @has_next = rows.size > @per_page
59
- rows.first(@per_page)
55
+ def fetch_backward(scoped_query)
56
+ rows, has_more = paginate(scoped_query)
57
+ @has_next = true
58
+ @has_prev = has_more
59
+ rows.reverse
60
60
  end
61
61
 
62
- def fetch_prev_page
63
- rows = @scope.where('id > ?', @cursor).order(id: :asc).limit(@per_page + 1).to_a
64
- @has_next = true
65
- @has_prev = rows.size > @per_page
66
- rows.first(@per_page).reverse
62
+ def paginate(scoped_query)
63
+ rows = scoped_query.limit(@per_page + 1).to_a
64
+ [rows.first(@per_page), rows.size > @per_page]
67
65
  end
68
66
  end
69
67
  end
@@ -25,6 +25,10 @@ module RailsSimpleEventSourcing
25
25
  @success
26
26
  end
27
27
 
28
+ def failure?
29
+ !@success
30
+ end
31
+
28
32
  def on_success(&block)
29
33
  raise ArgumentError, 'Block required' unless block
30
34
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsSimpleEventSourcing
4
- VERSION = '1.0.10'
4
+ VERSION = '1.0.12'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_simple_event_sourcing
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.10
4
+ version: 1.0.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Damian Baćkowski
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-08 00:00:00.000000000 Z
11
+ date: 2026-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg
@@ -94,7 +94,6 @@ files:
94
94
  - app/controllers/concerns/rails_simple_event_sourcing/set_current_request_details.rb
95
95
  - app/controllers/rails_simple_event_sourcing/application_controller.rb
96
96
  - app/controllers/rails_simple_event_sourcing/events_controller.rb
97
- - app/helpers/rails_simple_event_sourcing/events_helper.rb
98
97
  - app/models/concerns/rails_simple_event_sourcing/aggregate_configuration.rb
99
98
  - app/models/concerns/rails_simple_event_sourcing/event_attributes.rb
100
99
  - app/models/concerns/rails_simple_event_sourcing/events.rb
@@ -116,6 +115,7 @@ files:
116
115
  - lib/rails_simple_event_sourcing/commands/base.rb
117
116
  - lib/rails_simple_event_sourcing/configuration.rb
118
117
  - lib/rails_simple_event_sourcing/engine.rb
118
+ - lib/rails_simple_event_sourcing/event_bus.rb
119
119
  - lib/rails_simple_event_sourcing/event_player.rb
120
120
  - lib/rails_simple_event_sourcing/event_search.rb
121
121
  - lib/rails_simple_event_sourcing/paginator.rb
@@ -135,7 +135,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
135
135
  requirements:
136
136
  - - ">="
137
137
  - !ruby/object:Gem::Version
138
- version: '0'
138
+ version: '3.2'
139
139
  required_rubygems_version: !ruby/object:Gem::Requirement
140
140
  requirements:
141
141
  - - ">="
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RailsSimpleEventSourcing
4
- module EventsHelper
5
- end
6
- end