rails_simple_event_sourcing 1.1.0 → 1.1.1
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 +271 -33
- data/app/models/concerns/rails_simple_event_sourcing/aggregate_configuration.rb +1 -3
- data/app/models/concerns/rails_simple_event_sourcing/events.rb +15 -1
- data/app/models/rails_simple_event_sourcing/event.rb +10 -2
- data/app/models/rails_simple_event_sourcing/snapshot.rb +38 -0
- data/db/migrate/20260328000000_create_rails_simple_event_sourcing_snapshots.rb +20 -0
- data/lib/rails_simple_event_sourcing/configuration.rb +11 -0
- data/lib/rails_simple_event_sourcing/event_bus.rb +4 -0
- data/lib/rails_simple_event_sourcing/event_player.rb +29 -1
- data/lib/rails_simple_event_sourcing/version.rb +1 -1
- metadata +8 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97abf701a07288f6fc66a33c3cc064810ad7a5ea5c7f798be4f85a96f839722a
|
|
4
|
+
data.tar.gz: '08c7fe14306245b4f0a3b59e5309aa9de4b4a8dd76b4f989d6f7e5cc02962f0e'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e0a8dd9298aacb9b1a5f414dc70c27f6ba657e77ec8db90f8ecff6b8b5ff085197cdca911117e73dbab3046504a0d857bbba4984f39124373920444af6ad904d
|
|
7
|
+
data.tar.gz: 64ebd4e3f398a7bee7e4e118c4d6df301dea1e85d214c139f763d4755eea69161208684c59b834a8d743bf85cf38b6d7ddc026aed5f9d113feea325bb4f227e6
|
data/README.md
CHANGED
|
@@ -18,6 +18,8 @@ If you need a more comprehensive solution, check out:
|
|
|
18
18
|
- [Commands](#commands)
|
|
19
19
|
- [Command Handlers](#command-handlers)
|
|
20
20
|
- [Events](#events)
|
|
21
|
+
- [Model Configuration](#model-configuration)
|
|
22
|
+
- [Immutability and Read-Only Protection](#immutability-and-read-only-protection)
|
|
21
23
|
- [Registering Command Handlers](#registering-command-handlers)
|
|
22
24
|
- [Controller Integration](#controller-integration)
|
|
23
25
|
- [Update and Delete Operations](#update-and-delete-operations)
|
|
@@ -27,6 +29,7 @@ If you need a more comprehensive solution, check out:
|
|
|
27
29
|
- [Adding Event Sourcing to an Existing Model](#adding-event-sourcing-to-an-existing-model)
|
|
28
30
|
- [Event Subscriptions](#event-subscriptions)
|
|
29
31
|
- [Event Schema Versioning](#event-schema-versioning)
|
|
32
|
+
- [Snapshots](#snapshots)
|
|
30
33
|
- [Testing](#testing)
|
|
31
34
|
- [Limitations](#limitations)
|
|
32
35
|
- [Troubleshooting](#troubleshooting)
|
|
@@ -47,12 +50,13 @@ If you need a more comprehensive solution, check out:
|
|
|
47
50
|
- **Built-in Events Viewer** - Web UI for browsing, searching, and inspecting events
|
|
48
51
|
- **Event Subscriptions** - React to events after they are committed (send emails, send webhooks, etc.)
|
|
49
52
|
- **Event Schema Versioning** - Built-in upcasting to evolve event schemas without modifying stored data
|
|
53
|
+
- **Snapshot Support** - Optional snapshots to speed up aggregate reconstruction for long event streams
|
|
50
54
|
- **Minimal Configuration** - Convention over configuration approach
|
|
51
55
|
|
|
52
56
|
## Requirements
|
|
53
57
|
|
|
54
58
|
- **Ruby**: 3.2 or higher
|
|
55
|
-
- **Rails**: 7.
|
|
59
|
+
- **Rails**: 7.2.0 or higher
|
|
56
60
|
- **Database**: PostgreSQL 9.4+ (requires JSONB support)
|
|
57
61
|
|
|
58
62
|
## Installation
|
|
@@ -98,6 +102,12 @@ RailsSimpleEventSourcing.configure do |config|
|
|
|
98
102
|
|
|
99
103
|
# Number of events displayed per page in the Events Viewer (defaults to 25)
|
|
100
104
|
config.events_per_page = 50
|
|
105
|
+
|
|
106
|
+
# Automatically create a snapshot every N events per aggregate (defaults to nil = disabled)
|
|
107
|
+
# With snapshot_interval = 50, a snapshot is written after every 50th event.
|
|
108
|
+
# EventPlayer will load the nearest snapshot and replay only the delta,
|
|
109
|
+
# instead of replaying the full event history from the beginning.
|
|
110
|
+
config.snapshot_interval = 50
|
|
101
111
|
end
|
|
102
112
|
```
|
|
103
113
|
|
|
@@ -126,7 +136,7 @@ flowchart TD
|
|
|
126
136
|
4. **Event** - Immutable record of what happened
|
|
127
137
|
5. **Aggregate** - Model updated via event
|
|
128
138
|
6. **EventBus** - After the transaction commits, enqueues subscriber jobs
|
|
129
|
-
7. **Subscribers** - ActiveJob classes that react to events asynchronously (send emails, sync external systems, etc.)
|
|
139
|
+
7. **Subscribers** - ActiveJob classes that react to events asynchronously (send emails, sync external systems, etc.). See [Event Subscriptions](#event-subscriptions) for setup details
|
|
130
140
|
|
|
131
141
|
### Directory Structure
|
|
132
142
|
|
|
@@ -407,6 +417,28 @@ end
|
|
|
407
417
|
|
|
408
418
|
This is useful for recording domain-significant occurrences that don't map to a state change on a model.
|
|
409
419
|
|
|
420
|
+
### Model Configuration
|
|
421
|
+
|
|
422
|
+
Models that use event sourcing should include the `RailsSimpleEventSourcing::Events` module:
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
class Customer < ApplicationRecord
|
|
426
|
+
include RailsSimpleEventSourcing::Events
|
|
427
|
+
end
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
**This provides:**
|
|
431
|
+
- `.events` association - Access all events for this aggregate
|
|
432
|
+
- Read-only protection - Prevents accidental direct modifications
|
|
433
|
+
- Event replay capability - Reconstruct state from events
|
|
434
|
+
|
|
435
|
+
### Immutability and Read-Only Protection
|
|
436
|
+
|
|
437
|
+
**Important Principles:**
|
|
438
|
+
- **Events are immutable** - Once created, events should never be modified
|
|
439
|
+
- **Models are read-only** - Aggregates should only be modified through events
|
|
440
|
+
- Both have built-in protection against accidental changes
|
|
441
|
+
|
|
410
442
|
### Registering Command Handlers
|
|
411
443
|
|
|
412
444
|
The recommended approach is to register command handlers explicitly using the registry. This makes the command-to-handler mapping explicit and avoids relying on naming conventions.
|
|
@@ -463,6 +495,58 @@ end
|
|
|
463
495
|
|
|
464
496
|
**Update Example:**
|
|
465
497
|
|
|
498
|
+
Command — same structure as create, but inherits `aggregate_id` from the base class:
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
class Customer
|
|
502
|
+
module Commands
|
|
503
|
+
class Update < RailsSimpleEventSourcing::Commands::Base
|
|
504
|
+
attr_accessor :first_name, :last_name, :email
|
|
505
|
+
|
|
506
|
+
validates :first_name, presence: true
|
|
507
|
+
validates :last_name, presence: true
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Event:
|
|
514
|
+
|
|
515
|
+
```ruby
|
|
516
|
+
class Customer
|
|
517
|
+
module Events
|
|
518
|
+
class CustomerUpdated < RailsSimpleEventSourcing::Event
|
|
519
|
+
aggregate_class Customer
|
|
520
|
+
event_attributes :first_name, :last_name, :email, :updated_at
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Handler — pass `aggregate_id` to the event so it knows which aggregate to replay and update:
|
|
527
|
+
|
|
528
|
+
```ruby
|
|
529
|
+
class Customer
|
|
530
|
+
module CommandHandlers
|
|
531
|
+
class Update < RailsSimpleEventSourcing::CommandHandlers::Base
|
|
532
|
+
def call
|
|
533
|
+
event = Customer::Events::CustomerUpdated.create!(
|
|
534
|
+
aggregate_id: command.aggregate_id,
|
|
535
|
+
first_name: command.first_name,
|
|
536
|
+
last_name: command.last_name,
|
|
537
|
+
email: command.email,
|
|
538
|
+
updated_at: Time.zone.now
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
success(data: event.aggregate)
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Controller:
|
|
549
|
+
|
|
466
550
|
```ruby
|
|
467
551
|
class CustomersController < ApplicationController
|
|
468
552
|
def update
|
|
@@ -482,6 +566,51 @@ end
|
|
|
482
566
|
|
|
483
567
|
**Delete Example:**
|
|
484
568
|
|
|
569
|
+
Command — only `aggregate_id` is needed (inherited from base class, no extra attributes):
|
|
570
|
+
|
|
571
|
+
```ruby
|
|
572
|
+
class Customer
|
|
573
|
+
module Commands
|
|
574
|
+
class Delete < RailsSimpleEventSourcing::Commands::Base
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Event — uses a soft delete pattern; sets `deleted_at` instead of removing the record:
|
|
581
|
+
|
|
582
|
+
```ruby
|
|
583
|
+
class Customer
|
|
584
|
+
module Events
|
|
585
|
+
class CustomerDeleted < RailsSimpleEventSourcing::Event
|
|
586
|
+
aggregate_class Customer
|
|
587
|
+
event_attributes :deleted_at
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Handler:
|
|
594
|
+
|
|
595
|
+
```ruby
|
|
596
|
+
class Customer
|
|
597
|
+
module CommandHandlers
|
|
598
|
+
class Delete < RailsSimpleEventSourcing::CommandHandlers::Base
|
|
599
|
+
def call
|
|
600
|
+
Customer::Events::CustomerDeleted.create!(
|
|
601
|
+
aggregate_id: command.aggregate_id,
|
|
602
|
+
deleted_at: Time.zone.now
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
success
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
Controller:
|
|
613
|
+
|
|
485
614
|
```ruby
|
|
486
615
|
class CustomersController < ApplicationController
|
|
487
616
|
def destroy
|
|
@@ -494,7 +623,7 @@ class CustomersController < ApplicationController
|
|
|
494
623
|
end
|
|
495
624
|
```
|
|
496
625
|
|
|
497
|
-
**Important:** For update and delete operations, you must pass `aggregate_id` to identify which record to modify.
|
|
626
|
+
**Important:** For update and delete operations, you must pass `aggregate_id` to identify which record to modify. The event handler receives this via `command.aggregate_id` and uses it to find and replay the correct aggregate before applying the new event.
|
|
498
627
|
|
|
499
628
|
### Testing the API
|
|
500
629
|
|
|
@@ -633,7 +762,29 @@ end
|
|
|
633
762
|
```
|
|
634
763
|
|
|
635
764
|
**Metadata Outside HTTP Requests:**
|
|
636
|
-
|
|
765
|
+
|
|
766
|
+
When events are created outside HTTP requests (background jobs, rake tasks, console), metadata will be empty unless you set it manually. `CurrentRequest` is backed by `ActiveSupport::CurrentAttributes`, which resets automatically between requests and jobs, so you must set it at the start of each execution:
|
|
767
|
+
|
|
768
|
+
```ruby
|
|
769
|
+
class CustomerImportJob < ApplicationJob
|
|
770
|
+
def perform(row)
|
|
771
|
+
RailsSimpleEventSourcing::CurrentRequest.metadata = {
|
|
772
|
+
job: self.class.name,
|
|
773
|
+
job_id: job_id
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
RailsSimpleEventSourcing.dispatch(
|
|
777
|
+
Customer::Commands::Create.new(
|
|
778
|
+
first_name: row[:first_name],
|
|
779
|
+
last_name: row[:last_name],
|
|
780
|
+
email: row[:email]
|
|
781
|
+
)
|
|
782
|
+
)
|
|
783
|
+
end
|
|
784
|
+
end
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
Any events dispatched during that job execution will carry the metadata you set. You do not need to clear it afterwards — `CurrentAttributes` resets automatically when the job finishes.
|
|
637
788
|
|
|
638
789
|
### Events Viewer
|
|
639
790
|
|
|
@@ -687,37 +838,23 @@ end
|
|
|
687
838
|
- **Filtering** - Filter events by event type or aggregate type using dropdown selectors
|
|
688
839
|
- **Search** - Search by aggregate ID, or use `key:value` syntax to search within payload and metadata (e.g., `email:john@example.com`)
|
|
689
840
|
|
|
690
|
-
### Model Configuration
|
|
691
|
-
|
|
692
|
-
Models that use event sourcing should include the `RailsSimpleEventSourcing::Events` module:
|
|
693
|
-
|
|
694
|
-
```ruby
|
|
695
|
-
class Customer < ApplicationRecord
|
|
696
|
-
include RailsSimpleEventSourcing::Events
|
|
697
|
-
end
|
|
698
|
-
```
|
|
699
|
-
|
|
700
|
-
**This provides:**
|
|
701
|
-
- `.events` association - Access all events for this aggregate
|
|
702
|
-
- Read-only protection - Prevents accidental direct modifications
|
|
703
|
-
- Event replay capability - Reconstruct state from events
|
|
704
|
-
|
|
705
|
-
### Immutability and Read-Only Protection
|
|
706
|
-
|
|
707
|
-
**Important Principles:**
|
|
708
|
-
- **Events are immutable** - Once created, events should never be modified
|
|
709
|
-
- **Models are read-only** - Aggregates should only be modified through events
|
|
710
|
-
- Both have built-in protection against accidental changes
|
|
711
|
-
|
|
712
841
|
### Soft Deletes
|
|
713
842
|
|
|
714
843
|
**Recommendation:** Use soft deletes instead of hard deletes to preserve event history.
|
|
715
844
|
|
|
716
845
|
**Why?**
|
|
717
|
-
- Events are linked to aggregates via
|
|
718
|
-
- Hard deleting a record
|
|
719
|
-
-
|
|
720
|
-
|
|
846
|
+
- Events are linked to aggregates via a polymorphic association
|
|
847
|
+
- Hard deleting a record breaks the link between the aggregate and its events
|
|
848
|
+
- The event log becomes incomplete and historical state cannot be reconstructed
|
|
849
|
+
|
|
850
|
+
**Hard deletes are blocked by default.** The `RailsSimpleEventSourcing::Events` concern declares `dependent: :restrict_with_exception`, so calling `destroy` on an aggregate that has any events raises `ActiveRecord::DeleteRestrictionError`. This makes the "don't hard-delete" rule a runtime error rather than a silent data-integrity drift.
|
|
851
|
+
|
|
852
|
+
If you genuinely need to erase an aggregate and its event history (e.g. GDPR erasure), do it explicitly:
|
|
853
|
+
|
|
854
|
+
```ruby
|
|
855
|
+
customer.events.delete_all
|
|
856
|
+
customer.delete
|
|
857
|
+
```
|
|
721
858
|
|
|
722
859
|
**How to implement:**
|
|
723
860
|
|
|
@@ -738,12 +875,22 @@ class Customer < ApplicationRecord
|
|
|
738
875
|
scope :deleted, -> { where.not(deleted_at: nil) }
|
|
739
876
|
end
|
|
740
877
|
|
|
741
|
-
# Event
|
|
878
|
+
# Event - deleted_at is applied to the aggregate automatically via event_attributes
|
|
742
879
|
class Customer::Events::CustomerDeleted < RailsSimpleEventSourcing::Event
|
|
743
880
|
aggregate_class Customer
|
|
744
881
|
event_attributes :deleted_at
|
|
882
|
+
end
|
|
745
883
|
|
|
746
|
-
|
|
884
|
+
# Handler
|
|
885
|
+
class Customer::CommandHandlers::Delete < RailsSimpleEventSourcing::CommandHandlers::Base
|
|
886
|
+
def call
|
|
887
|
+
Customer::Events::CustomerDeleted.create!(
|
|
888
|
+
aggregate_id: command.aggregate_id,
|
|
889
|
+
deleted_at: Time.zone.now
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
success
|
|
893
|
+
end
|
|
747
894
|
end
|
|
748
895
|
```
|
|
749
896
|
|
|
@@ -865,6 +1012,27 @@ RailsSimpleEventSourcing::EventBus.subscribe(
|
|
|
865
1012
|
|
|
866
1013
|
If you subscribe the same job to both a specific event class and `RailsSimpleEventSourcing::Event`, it will be enqueued twice — once for each subscription. This is intentional and consistent with standard pub/sub behaviour.
|
|
867
1014
|
|
|
1015
|
+
**Delivery guarantees:**
|
|
1016
|
+
|
|
1017
|
+
Subscribers are enqueued via `perform_later` in an `after_commit` callback. This means delivery is **at-most-once** by default — if the queue backend (Redis, SQS, etc.) is temporarily unavailable when `perform_later` is called, the job is lost. The EventBus logs these failures individually and continues enqueuing remaining subscribers, so a single failure does not block other subscribers from being dispatched.
|
|
1018
|
+
|
|
1019
|
+
For stronger guarantees, consider using a database-backed queue adapter such as [solid_queue](https://github.com/rails/solid_queue) or [good_job](https://github.com/bensheldon/good_job). Since these adapters write job records to PostgreSQL, the enqueue is durable as soon as the transaction commits — eliminating the window where a network blip can lose a job.
|
|
1020
|
+
|
|
1021
|
+
**Error handling:**
|
|
1022
|
+
|
|
1023
|
+
Subscribers are dispatched as ActiveJob jobs. Use standard ActiveJob error handling to configure retries and failure behavior:
|
|
1024
|
+
|
|
1025
|
+
```ruby
|
|
1026
|
+
class SendWelcomeEmailJob < ApplicationJob
|
|
1027
|
+
retry_on Net::OpenError, wait: :polynomially_longer, attempts: 5
|
|
1028
|
+
discard_on ActiveJob::DeserializationError
|
|
1029
|
+
|
|
1030
|
+
def perform(event)
|
|
1031
|
+
# ...
|
|
1032
|
+
end
|
|
1033
|
+
end
|
|
1034
|
+
```
|
|
1035
|
+
|
|
868
1036
|
**Testing with EventBus:**
|
|
869
1037
|
|
|
870
1038
|
Use `ActiveJob::TestHelper` to assert that subscriber jobs are enqueued. Call `RailsSimpleEventSourcing::EventBus.reset!` in your test `setup` to clear all subscriptions between tests:
|
|
@@ -965,6 +1133,76 @@ end
|
|
|
965
1133
|
- Upcasting is transparent to `apply` — custom or default `apply` methods receive the already-upcasted payload
|
|
966
1134
|
- Events without `aggregate_class` do not use schema versioning (the `schema_version` column is left `nil`)
|
|
967
1135
|
|
|
1136
|
+
### Snapshots
|
|
1137
|
+
|
|
1138
|
+
By default, every aggregate reconstruction replays its full event history from the beginning. For aggregates with many events this can become slow. Snapshots store the aggregate's serialized state at a given version so that `EventPlayer` only needs to replay events written after the snapshot.
|
|
1139
|
+
|
|
1140
|
+
**Automatic snapshots:**
|
|
1141
|
+
|
|
1142
|
+
Set `snapshot_interval` in your initializer to have a snapshot created automatically every N events:
|
|
1143
|
+
|
|
1144
|
+
```ruby
|
|
1145
|
+
# config/initializers/rails_simple_event_sourcing.rb
|
|
1146
|
+
RailsSimpleEventSourcing.configure do |config|
|
|
1147
|
+
config.snapshot_interval = 50
|
|
1148
|
+
end
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
With `snapshot_interval = 50`, a snapshot is written after event version 50, 100, 150, and so on. On the next reconstruction, `EventPlayer` loads the snapshot at version 100 and replays only events 101 onwards — at most 49 events instead of all 100+.
|
|
1152
|
+
|
|
1153
|
+
**Choosing a snapshot interval:**
|
|
1154
|
+
|
|
1155
|
+
The right interval depends on your event replay cost. A lower interval (e.g., 20) means faster reconstruction but more snapshot writes. A higher interval (e.g., 200) saves writes but replays more events. For most applications, 50–100 is a reasonable starting point.
|
|
1156
|
+
|
|
1157
|
+
**Manual snapshots:**
|
|
1158
|
+
|
|
1159
|
+
Call `create_snapshot!` on any aggregate to write a snapshot immediately at the current version:
|
|
1160
|
+
|
|
1161
|
+
```ruby
|
|
1162
|
+
customer = Customer.find(params[:id])
|
|
1163
|
+
customer.create_snapshot!
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
This is useful after bulk imports or data migrations, where you want to pre-warm snapshots for all existing aggregates:
|
|
1167
|
+
|
|
1168
|
+
```ruby
|
|
1169
|
+
# lib/tasks/import_customer_events.rake
|
|
1170
|
+
namespace :events do
|
|
1171
|
+
desc "Import existing customers as CustomerCreated events and pre-warm snapshots"
|
|
1172
|
+
task import_customers: :environment do
|
|
1173
|
+
Customer.find_each do |customer|
|
|
1174
|
+
next if customer.events.exists?
|
|
1175
|
+
|
|
1176
|
+
Customer::Events::CustomerCreated.create!(
|
|
1177
|
+
aggregate_id: customer.id,
|
|
1178
|
+
first_name: customer.first_name,
|
|
1179
|
+
last_name: customer.last_name,
|
|
1180
|
+
email: customer.email,
|
|
1181
|
+
created_at: customer.created_at,
|
|
1182
|
+
updated_at: customer.updated_at
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
customer.reload.create_snapshot!
|
|
1186
|
+
end
|
|
1187
|
+
end
|
|
1188
|
+
end
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
**How it works:**
|
|
1192
|
+
|
|
1193
|
+
- Snapshots are stored in the `rails_simple_event_sourcing_snapshots` table
|
|
1194
|
+
- One snapshot per aggregate is kept — each new snapshot overwrites the previous one
|
|
1195
|
+
- `aggregate_state` (used by the Events Viewer) also benefits: when reconstructing historical state at version N, the nearest snapshot at or before version N is used
|
|
1196
|
+
- If no snapshot exists, `EventPlayer` falls back to a full replay — behaviour is identical, just slower
|
|
1197
|
+
|
|
1198
|
+
**Schema fingerprinting:**
|
|
1199
|
+
|
|
1200
|
+
Each snapshot stores a `schema_fingerprint` — a hash of the aggregate's column names at the time the snapshot was written. When `EventPlayer` loads a snapshot, it compares the stored fingerprint against the aggregate's current fingerprint. If they differ (because a column was added, removed, or renamed), the snapshot is **ignored** and a full event replay is performed instead.
|
|
1201
|
+
|
|
1202
|
+
This protects you from a subtle class of bug: snapshots store the aggregate's raw attributes at a point in time, but upcasters only transform event payloads — there's no equivalent migration path for snapshot state. Without fingerprint validation, a schema change could leave your aggregate in an inconsistent state after a snapshot restore (e.g., a renamed column would silently drop the old value and the new column would remain `nil`).
|
|
1203
|
+
|
|
1204
|
+
The fingerprint check makes snapshots self-invalidating: after any aggregate schema change, stale snapshots are skipped automatically and replaced with fresh ones on the next auto-snapshot or `create_snapshot!` call. No manual cleanup required.
|
|
1205
|
+
|
|
968
1206
|
## Testing
|
|
969
1207
|
|
|
970
1208
|
### Setting Up Tests with Command Handler Registry
|
|
@@ -1092,11 +1330,11 @@ end
|
|
|
1092
1330
|
Be aware of these limitations when using this gem:
|
|
1093
1331
|
|
|
1094
1332
|
- **PostgreSQL Only** - Requires PostgreSQL 9.4+ for JSONB support
|
|
1095
|
-
- **No Snapshots** - All aggregate reconstruction done by replaying all events (can be slow for aggregates with many events)
|
|
1096
1333
|
- **No Projections** - No built-in read model or projection support
|
|
1097
1334
|
- **Manual aggregate_id** - Must manually track and pass `aggregate_id` for updates/deletes
|
|
1098
1335
|
- **No Saga Support** - No built-in support for long-running processes or sagas
|
|
1099
1336
|
- **Single Database** - Events and aggregates must be in the same database
|
|
1337
|
+
- **Pessimistic Locking** - Concurrent updates to the same aggregate are serialized using `SELECT ... FOR UPDATE`. This guarantees correctness but may increase latency under high contention on a single aggregate. A unique database index on `(eventable_type, aggregate_id, version)` provides an additional safety net
|
|
1100
1338
|
|
|
1101
1339
|
## Troubleshooting
|
|
1102
1340
|
|
|
@@ -6,7 +6,21 @@ module RailsSimpleEventSourcing
|
|
|
6
6
|
include ReadOnly
|
|
7
7
|
|
|
8
8
|
included do
|
|
9
|
-
has_many :events, class_name: 'RailsSimpleEventSourcing::Event', as: :eventable,
|
|
9
|
+
has_many :events, class_name: 'RailsSimpleEventSourcing::Event', as: :eventable,
|
|
10
|
+
dependent: :restrict_with_exception
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def create_snapshot!
|
|
14
|
+
latest_event = events.order(version: :desc).first
|
|
15
|
+
return unless latest_event
|
|
16
|
+
|
|
17
|
+
RailsSimpleEventSourcing::Snapshot.create_or_update!(
|
|
18
|
+
aggregate_type: self.class.name,
|
|
19
|
+
aggregate_id: id,
|
|
20
|
+
state: attributes,
|
|
21
|
+
version: latest_event.version,
|
|
22
|
+
schema_fingerprint: RailsSimpleEventSourcing::Snapshot.fingerprint_for(self.class)
|
|
23
|
+
)
|
|
10
24
|
end
|
|
11
25
|
end
|
|
12
26
|
end
|
|
@@ -15,13 +15,14 @@ module RailsSimpleEventSourcing
|
|
|
15
15
|
numericality: { only_integer: true, greater_than: 0 },
|
|
16
16
|
if: -> { aggregate_id.present? }
|
|
17
17
|
validates :version,
|
|
18
|
-
uniqueness: { scope:
|
|
18
|
+
uniqueness: { scope: %i[eventable_type aggregate_id] },
|
|
19
19
|
if: -> { aggregate_id.present? }
|
|
20
20
|
|
|
21
21
|
before_validation :setup_for_create, on: :create
|
|
22
22
|
before_save :persist_aggregate, if: :aggregate_defined?
|
|
23
23
|
after_create :disable_write_access!
|
|
24
24
|
after_commit :dispatch_to_event_bus, on: :create
|
|
25
|
+
after_commit :maybe_create_snapshot, on: :create
|
|
25
26
|
|
|
26
27
|
def apply(aggregate)
|
|
27
28
|
payload.each do |key, value|
|
|
@@ -67,7 +68,7 @@ module RailsSimpleEventSourcing
|
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def calculate_next_version
|
|
70
|
-
max_version = Event.where(aggregate_id:).maximum(:version) || 0
|
|
71
|
+
max_version = Event.where(eventable_type: aggregate_class.name, aggregate_id:).maximum(:version) || 0
|
|
71
72
|
max_version + 1
|
|
72
73
|
end
|
|
73
74
|
|
|
@@ -86,5 +87,12 @@ module RailsSimpleEventSourcing
|
|
|
86
87
|
def dispatch_to_event_bus
|
|
87
88
|
EventBus.dispatch(self)
|
|
88
89
|
end
|
|
90
|
+
|
|
91
|
+
def maybe_create_snapshot
|
|
92
|
+
interval = RailsSimpleEventSourcing.config.snapshot_interval
|
|
93
|
+
return unless interval && aggregate_defined? && eventable.present? && (version % interval).zero?
|
|
94
|
+
|
|
95
|
+
Snapshot.create_from_event!(self)
|
|
96
|
+
end
|
|
89
97
|
end
|
|
90
98
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsSimpleEventSourcing
|
|
4
|
+
class Snapshot < ApplicationRecord
|
|
5
|
+
validates :aggregate_type, :aggregate_id, :state, :version, presence: true
|
|
6
|
+
|
|
7
|
+
def self.create_from_event!(event)
|
|
8
|
+
create_or_update!(
|
|
9
|
+
aggregate_type: event.eventable_type,
|
|
10
|
+
aggregate_id: event.aggregate_id,
|
|
11
|
+
state: event.eventable.attributes,
|
|
12
|
+
version: event.version,
|
|
13
|
+
schema_fingerprint: fingerprint_for(event.eventable.class)
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.create_or_update!(aggregate_type:, aggregate_id:, state:, version:, schema_fingerprint:)
|
|
18
|
+
upsert( # rubocop:disable Rails/SkipsModelValidations
|
|
19
|
+
{
|
|
20
|
+
aggregate_type: aggregate_type,
|
|
21
|
+
aggregate_id: aggregate_id.to_s,
|
|
22
|
+
state: state,
|
|
23
|
+
version: version,
|
|
24
|
+
schema_fingerprint: schema_fingerprint
|
|
25
|
+
},
|
|
26
|
+
unique_by: :index_snapshots_on_aggregate_type_and_aggregate_id
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.fingerprint_for(aggregate_class)
|
|
31
|
+
signature = aggregate_class.columns
|
|
32
|
+
.map { |c| "#{c.name}:#{c.sql_type}:#{c.null}" }
|
|
33
|
+
.sort
|
|
34
|
+
.join(',')
|
|
35
|
+
Digest::SHA256.hexdigest(signature)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRailsSimpleEventSourcingSnapshots < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :rails_simple_event_sourcing_snapshots do |t|
|
|
6
|
+
t.string :aggregate_type, null: false
|
|
7
|
+
t.string :aggregate_id, null: false
|
|
8
|
+
t.jsonb :state, null: false, default: {}
|
|
9
|
+
t.integer :version, null: false
|
|
10
|
+
t.string :schema_fingerprint
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :rails_simple_event_sourcing_snapshots,
|
|
16
|
+
%i[aggregate_type aggregate_id],
|
|
17
|
+
unique: true,
|
|
18
|
+
name: 'index_snapshots_on_aggregate_type_and_aggregate_id'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -3,10 +3,21 @@
|
|
|
3
3
|
module RailsSimpleEventSourcing
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :use_naming_convention_fallback, :events_per_page
|
|
6
|
+
attr_reader :snapshot_interval
|
|
6
7
|
|
|
7
8
|
def initialize
|
|
8
9
|
@use_naming_convention_fallback = true
|
|
9
10
|
@events_per_page = 25
|
|
11
|
+
@snapshot_interval = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def snapshot_interval=(value)
|
|
15
|
+
if !value.nil? && !(value.is_a?(Integer) && value.positive?)
|
|
16
|
+
raise ArgumentError,
|
|
17
|
+
"snapshot_interval must be nil or a positive integer, got #{value.inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@snapshot_interval = value
|
|
10
21
|
end
|
|
11
22
|
end
|
|
12
23
|
end
|
|
@@ -16,6 +16,10 @@ module RailsSimpleEventSourcing
|
|
|
16
16
|
def dispatch(event)
|
|
17
17
|
subscribers_for(event).each do |subscriber|
|
|
18
18
|
subscriber.perform_later(event)
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
Rails.logger.error(
|
|
21
|
+
"[RailsSimpleEventSourcing::EventBus] Failed to enqueue #{subscriber} for event ##{event.id}: #{e.message}"
|
|
22
|
+
)
|
|
19
23
|
end
|
|
20
24
|
end
|
|
21
25
|
|
|
@@ -19,11 +19,39 @@ module RailsSimpleEventSourcing
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def load_event_stream(up_to_version:)
|
|
22
|
-
|
|
22
|
+
snapshot = load_snapshot(up_to_version:)
|
|
23
|
+
|
|
24
|
+
if snapshot
|
|
25
|
+
restore_from_snapshot(snapshot)
|
|
26
|
+
from_version = snapshot.version + 1
|
|
27
|
+
else
|
|
28
|
+
from_version = 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
scope = @aggregate.events.where(version: from_version..).order(:version)
|
|
23
32
|
scope = scope.where(version: ..up_to_version) if up_to_version
|
|
24
33
|
scope
|
|
25
34
|
end
|
|
26
35
|
|
|
36
|
+
def load_snapshot(up_to_version:)
|
|
37
|
+
return nil if @aggregate.new_record?
|
|
38
|
+
|
|
39
|
+
snapshot = Snapshot.find_by(
|
|
40
|
+
aggregate_type: @aggregate.class.name,
|
|
41
|
+
aggregate_id: @aggregate.id.to_s
|
|
42
|
+
)
|
|
43
|
+
return nil if snapshot && up_to_version && snapshot.version > up_to_version
|
|
44
|
+
return nil if snapshot && snapshot.schema_fingerprint != Snapshot.fingerprint_for(@aggregate.class)
|
|
45
|
+
|
|
46
|
+
snapshot
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def restore_from_snapshot(snapshot)
|
|
50
|
+
snapshot.state.each do |key, value|
|
|
51
|
+
@aggregate.send("#{key}=", value) if @aggregate.respond_to?("#{key}=")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
27
55
|
def apply_events(events)
|
|
28
56
|
events.each do |event|
|
|
29
57
|
event.apply(@aggregate)
|
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.1.
|
|
4
|
+
version: 1.1.1
|
|
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-05-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: pg
|
|
@@ -30,14 +30,14 @@ dependencies:
|
|
|
30
30
|
requirements:
|
|
31
31
|
- - ">="
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: 7.1
|
|
33
|
+
version: '7.1'
|
|
34
34
|
type: :runtime
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: 7.1
|
|
40
|
+
version: '7.1'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
42
|
name: concurrent-ruby
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -102,12 +102,14 @@ files:
|
|
|
102
102
|
- app/models/rails_simple_event_sourcing.rb
|
|
103
103
|
- app/models/rails_simple_event_sourcing/current_request.rb
|
|
104
104
|
- app/models/rails_simple_event_sourcing/event.rb
|
|
105
|
+
- app/models/rails_simple_event_sourcing/snapshot.rb
|
|
105
106
|
- app/views/layouts/rails_simple_event_sourcing/application.html.erb
|
|
106
107
|
- app/views/rails_simple_event_sourcing/events/_pagination.html.erb
|
|
107
108
|
- app/views/rails_simple_event_sourcing/events/index.html.erb
|
|
108
109
|
- app/views/rails_simple_event_sourcing/events/show.html.erb
|
|
109
110
|
- config/routes.rb
|
|
110
111
|
- db/migrate/20231231133250_create_rails_simple_event_sourcing_events.rb
|
|
112
|
+
- db/migrate/20260328000000_create_rails_simple_event_sourcing_snapshots.rb
|
|
111
113
|
- lib/rails_simple_event_sourcing.rb
|
|
112
114
|
- lib/rails_simple_event_sourcing/aggregate_links_builder.rb
|
|
113
115
|
- lib/rails_simple_event_sourcing/aggregate_repository.rb
|
|
@@ -129,6 +131,7 @@ licenses:
|
|
|
129
131
|
- MIT
|
|
130
132
|
metadata:
|
|
131
133
|
homepage_uri: https://github.com/dbackowski/rails_simple_event_sourcing
|
|
134
|
+
source_code_uri: https://github.com/dbackowski/rails_simple_event_sourcing
|
|
132
135
|
post_install_message:
|
|
133
136
|
rdoc_options: []
|
|
134
137
|
require_paths:
|
|
@@ -144,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
144
147
|
- !ruby/object:Gem::Version
|
|
145
148
|
version: '0'
|
|
146
149
|
requirements: []
|
|
147
|
-
rubygems_version: 3.4.
|
|
150
|
+
rubygems_version: 3.4.19
|
|
148
151
|
signing_key:
|
|
149
152
|
specification_version: 4
|
|
150
153
|
summary: Rails engine for simple event sourcing.
|