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 +4 -4
- data/README.md +76 -0
- data/app/controllers/rails_simple_event_sourcing/application_controller.rb +6 -0
- data/app/controllers/rails_simple_event_sourcing/events_controller.rb +57 -0
- data/app/helpers/rails_simple_event_sourcing/events_helper.rb +6 -0
- data/app/models/rails_simple_event_sourcing/event.rb +26 -16
- data/app/views/layouts/rails_simple_event_sourcing/application.html.erb +64 -0
- data/app/views/rails_simple_event_sourcing/events/_pagination.html.erb +15 -0
- data/app/views/rails_simple_event_sourcing/events/index.html.erb +47 -0
- data/app/views/rails_simple_event_sourcing/events/show.html.erb +55 -0
- data/config/routes.rb +5 -1
- data/db/migrate/20231231133250_create_rails_simple_event_sourcing_events.rb +0 -1
- data/lib/rails_simple_event_sourcing/aggregate_repository.rb +0 -1
- data/lib/rails_simple_event_sourcing/command_handler.rb +6 -4
- data/lib/rails_simple_event_sourcing/command_handler_registry.rb +11 -0
- data/lib/rails_simple_event_sourcing/configuration.rb +2 -1
- data/lib/rails_simple_event_sourcing/engine.rb +2 -1
- data/lib/rails_simple_event_sourcing/event_player.rb +11 -4
- data/lib/rails_simple_event_sourcing/event_search.rb +57 -0
- data/lib/rails_simple_event_sourcing/paginator.rb +69 -0
- data/lib/rails_simple_event_sourcing/version.rb +1 -1
- metadata +11 -3
- data/lib/rails_simple_event_sourcing/event_applicator.rb +0 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9415504de52817629c90a90ad0fff708ad8f829c768ed88485b8eae53f241c18
|
|
4
|
+
data.tar.gz: 2344f7a06e5c3b9dca5d0b6c98b1526ad73088c59cde9a0c64f2b5cb6a227ec6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,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
|
|
@@ -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
|
-
|
|
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 @
|
|
66
|
+
return unless @aggregate
|
|
57
67
|
|
|
58
|
-
aggregate_repository.save!(@
|
|
59
|
-
self.aggregate_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 "← 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 "← Previous".html_safe, event_path(@previous_version) %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<%= @event.version %>
|
|
20
|
+
<% if @next_version %>
|
|
21
|
+
<%= link_to "Next →".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
|
@@ -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
|
|
@@ -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
|
-
|
|
31
|
-
handler_class =
|
|
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
|
|
39
|
-
|
|
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
|
|
10
|
-
|
|
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
|
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.
|
|
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-
|
|
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
|