rails_simple_event_sourcing 1.0.5 → 1.0.7

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: d30476e9eae518afc83f79c53c821eed02ca3029de688ec4431fa12b0b1838ac
4
- data.tar.gz: 6ae993b5cc5ec4288909560b2983b24e7786fd237249bd1737e56b64b56c1323
3
+ metadata.gz: 9415504de52817629c90a90ad0fff708ad8f829c768ed88485b8eae53f241c18
4
+ data.tar.gz: 2344f7a06e5c3b9dca5d0b6c98b1526ad73088c59cde9a0c64f2b5cb6a227ec6
5
5
  SHA512:
6
- metadata.gz: 1d00b4ec0df1f3a08f089e2ed307fc638cfbe92f7c7a5f6c54b48c3a02ed065e00aa8e0ceb421302ed2daaad6883281c6606caa7a45b88683cb9f9ce57fbf8c2
7
- data.tar.gz: fa7af20a15464beaf0cc0722cf342fc8ca96b84c9fde7c581ef8b53a0eb7ce360bc5b857be870b4cc45001595743310870803f28d526ca127159be1ec86c2e2d
6
+ metadata.gz: 3f2ec958c3dab2f9b398ed1bd0bfddec38fb13c720762ab57cc45c59091d9116f07af133601a19dae083ba5c40b32177cbfaa4809cb6af1a37cf569ed941693d
7
+ data.tar.gz: 32b8d673ca804d1ef03e5e55a561011fd052d70789afc078b9f8c7becff373d310bb2d24da411e6fc8a2fa869720a3aad487fbc4a93c5290cc2ce9a4f4d81c4d
data/README.md CHANGED
@@ -21,6 +21,7 @@ If you need a more comprehensive solution, check out:
21
21
  - [Update and Delete Operations](#update-and-delete-operations)
22
22
  - [Metadata Tracking](#metadata-tracking)
23
23
  - [Event Querying](#event-querying)
24
+ - [Events Viewer](#events-viewer)
24
25
  - [Testing](#testing)
25
26
  - [Limitations](#limitations)
26
27
  - [Troubleshooting](#troubleshooting)
@@ -38,6 +39,7 @@ If you need a more comprehensive solution, check out:
38
39
  - **Command Handler Registry** - Explicit command-to-handler mapping with fallback to convention
39
40
  - **Simple Command Pattern** - Clear command → handler → event flow
40
41
  - **PostgreSQL JSONB Storage** - Efficient JSON storage for event payloads and metadata
42
+ - **Built-in Events Viewer** - Web UI for browsing, searching, and inspecting events
41
43
  - **Minimal Configuration** - Convention over configuration approach
42
44
 
43
45
  ## Requirements
@@ -86,6 +88,9 @@ RailsSimpleEventSourcing.configure do |config|
86
88
  # When true, falls back to convention-based handler resolution
87
89
  # When false, requires explicit registration of all handlers
88
90
  config.use_naming_convention_fallback = true
91
+
92
+ # Number of events displayed per page in the Events Viewer (defaults to 25)
93
+ config.events_per_page = 50
89
94
  end
90
95
  ```
91
96
 
@@ -462,6 +467,25 @@ customer.events.where(created_at: 1.week.ago..Time.now)
462
467
  RailsSimpleEventSourcing::Event.where(eventable_type: "Customer", eventable_id: 1)
463
468
  ```
464
469
 
470
+ **Reconstructing Aggregate State:**
471
+
472
+ You can reconstruct the aggregate state as it was at any point in time by calling `aggregate_state` on an event. This replays all events for that aggregate up to and including the given event's version, returning the resulting attributes. This is useful for debugging, auditing, or understanding how an aggregate evolved over time.
473
+
474
+ ```ruby
475
+ event = customer.events.last
476
+ event.aggregate_state
477
+ # => {"id"=>1, "first_name"=>"John", "last_name"=>"Doe", "email"=>"john@example.com", ...}
478
+
479
+ # Compare state at different points in time
480
+ first_event = customer.events.first
481
+ first_event.aggregate_state
482
+ # => {"id"=>1, "first_name"=>"John", "last_name"=>"Doe", ...}
483
+
484
+ latest_event = customer.events.last
485
+ latest_event.aggregate_state
486
+ # => {"id"=>1, "first_name"=>"Jane", "last_name"=>"Smith", ...}
487
+ ```
488
+
465
489
  **Event Structure:**
466
490
  - `payload` - Contains the event attributes you defined (as JSON)
467
491
  - `metadata` - Contains request context (request ID, IP, user agent, params)
@@ -514,6 +538,58 @@ end
514
538
  **Metadata Outside HTTP Requests:**
515
539
  When events are created outside HTTP requests (background jobs, console, tests), metadata will be empty unless you manually set it using `CurrentRequest.metadata = {...}`.
516
540
 
541
+ ### Events Viewer
542
+
543
+ The gem ships with a built-in web UI for browsing and inspecting your event log. It is mounted as a Rails engine.
544
+
545
+ **Setup:**
546
+
547
+ Mount the engine in your `config/routes.rb`:
548
+
549
+ ```ruby
550
+ Rails.application.routes.draw do
551
+ mount RailsSimpleEventSourcing::Engine, at: "/event-store"
552
+ end
553
+ ```
554
+
555
+ Then navigate to `/event-store` in your browser to access the viewer.
556
+
557
+ **Password Protection:**
558
+
559
+ In production you will likely want to restrict access to the events viewer.
560
+
561
+ *Using Rack::Auth::Basic middleware:*
562
+
563
+ ```ruby
564
+ Rails.application.routes.draw do
565
+ mount Rack::Auth::Basic.new(
566
+ RailsSimpleEventSourcing::Engine,
567
+ "Event Store"
568
+ ) { |username, password|
569
+ ActiveSupport::SecurityUtils.secure_compare(username, "admin") &
570
+ ActiveSupport::SecurityUtils.secure_compare(password, Rails.application.credentials.event_sourcing_password || "secret")
571
+ }, at: "/event-store"
572
+ end
573
+ ```
574
+
575
+ *Using Devise authentication:*
576
+
577
+ ```ruby
578
+ Rails.application.routes.draw do
579
+ authenticate :user, ->(user) { user.admin? } do
580
+ mount RailsSimpleEventSourcing::Engine, at: "/event-store"
581
+ end
582
+ end
583
+ ```
584
+
585
+ **Features:**
586
+
587
+ - **Event list** - Paginated table of all events sorted by newest first, showing event type, aggregate, aggregate ID, version, and timestamp
588
+ - **Event detail** - Click any event to view its full payload, metadata, and the reconstructed aggregate state at that version
589
+ - **Version navigation** - Navigate between previous/next versions of the same aggregate from the detail page
590
+ - **Filtering** - Filter events by event type or aggregate type using dropdown selectors
591
+ - **Search** - Search by aggregate ID, or use `key:value` syntax to search within payload and metadata (e.g., `email:john@example.com`)
592
+
517
593
  ### Model Configuration
518
594
 
519
595
  Models that use event sourcing should include the `RailsSimpleEventSourcing::Events` module:
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSimpleEventSourcing
4
+ class ApplicationController < ActionController::Base
5
+ end
6
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSimpleEventSourcing
4
+ class EventsController < ApplicationController
5
+ def index
6
+ @event_types = event_types
7
+ @aggregates = aggregates
8
+ paginate(search_events)
9
+ end
10
+
11
+ def show
12
+ @event = Event.find(params[:id])
13
+ @aggregate_state = @event.aggregate_state
14
+ find_adjacent_versions
15
+ end
16
+
17
+ private
18
+
19
+ def event_types
20
+ return Event.descendants.map(&:name).sort if Rails.env.production?
21
+
22
+ Event.distinct.pluck(:event_type).sort
23
+ end
24
+
25
+ def aggregates
26
+ return Event.descendants.filter_map(&:aggregate_class).map(&:name).uniq.sort if Rails.env.production?
27
+
28
+ Event.where.not(eventable_type: nil).distinct.pluck(:eventable_type).sort
29
+ end
30
+
31
+ def search_events
32
+ EventSearch.new(
33
+ scope: Event.all,
34
+ event_type: params[:event_type],
35
+ aggregate: params[:aggregate],
36
+ query: params[:q]
37
+ ).call
38
+ end
39
+
40
+ def paginate(scope)
41
+ @paginator = Paginator.new(
42
+ scope:,
43
+ per_page: RailsSimpleEventSourcing.config.events_per_page,
44
+ cursor: params[:after] || params[:before],
45
+ direction: params[:before].present? ? :prev : :next
46
+ )
47
+ end
48
+
49
+ def find_adjacent_versions
50
+ return if @event.aggregate_id.blank?
51
+
52
+ scope = Event.where(aggregate_id: @event.aggregate_id)
53
+ @previous_version = scope.where(version: ...@event.version).order(version: :desc).first
54
+ @next_version = scope.where('version > ?', @event.version).order(version: :asc).first
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSimpleEventSourcing
4
+ module EventsHelper
5
+ end
6
+ end
@@ -9,14 +9,10 @@ module RailsSimpleEventSourcing
9
9
  belongs_to :eventable, polymorphic: true, optional: true
10
10
  alias aggregate eventable
11
11
 
12
- # Validations
13
12
  validates :version, presence: true, numericality: { only_integer: true, greater_than: 0 }
14
13
  validates :version, uniqueness: { scope: :aggregate_id }, if: -> { aggregate_id.present? }
15
14
 
16
- # Callbacks for automatic aggregate lifecycle
17
- before_validation :setup_event_fields, on: :create
18
- before_validation :apply_event_to_aggregate, on: :create, if: :aggregate_defined?
19
- before_validation :set_version
15
+ before_validation :setup_for_create, on: :create
20
16
  before_save :persist_aggregate, if: :aggregate_defined?
21
17
 
22
18
  def apply(aggregate)
@@ -25,14 +21,36 @@ module RailsSimpleEventSourcing
25
21
  end
26
22
  end
27
23
 
24
+ def aggregate_state
25
+ return unless aggregate_defined? && aggregate_id.present?
26
+
27
+ aggregate = aggregate_class.new
28
+ aggregate.id = aggregate_id
29
+ EventPlayer.new(aggregate).replay_stream(up_to_version: version)
30
+ aggregate.attributes
31
+ end
32
+
28
33
  private
29
34
 
35
+ def setup_for_create
36
+ setup_event_fields
37
+ setup_aggregate if aggregate_defined?
38
+ set_version
39
+ end
40
+
30
41
  def setup_event_fields
31
42
  enable_write_access!
32
43
  self.event_type = self.class
33
44
  self.metadata = CurrentRequest.metadata&.compact&.presence
34
45
  end
35
46
 
47
+ def setup_aggregate
48
+ @aggregate = aggregate_repository.find_or_build(aggregate_id)
49
+ @aggregate.enable_write_access!
50
+ self.eventable = @aggregate
51
+ EventPlayer.new(@aggregate).replay_and_apply(self)
52
+ end
53
+
36
54
  def set_version
37
55
  self.version ||= calculate_next_version
38
56
  end
@@ -44,19 +62,11 @@ module RailsSimpleEventSourcing
44
62
  max_version + 1
45
63
  end
46
64
 
47
- def apply_event_to_aggregate
48
- @aggregate_for_persistence = aggregate_repository.find_or_build(aggregate_id)
49
- self.eventable = @aggregate_for_persistence
50
-
51
- applicator = EventApplicator.new(self)
52
- applicator.apply_to_aggregate(@aggregate_for_persistence)
53
- end
54
-
55
65
  def persist_aggregate
56
- return unless @aggregate_for_persistence
66
+ return unless @aggregate
57
67
 
58
- aggregate_repository.save!(@aggregate_for_persistence) if aggregate_id.present?
59
- self.aggregate_id = @aggregate_for_persistence.id
68
+ aggregate_repository.save!(@aggregate)
69
+ self.aggregate_id = @aggregate.id
60
70
  end
61
71
 
62
72
  def aggregate_repository
@@ -0,0 +1,64 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Event Sourcing - Events</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #f5f7fa; color: #333; line-height: 1.6; }
10
+ .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
11
+ h1 { font-size: 1.5rem; margin-bottom: 20px; color: #1a1a2e; }
12
+ h2 { font-size: 1.25rem; margin-bottom: 16px; color: #1a1a2e; }
13
+ a { color: #4361ee; text-decoration: none; }
14
+ a:hover { text-decoration: underline; }
15
+
16
+ .header { background: #1a1a2e; color: #fff; padding: 16px 0; margin-bottom: 24px; }
17
+ .header .container { display: flex; align-items: center; justify-content: space-between; }
18
+ .header h1 { color: #fff; margin: 0; font-size: 1.2rem; }
19
+ .header a { color: #a8b2d1; }
20
+ .header a:hover { color: #fff; }
21
+
22
+ .search-form { margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
23
+ .search-form select { padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.95rem; background: #fff; }
24
+ .search-form select:focus { outline: none; border-color: #4361ee; box-shadow: 0 0 0 2px rgba(67,97,238,0.15); }
25
+ .search-form input[type="text"] { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.95rem; min-width: 200px; }
26
+ .search-form input[type="text"]:focus { outline: none; border-color: #4361ee; box-shadow: 0 0 0 2px rgba(67,97,238,0.15); }
27
+ .search-form button { padding: 8px 20px; background: #4361ee; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.95rem; }
28
+ .search-form button:hover { background: #3a56d4; }
29
+ .clear-link { padding: 8px 16px; background: #eee; border-radius: 6px; color: #333 !important; display: flex; align-items: center; }
30
+
31
+ .card { background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); padding: 20px; margin-bottom: 20px; }
32
+
33
+ table { width: 100%; border-collapse: collapse; }
34
+ th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 0.9rem; }
35
+ th { background: #f8f9fa; font-weight: 600; color: #555; text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.5px; }
36
+ tr:hover { background: #f8f9fb; }
37
+
38
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: 500; background: #e8eaf6; color: #3949ab; }
39
+ .text-muted { color: #888; font-size: 0.85rem; }
40
+ .text-mono { font-family: "SF Mono", Monaco, "Cascadia Code", monospace; font-size: 0.85rem; }
41
+
42
+ .pagination { display: flex; align-items: center; justify-content: center; gap: 4px; margin-top: 20px; flex-wrap: wrap; }
43
+ .pagination a, .pagination span { display: inline-block; padding: 6px 12px; border-radius: 4px; font-size: 0.9rem; }
44
+ .pagination a { background: #fff; border: 1px solid #ddd; color: #333; }
45
+ .pagination a:hover { background: #f0f0f0; text-decoration: none; }
46
+ .pagination .current { background: #4361ee; color: #fff; border: 1px solid #4361ee; font-weight: 600; }
47
+ .pagination .gap { border: none; color: #888; }
48
+ .pagination .disabled { color: #ccc; border-color: #eee; pointer-events: none; }
49
+
50
+ .detail-grid { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; }
51
+ .detail-grid dt { font-weight: 600; color: #555; font-size: 0.85rem; }
52
+ .detail-grid dd { font-size: 0.9rem; }
53
+ pre.json { background: #f5f7fa; border: 1px solid #e2e6ea; border-radius: 6px; padding: 12px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; }
54
+
55
+ .info-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; color: #666; font-size: 0.85rem; }
56
+ .back-link { margin-bottom: 16px; display: inline-block; font-size: 0.9rem; }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <div class="container">
61
+ <%= yield %>
62
+ </div>
63
+ </body>
64
+ </html>
@@ -0,0 +1,15 @@
1
+ <% if @paginator.prev? || @paginator.next? %>
2
+ <nav class="pagination">
3
+ <% if @paginator.prev? %>
4
+ <%= link_to "Prev", events_path(before: @paginator.prev_cursor, q: params[:q], event_type: params[:event_type], aggregate: params[:aggregate]) %>
5
+ <% else %>
6
+ <span class="disabled">Prev</span>
7
+ <% end %>
8
+
9
+ <% if @paginator.next? %>
10
+ <%= link_to "Next", events_path(after: @paginator.next_cursor, q: params[:q], event_type: params[:event_type], aggregate: params[:aggregate]) %>
11
+ <% else %>
12
+ <span class="disabled">Next</span>
13
+ <% end %>
14
+ </nav>
15
+ <% end %>
@@ -0,0 +1,47 @@
1
+ <%= form_with url: events_path, method: :get, local: true, class: "search-form" do %>
2
+ <%= select_tag :event_type, options_for_select([["All event types", ""]] + @event_types.map { |t| [t, t] }, params[:event_type]) %>
3
+ <%= select_tag :aggregate, options_for_select([["All aggregates", ""]] + @aggregates.map { |a| [a, a] }, params[:aggregate]) %>
4
+ <%= text_field_tag :q, params[:q], placeholder: "Aggregate ID or key:value from payload or metadata" %>
5
+ <button type="submit">Search</button>
6
+ <% if params[:q].present? || params[:event_type].present? || params[:aggregate].present? %>
7
+ <%= link_to "Clear", events_path, class: "clear-link" %>
8
+ <% end %>
9
+ <% end %>
10
+
11
+ <div class="card">
12
+ <div class="info-bar">
13
+ <span>Events</span>
14
+ </div>
15
+
16
+ <table>
17
+ <thead>
18
+ <tr>
19
+ <th>ID</th>
20
+ <th>Event Type</th>
21
+ <th>Aggregate</th>
22
+ <th>Aggregate ID</th>
23
+ <th>Version</th>
24
+ <th>Created At</th>
25
+ </tr>
26
+ </thead>
27
+ <tbody>
28
+ <% @paginator.records.each do |event| %>
29
+ <tr>
30
+ <td class="text-mono"><%= link_to event.id, event_path(event) %></td>
31
+ <td><span class="badge"><%= event.event_type %></span></td>
32
+ <td><%= event.aggregate_class&.name || "-" %></td>
33
+ <td class="text-mono"><%= event.aggregate_id || "-" %></td>
34
+ <td><%= event.version %></td>
35
+ <td class="text-muted"><%= event.read_attribute(:created_at)&.strftime("%Y-%m-%d %H:%M:%S") %></td>
36
+ </tr>
37
+ <% end %>
38
+ <% if @paginator.records.empty? %>
39
+ <tr>
40
+ <td colspan="6" style="text-align: center; color: #888; padding: 32px;">No events found.</td>
41
+ </tr>
42
+ <% end %>
43
+ </tbody>
44
+ </table>
45
+
46
+ <%= render "pagination" %>
47
+ </div>
@@ -0,0 +1,55 @@
1
+ <%= link_to "&larr; Back to events".html_safe, events_path(q: params[:q]), class: "back-link" %>
2
+
3
+ <div class="card">
4
+ <dl class="detail-grid">
5
+ <dt>Event Type</dt>
6
+ <dd><span class="badge"><%= @event.event_type %></span></dd>
7
+
8
+ <dt>Aggregate</dt>
9
+ <dd><%= @event.aggregate_class&.name || "-" %></dd>
10
+
11
+ <dt>Aggregate ID</dt>
12
+ <dd class="text-mono"><%= @event.aggregate_id || "-" %></dd>
13
+
14
+ <dt>Version</dt>
15
+ <dd>
16
+ <% if @previous_version %>
17
+ <%= link_to "&larr; Previous".html_safe, event_path(@previous_version) %>
18
+ <% end %>
19
+ <%= @event.version %>
20
+ <% if @next_version %>
21
+ <%= link_to "Next &rarr;".html_safe, event_path(@next_version) %>
22
+ <% end %>
23
+ </dd>
24
+
25
+ <dt>Created At</dt>
26
+ <dd><%= @event.read_attribute(:created_at)&.strftime("%Y-%m-%d %H:%M:%S %Z") %></dd>
27
+ </dl>
28
+ </div>
29
+
30
+ <h2>Payload</h2>
31
+ <div class="card">
32
+ <% if @event.payload.present? %>
33
+ <pre class="json"><%= JSON.pretty_generate(@event.payload) %></pre>
34
+ <% else %>
35
+ <p class="text-muted">No payload</p>
36
+ <% end %>
37
+ </div>
38
+
39
+ <h2>Metadata</h2>
40
+ <div class="card">
41
+ <% if @event.metadata.present? %>
42
+ <pre class="json"><%= JSON.pretty_generate(@event.metadata) %></pre>
43
+ <% else %>
44
+ <p class="text-muted">No metadata</p>
45
+ <% end %>
46
+ </div>
47
+
48
+ <h2>Aggregate State (at version <%= @event.version %>)</h2>
49
+ <div class="card">
50
+ <% if @aggregate_state.present? %>
51
+ <pre class="json"><%= JSON.pretty_generate(@aggregate_state) %></pre>
52
+ <% else %>
53
+ <p class="text-muted">No aggregate</p>
54
+ <% end %>
55
+ </div>
data/config/routes.rb CHANGED
@@ -1,2 +1,6 @@
1
- Rails.application.routes.draw do
1
+ # frozen_string_literal: true
2
+
3
+ RailsSimpleEventSourcing::Engine.routes.draw do
4
+ resources :events, only: [:index, :show]
5
+ root to: "events#index"
2
6
  end
@@ -15,7 +15,6 @@ class CreateRailsSimpleEventSourcingEvents < ActiveRecord::Migration[7.1]
15
15
 
16
16
  t.index :type
17
17
  t.index :event_type
18
- t.index :aggregate_id
19
18
  t.index %i[aggregate_id version], unique: true, name: 'index_events_on_aggregate_id_and_version'
20
19
  t.index :payload, using: :gin
21
20
  t.index :metadata, using: :gin
@@ -15,7 +15,6 @@ module RailsSimpleEventSourcing
15
15
  end
16
16
 
17
17
  def save!(aggregate)
18
- aggregate.enable_write_access!
19
18
  aggregate.save!
20
19
  end
21
20
 
@@ -27,16 +27,18 @@ module RailsSimpleEventSourcing
27
27
  handler_class = CommandHandlerRegistry.handler_for(@command.class)
28
28
 
29
29
  if handler_class.nil? && RailsSimpleEventSourcing.config.use_naming_convention_fallback
30
- handler_class_name = @command.class.to_s.gsub('::Commands::', '::CommandHandlers::')
31
- handler_class = handler_class_name.safe_constantize
30
+ @convention_handler_name = @command.class.to_s.sub('::Commands::', '::CommandHandlers::')
31
+ handler_class = @convention_handler_name.safe_constantize
32
32
  end
33
33
 
34
34
  handler_class
35
35
  end
36
36
 
37
37
  def handler_not_found_message
38
- "No handler registered for #{@command.class}. " \
39
- "Register one with CommandHandlerRegistry.register(#{@command.class}, YourHandlerClass)"
38
+ message = "No handler found for #{@command.class}."
39
+ message += " Tried convention-based lookup: #{@convention_handler_name} (not found)." if @convention_handler_name
40
+ message += " Register one with CommandHandlerRegistry.register(#{@command.class}, YourHandlerClass)"
41
+ message
40
42
  end
41
43
  end
42
44
  end
@@ -2,12 +2,23 @@
2
2
 
3
3
  module RailsSimpleEventSourcing
4
4
  class CommandHandlerRegistry
5
+ class CommandAlreadyRegisteredError < StandardError
6
+ end
7
+
5
8
  @registry = Concurrent::Map.new
6
9
 
7
10
  def self.register(command_class, handler_class)
11
+ if @registry.key?(command_class)
12
+ raise CommandAlreadyRegisteredError, "Command handler already registered for #{command_class}"
13
+ end
14
+
8
15
  @registry[command_class] = handler_class
9
16
  end
10
17
 
18
+ def self.deregister(command_class)
19
+ @registry.delete(command_class)
20
+ end
21
+
11
22
  def self.handler_for(command_class)
12
23
  @registry[command_class]
13
24
  end
@@ -2,10 +2,11 @@
2
2
 
3
3
  module RailsSimpleEventSourcing
4
4
  class Configuration
5
- attr_accessor :use_naming_convention_fallback
5
+ attr_accessor :use_naming_convention_fallback, :events_per_page
6
6
 
7
7
  def initialize
8
8
  @use_naming_convention_fallback = true
9
+ @events_per_page = 25
9
10
  end
10
11
  end
11
12
  end
@@ -4,8 +4,9 @@ require_relative 'aggregate_repository'
4
4
  require_relative 'command_handler'
5
5
  require_relative 'command_handlers/base'
6
6
  require_relative 'commands/base'
7
- require_relative 'event_applicator'
8
7
  require_relative 'event_player'
8
+ require_relative 'event_search'
9
+ require_relative 'paginator'
9
10
  require_relative 'result'
10
11
  require_relative 'command_handler_registry'
11
12
 
@@ -6,15 +6,22 @@ module RailsSimpleEventSourcing
6
6
  @aggregate = aggregate
7
7
  end
8
8
 
9
- def replay_stream
10
- events = load_event_stream
9
+ def replay_and_apply(new_event)
10
+ replay_stream unless @aggregate.new_record?
11
+ new_event.apply(@aggregate)
12
+ end
13
+
14
+ def replay_stream(up_to_version: nil)
15
+ events = load_event_stream(up_to_version:)
11
16
  apply_events(events)
12
17
  end
13
18
 
14
19
  private
15
20
 
16
- def load_event_stream
17
- @aggregate.events.order(version: :asc)
21
+ def load_event_stream(up_to_version:)
22
+ scope = @aggregate.events.order(version: :asc)
23
+ scope = scope.where(version: ..up_to_version) if up_to_version
24
+ scope
18
25
  end
19
26
 
20
27
  def apply_events(events)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSimpleEventSourcing
4
+ class EventSearch
5
+ KEY_VALUE_PATTERN = /\A([^:]+):(.+)\z/
6
+
7
+ def initialize(scope:, event_type: nil, aggregate: nil, query: nil)
8
+ @scope = scope
9
+ @event_type = event_type
10
+ @aggregate = aggregate
11
+ @query = query&.strip
12
+ end
13
+
14
+ def call
15
+ filter_by_event_type
16
+ filter_by_aggregate
17
+ filter_by_query
18
+ @scope
19
+ end
20
+
21
+ private
22
+
23
+ def filter_by_event_type
24
+ return if @event_type.blank?
25
+
26
+ @scope = @scope.where(event_type: @event_type)
27
+ end
28
+
29
+ def filter_by_aggregate
30
+ return if @aggregate.blank?
31
+
32
+ @scope = @scope.where(eventable_type: @aggregate)
33
+ end
34
+
35
+ def filter_by_query
36
+ return if @query.blank?
37
+
38
+ if (match = @query.match(KEY_VALUE_PATTERN))
39
+ filter_by_key_value(match[1], match[2])
40
+ else
41
+ filter_by_aggregate_id(@query)
42
+ end
43
+ end
44
+
45
+ def filter_by_key_value(key, value)
46
+ json_fragment = { key => value }.to_json
47
+ @scope = @scope.where(
48
+ 'payload @> :json::jsonb OR metadata @> :json::jsonb',
49
+ json: json_fragment
50
+ )
51
+ end
52
+
53
+ def filter_by_aggregate_id(value)
54
+ @scope = @scope.where(aggregate_id: value)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSimpleEventSourcing
4
+ class Paginator
5
+ attr_reader :per_page
6
+
7
+ def initialize(scope:, per_page:, cursor: nil, direction: :next)
8
+ @scope = scope
9
+ @per_page = per_page
10
+ @cursor = cursor&.to_i
11
+ @direction = direction
12
+ end
13
+
14
+ def records
15
+ @records ||= fetch_records
16
+ end
17
+
18
+ def next_cursor
19
+ records.last&.id
20
+ end
21
+
22
+ def prev_cursor
23
+ records.first&.id
24
+ end
25
+
26
+ def next?
27
+ records
28
+ @has_next
29
+ end
30
+
31
+ def prev?
32
+ records
33
+ @has_prev
34
+ end
35
+
36
+ private
37
+
38
+ def fetch_records
39
+ if @cursor.nil?
40
+ fetch_first_page
41
+ elsif @direction == :prev
42
+ fetch_prev_page
43
+ else
44
+ fetch_next_page
45
+ end
46
+ end
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)
53
+ end
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)
60
+ end
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
67
+ end
68
+ end
69
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsSimpleEventSourcing
4
- VERSION = '1.0.5'
4
+ VERSION = '1.0.7'
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.5
4
+ version: 1.0.7
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-01-25 00:00:00.000000000 Z
11
+ date: 2026-02-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg
@@ -78,6 +78,9 @@ files:
78
78
  - Rakefile
79
79
  - app/assets/config/rails_simple_event_sourcing_manifest.js
80
80
  - app/controllers/concerns/rails_simple_event_sourcing/set_current_request_details.rb
81
+ - app/controllers/rails_simple_event_sourcing/application_controller.rb
82
+ - app/controllers/rails_simple_event_sourcing/events_controller.rb
83
+ - app/helpers/rails_simple_event_sourcing/events_helper.rb
81
84
  - app/models/concerns/rails_simple_event_sourcing/aggregate_configuration.rb
82
85
  - app/models/concerns/rails_simple_event_sourcing/event_attributes.rb
83
86
  - app/models/concerns/rails_simple_event_sourcing/events.rb
@@ -85,6 +88,10 @@ files:
85
88
  - app/models/rails_simple_event_sourcing.rb
86
89
  - app/models/rails_simple_event_sourcing/current_request.rb
87
90
  - app/models/rails_simple_event_sourcing/event.rb
91
+ - app/views/layouts/rails_simple_event_sourcing/application.html.erb
92
+ - app/views/rails_simple_event_sourcing/events/_pagination.html.erb
93
+ - app/views/rails_simple_event_sourcing/events/index.html.erb
94
+ - app/views/rails_simple_event_sourcing/events/show.html.erb
88
95
  - config/routes.rb
89
96
  - db/migrate/20231231133250_create_rails_simple_event_sourcing_events.rb
90
97
  - lib/rails_simple_event_sourcing.rb
@@ -95,8 +102,9 @@ files:
95
102
  - lib/rails_simple_event_sourcing/commands/base.rb
96
103
  - lib/rails_simple_event_sourcing/configuration.rb
97
104
  - lib/rails_simple_event_sourcing/engine.rb
98
- - lib/rails_simple_event_sourcing/event_applicator.rb
99
105
  - lib/rails_simple_event_sourcing/event_player.rb
106
+ - lib/rails_simple_event_sourcing/event_search.rb
107
+ - lib/rails_simple_event_sourcing/paginator.rb
100
108
  - lib/rails_simple_event_sourcing/result.rb
101
109
  - lib/rails_simple_event_sourcing/version.rb
102
110
  - lib/tasks/rails_simple_event_sourcing_tasks.rake
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RailsSimpleEventSourcing
4
- class EventApplicator
5
- def initialize(event)
6
- @event = event
7
- end
8
-
9
- def apply_to_aggregate(aggregate)
10
- enable_aggregate_writes(aggregate)
11
- replay_history_if_needed(aggregate)
12
- apply_current_event(aggregate)
13
- end
14
-
15
- private
16
-
17
- def enable_aggregate_writes(aggregate)
18
- aggregate.enable_write_access!
19
- end
20
-
21
- def replay_history_if_needed(aggregate)
22
- return if aggregate.new_record?
23
-
24
- player = EventPlayer.new(aggregate)
25
- player.replay_stream
26
- end
27
-
28
- def apply_current_event(aggregate)
29
- @event.apply(aggregate)
30
- end
31
- end
32
- end