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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a12c3adaddba63ac71f028aa700a4c14826999551657b1cafe758d9d7c1a66f
4
- data.tar.gz: 480fcef167c89edb26e9ebe04596f3a90fe9cc04e58064bcb46309abba5926f6
3
+ metadata.gz: 97abf701a07288f6fc66a33c3cc064810ad7a5ea5c7f798be4f85a96f839722a
4
+ data.tar.gz: '08c7fe14306245b4f0a3b59e5309aa9de4b4a8dd76b4f989d6f7e5cc02962f0e'
5
5
  SHA512:
6
- metadata.gz: 47f6eddbbd5b4ad08e586caad344024432b716ac354b01bdd17494cff94b3b4419735a203a1425d7b3798a401685f8a8c1f28bacc50ceac3afe748fdb22c80b0
7
- data.tar.gz: 4800824fd957523c51466fafe888b80541fcf1a52abab599435e32dd3a18871e4e5ea6b18acba24579c45762558952a03118157aeed4f2bb7c2a29ce49589132
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.1.2 or higher
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. See the full examples in `test/dummy/app/domain/customer/`.
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
- When events are created outside HTTP requests (background jobs, console, tests), metadata will be empty unless you manually set it using `CurrentRequest.metadata = {...}`.
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 foreign keys
718
- - Hard deleting a record can orphan its events
719
- - Event log becomes incomplete
720
- - Cannot reconstruct historical state
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
- # No need to implement apply - deleted_at will be automatically set on the aggregate
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
 
@@ -12,9 +12,7 @@ module RailsSimpleEventSourcing
12
12
  end
13
13
  end
14
14
 
15
- def aggregate_class
16
- self.class.aggregate_class
17
- end
15
+ delegate :aggregate_class, to: :class
18
16
 
19
17
  def aggregate_defined?
20
18
  aggregate_class.present?
@@ -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, dependent: :nullify
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: :aggregate_id },
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
- scope = @aggregate.events.order(version: :asc)
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsSimpleEventSourcing
4
- VERSION = '1.1.0'
4
+ VERSION = '1.1.1'
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.1.0
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-03-27 00:00:00.000000000 Z
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.2
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.2
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.21
150
+ rubygems_version: 3.4.19
148
151
  signing_key:
149
152
  specification_version: 4
150
153
  summary: Rails engine for simple event sourcing.