rails_simple_event_sourcing 1.0.11 → 1.1.0

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: 0e55cdcaaea329dd389ea7bd44f539e6b676c45d5f0f400cf3cbdd0a67f61021
4
- data.tar.gz: ca8f42fb05104799d8ab73eb14290cc5941b0e05b4b504d8c3f8ba3dde26c971
3
+ metadata.gz: 1a12c3adaddba63ac71f028aa700a4c14826999551657b1cafe758d9d7c1a66f
4
+ data.tar.gz: 480fcef167c89edb26e9ebe04596f3a90fe9cc04e58064bcb46309abba5926f6
5
5
  SHA512:
6
- metadata.gz: 41c3718c3caadc0d5cbe82f87158b540c9e78831c4342bc37f582fc28b38cd1ec77f58ec02c8083cd9dde321c6cbe8aa29aded5a96456c6eac7a2e25ead78fc6
7
- data.tar.gz: a53c32406b3a37e448af71401524e85d4f03afdf9dc59d68e4c286ad32030686f1cf4ab961f63b86fb1f4037cb7d996c5128541b474f1db2a817a0b258c9abfa
6
+ metadata.gz: 47f6eddbbd5b4ad08e586caad344024432b716ac354b01bdd17494cff94b3b4419735a203a1425d7b3798a401685f8a8c1f28bacc50ceac3afe748fdb22c80b0
7
+ data.tar.gz: 4800824fd957523c51466fafe888b80541fcf1a52abab599435e32dd3a18871e4e5ea6b18acba24579c45762558952a03118157aeed4f2bb7c2a29ce49589132
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A minimalist implementation of the event sourcing pattern for Rails applications. This gem provides a simple, opinionated approach to event sourcing without the complexity of full-featured frameworks.
4
4
 
5
+ You don't have to go all-in. The gem can be applied to a single model or a specific part of your domain while the rest of your application continues using standard ActiveRecord. This makes it easy to adopt incrementally — start with a new feature or migrate an existing model to event sourcing when it makes sense, without rewriting your entire system.
6
+
5
7
  If you need a more comprehensive solution, check out:
6
8
  - https://www.sequent.io
7
9
  - https://railseventstore.org
@@ -22,7 +24,9 @@ If you need a more comprehensive solution, check out:
22
24
  - [Metadata Tracking](#metadata-tracking)
23
25
  - [Event Querying](#event-querying)
24
26
  - [Events Viewer](#events-viewer)
27
+ - [Adding Event Sourcing to an Existing Model](#adding-event-sourcing-to-an-existing-model)
25
28
  - [Event Subscriptions](#event-subscriptions)
29
+ - [Event Schema Versioning](#event-schema-versioning)
26
30
  - [Testing](#testing)
27
31
  - [Limitations](#limitations)
28
32
  - [Troubleshooting](#troubleshooting)
@@ -42,6 +46,7 @@ If you need a more comprehensive solution, check out:
42
46
  - **PostgreSQL JSONB Storage** - Efficient JSON storage for event payloads and metadata
43
47
  - **Built-in Events Viewer** - Web UI for browsing, searching, and inspecting events
44
48
  - **Event Subscriptions** - React to events after they are committed (send emails, send webhooks, etc.)
49
+ - **Event Schema Versioning** - Built-in upcasting to evolve event schemas without modifying stored data
45
50
  - **Minimal Configuration** - Convention over configuration approach
46
51
 
47
52
  ## Requirements
@@ -102,12 +107,16 @@ end
102
107
 
103
108
  The event sourcing flow follows this pattern:
104
109
 
105
- ```
106
- HTTP Request → Controller → Command → CommandHandler → Event → Aggregate (Model)
107
- ↓ ↓ ↓ ↓ ↓
108
- Pass data Parameters Validation + Immutable Database
109
- + Validation Business Storage
110
- Rules Logic
110
+ ```mermaid
111
+ flowchart TD
112
+ A[HTTP Request] --> B[Controller]
113
+ B -->|Pass data| C[Command]
114
+ C -->|Parameters\n+ Validation Rules| D[CommandHandler]
115
+ D -->|Validation +\nBusiness Logic| E[Event]
116
+ E -->|Immutable\nStorage| F[Aggregate\nModel]
117
+ F --> G[(Database)]
118
+ E --> H[EventBus]
119
+ H -->|after commit| I[Subscribers\nActiveJob]
111
120
  ```
112
121
 
113
122
  **Flow breakdown:**
@@ -116,6 +125,8 @@ HTTP Request → Controller → Command → CommandHandler → Event → Aggrega
116
125
  3. **CommandHandler** - Validates command, executes business logic, creates event
117
126
  4. **Event** - Immutable record of what happened
118
127
  5. **Aggregate** - Model updated via event
128
+ 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.)
119
130
 
120
131
  ### Directory Structure
121
132
 
@@ -127,13 +138,15 @@ app/
127
138
  │ ├─ customer/
128
139
  │ │ ├─ command_handlers/
129
140
  │ │ │ ├─ create.rb
130
- │ │ ├─ events/
131
- │ │ │ ├─ customer_created.rb
132
141
  │ │ ├─ commands/
133
142
  │ │ │ ├─ create.rb
143
+ │ │ ├─ events/
144
+ │ │ │ ├─ customer_created.rb
145
+ │ │ ├─ subscribers/
146
+ │ │ │ ├─ send_welcome_email.rb
134
147
  ```
135
148
 
136
- **Note:** The top directory name (`domain/`) can be different - Rails doesn't enforce this namespace.
149
+ **Note:** The top directory name (`domain/`) can be different - Rails doesn't enforce this namespace. Subscribers are ActiveJob classes (see [Event Subscriptions](#event-subscriptions)), so you can also place them in `app/jobs/` or any other autoloaded directory if that better fits your project structure.
137
150
 
138
151
  ### Commands
139
152
 
@@ -177,6 +190,7 @@ Handlers can be discovered in two ways:
177
190
  **Result Object:**
178
191
  The `Result` class has three fields:
179
192
  - `success?` - Boolean indicating if the operation succeeded
193
+ - `failure?` - Boolean indicating if the operation failed (inverse of `success?`)
180
194
  - `data` - Data to return (usually the aggregate/model instance)
181
195
  - `errors` - Array or hash of error messages when `success?` is false
182
196
 
@@ -194,7 +208,7 @@ It supports a chainable API for use in controllers:
194
208
  - `on_success { |data| }` - Executes the block (yielding `data`) if the result is successful
195
209
  - `on_failure { |errors| }` - Executes the block (yielding `errors`) if the result failed
196
210
 
197
- Both methods return `self`, so they can be chained. The predicate `success?` remains available for use in conditionals and tests.
211
+ Both methods return `self`, so they can be chained. The predicates `success?` and `failure?` remain available for use in conditionals and tests.
198
212
 
199
213
  ```ruby
200
214
  result = RailsSimpleEventSourcing::Result.success(data: customer)
@@ -205,9 +219,10 @@ result
205
219
  .on_failure { |errors| render json: { errors: }, status: :unprocessable_entity }
206
220
 
207
221
  # Predicate (preferred in tests)
208
- result.success? # => true
209
- result.data # => #<Customer ...>
210
- result.errors # => nil
222
+ result.success? # => true
223
+ result.failure? # => false
224
+ result.data # => #<Customer ...>
225
+ result.errors # => nil
211
226
  ```
212
227
 
213
228
  **Helper Methods in Handlers:**
@@ -223,9 +238,9 @@ class Customer
223
238
  class Create < RailsSimpleEventSourcing::CommandHandlers::Base
224
239
  def call
225
240
  event = Customer::Events::CustomerCreated.create(
226
- first_name: @command.first_name,
227
- last_name: @command.last_name,
228
- email: @command.email,
241
+ first_name: command.first_name,
242
+ last_name: command.last_name,
243
+ email: command.email,
229
244
  created_at: Time.zone.now,
230
245
  updated_at: Time.zone.now
231
246
  )
@@ -245,9 +260,9 @@ class Customer
245
260
  class Create < RailsSimpleEventSourcing::CommandHandlers::Base
246
261
  def call
247
262
  event = Customer::Events::CustomerCreated.create(
248
- first_name: @command.first_name,
249
- last_name: @command.last_name,
250
- email: @command.email,
263
+ first_name: command.first_name,
264
+ last_name: command.last_name,
265
+ email: command.email,
251
266
  created_at: Time.zone.now,
252
267
  updated_at: Time.zone.now
253
268
  )
@@ -335,6 +350,7 @@ end
335
350
  **Understanding the Event Structure:**
336
351
  - `aggregate_class Customer` - Specifies which model this event modifies
337
352
  - `event_attributes` - Defines what data gets stored in the event's JSON payload and what will be automatically applied
353
+ - `current_version` - Optional; declares the current schema version for this event (defaults to 1). See [Event Schema Versioning](#event-schema-versioning)
338
354
  - `apply(aggregate)` - Optional method; only implement if you need custom logic beyond automatic attribute assignment
339
355
  - `aggregate_id` - Auto-generated for creates, must be provided for updates/deletes
340
356
 
@@ -342,6 +358,55 @@ end
342
358
  - Optional - you can have events without an aggregate (e.g., `UserLoginFailed` for logging only)
343
359
  - The corresponding model should include `RailsSimpleEventSourcing::Events` for read-only protection
344
360
 
361
+ **Example - Event without an aggregate:**
362
+
363
+ When you want to record something that happened without modifying any model (audit logs, failed attempts, notifications, etc.), simply omit `aggregate_class`:
364
+
365
+ ```ruby
366
+ class Customer
367
+ module Events
368
+ class CustomerEmailTaken < RailsSimpleEventSourcing::Event
369
+ event_attributes :first_name, :last_name, :email
370
+ end
371
+ end
372
+ end
373
+ ```
374
+
375
+ These events are stored in the event log like any other event, but they don't create or update an aggregate. You can create them directly:
376
+
377
+ ```ruby
378
+ Customer::Events::CustomerEmailTaken.create!(
379
+ first_name: 'John',
380
+ last_name: 'Doe',
381
+ email: 'john@example.com'
382
+ )
383
+ ```
384
+
385
+ Or from within a command handler as part of your business logic:
386
+
387
+ ```ruby
388
+ class Customer
389
+ module CommandHandlers
390
+ class Create < RailsSimpleEventSourcing::CommandHandlers::Base
391
+ def call
392
+ if Customer.exists?(email: command.email)
393
+ Customer::Events::CustomerEmailTaken.create!(
394
+ first_name: command.first_name,
395
+ last_name: command.last_name,
396
+ email: command.email
397
+ )
398
+ return failure(errors: { email: ['already taken'] })
399
+ end
400
+
401
+ # ... create the customer event
402
+ end
403
+ end
404
+ end
405
+ end
406
+ ```
407
+
408
+ This is useful for recording domain-significant occurrences that don't map to a state change on a model.
409
+
345
410
  ### Registering Command Handlers
346
411
 
347
412
  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.
@@ -387,7 +452,7 @@ class CustomersController < ApplicationController
387
452
  email: params[:email]
388
453
  )
389
454
 
390
- RailsSimpleEventSourcing::CommandHandler.new(cmd).call
455
+ RailsSimpleEventSourcing.dispatch(cmd)
391
456
  .on_success { |data| render json: data }
392
457
  .on_failure { |errors| render json: { errors: }, status: :unprocessable_entity }
393
458
  end
@@ -408,7 +473,7 @@ class CustomersController < ApplicationController
408
473
  email: params[:email]
409
474
  )
410
475
 
411
- RailsSimpleEventSourcing::CommandHandler.new(cmd).call
476
+ RailsSimpleEventSourcing.dispatch(cmd)
412
477
  .on_success { |data| render json: data }
413
478
  .on_failure { |errors| render json: { errors: }, status: :unprocessable_entity }
414
479
  end
@@ -422,7 +487,7 @@ class CustomersController < ApplicationController
422
487
  def destroy
423
488
  cmd = Customer::Commands::Delete.new(aggregate_id: params[:id])
424
489
 
425
- RailsSimpleEventSourcing::CommandHandler.new(cmd).call
490
+ RailsSimpleEventSourcing.dispatch(cmd)
426
491
  .on_success { head :no_content }
427
492
  .on_failure { |errors| render json: { errors: }, status: :unprocessable_entity }
428
493
  end
@@ -477,7 +542,7 @@ event.metadata
477
542
  # "request_user_agent"=>"curl/8.6.0", ...}
478
543
 
479
544
  # Query events by type
480
- RailsSimpleEventSourcing::Event.where(event_type: "Customer::Events::CustomerCreated")
545
+ RailsSimpleEventSourcing::Event.where(type: "Customer::Events::CustomerCreated")
481
546
 
482
547
  # Get events in a date range
483
548
  customer.events.where(created_at: 1.week.ago..Time.now)
@@ -508,7 +573,7 @@ latest_event.aggregate_state
508
573
  **Event Structure:**
509
574
  - `payload` - Contains the event attributes you defined (as JSON)
510
575
  - `metadata` - Contains request context (request ID, IP, user agent, params)
511
- - `event_type` - The event class name
576
+ - `type` - The event class name (Rails STI column)
512
577
  - `aggregate_id` - Links to the aggregate instance
513
578
  - `eventable` - Polymorphic relation to the aggregate
514
579
 
@@ -532,23 +597,36 @@ By default, the following is captured:
532
597
  - `request_ip` - Client IP address
533
598
  - `request_params` - Request parameters (filtered using Rails parameter filter)
534
599
 
535
- **Customizing Metadata:**
536
- Override the `event_metadata` method in your controller:
600
+ **Adding Custom Metadata:**
601
+ Override `custom_event_metadata` to add your own fields — they are merged into the defaults:
537
602
 
538
603
  ```ruby
539
604
  class ApplicationController < ActionController::Base
540
605
  include RailsSimpleEventSourcing::SetCurrentRequestDetails
541
606
 
542
- def event_metadata
543
- parameter_filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
607
+ def custom_event_metadata
608
+ {
609
+ current_user_id: current_user&.id,
610
+ tenant_id: current_tenant&.id
611
+ }
612
+ end
613
+ end
614
+ ```
615
+
616
+ The method must return a hash. Any keys it returns are merged on top of the default metadata (custom keys take precedence on collision).
617
+
618
+ **Overriding Default Metadata Entirely:**
619
+ If you need full control over what is captured, override `default_event_metadata` instead:
620
+
621
+ ```ruby
622
+ class ApplicationController < ActionController::Base
623
+ include RailsSimpleEventSourcing::SetCurrentRequestDetails
544
624
 
625
+ def default_event_metadata
545
626
  {
546
627
  request_id: request.uuid,
547
- request_user_agent: request.user_agent,
548
- request_ip: request.ip,
549
- request_params: parameter_filter.filter(request.params),
550
- current_user_id: current_user&.id, # Add custom fields
551
- tenant_id: current_tenant&.id
628
+ request_ip: request.ip
629
+ # only keep what you need
552
630
  }
553
631
  end
554
632
  end
@@ -669,9 +747,64 @@ class Customer::Events::CustomerDeleted < RailsSimpleEventSourcing::Event
669
747
  end
670
748
  ```
671
749
 
750
+ ### Adding Event Sourcing to an Existing Model
751
+
752
+ You can introduce event sourcing to a model that already has data. The key step is importing existing records as initial events so that every aggregate has a complete event history going forward.
753
+
754
+ **Step 1 — Set up the event and command classes as usual:**
755
+
756
+ ```ruby
757
+ class Customer
758
+ module Events
759
+ class CustomerCreated < RailsSimpleEventSourcing::Event
760
+ aggregate_class Customer
761
+ event_attributes :first_name, :last_name, :email, :created_at, :updated_at
762
+ end
763
+ end
764
+ end
765
+ ```
766
+
767
+ **Step 2 — Include the module in your model:**
768
+
769
+ ```ruby
770
+ class Customer < ApplicationRecord
771
+ include RailsSimpleEventSourcing::Events
772
+ end
773
+ ```
774
+
775
+ **Step 3 — Create a migration task to import existing records as events:**
776
+
777
+ ```ruby
778
+ # lib/tasks/import_customer_events.rake
779
+ namespace :events do
780
+ desc "Import existing customers as CustomerCreated events"
781
+ task import_customers: :environment do
782
+ Customer.find_each do |customer|
783
+ next if customer.events.exists?
784
+
785
+ Customer::Events::CustomerCreated.create!(
786
+ aggregate_id: customer.id,
787
+ first_name: customer.first_name,
788
+ last_name: customer.last_name,
789
+ email: customer.email,
790
+ created_at: customer.created_at,
791
+ updated_at: customer.updated_at
792
+ )
793
+ end
794
+ end
795
+ end
796
+ ```
797
+
798
+ Run with:
799
+ ```bash
800
+ rake events:import_customers
801
+ ```
802
+
803
+ After the import, every existing record has a `CustomerCreated` event as its baseline. From that point on, all changes go through the command/event flow while the rest of your application remains unchanged.
804
+
672
805
  ### Event Subscriptions
673
806
 
674
- The `EventBus` lets you react to events after they are persisted and committed to the database. Subscribers run **after the transaction commits**, so they never execute against data that could later be rolled back.
807
+ The `EventBus` lets you react to events after they are persisted and committed to the database. Subscribers are **ActiveJob classes** that are enqueued **after the transaction commits**, so they never execute against data that could later be rolled back and they don't block the HTTP response.
675
808
 
676
809
  **Registering subscribers:**
677
810
 
@@ -680,35 +813,45 @@ The `EventBus` lets you react to events after they are persisted and committed t
680
813
  Rails.application.config.after_initialize do
681
814
  RailsSimpleEventSourcing::EventBus.subscribe(
682
815
  Customer::Events::CustomerCreated,
683
- Subscribers::SendWelcomeEmail
816
+ Customer::Subscribers::SendWelcomeEmail
684
817
  )
685
818
 
686
819
  RailsSimpleEventSourcing::EventBus.subscribe(
687
820
  Customer::Events::CustomerCreated,
688
- Subscribers::CreateStripeCustomer
821
+ Customer::Subscribers::CreateStripeCustomer
689
822
  )
690
823
 
691
824
  RailsSimpleEventSourcing::EventBus.subscribe(
692
825
  Customer::Events::CustomerDeleted,
693
- Subscribers::CancelStripeSubscription
826
+ Customer::Subscribers::CancelStripeSubscription
694
827
  )
695
828
  end
696
829
  ```
697
830
 
698
831
  **Writing a subscriber:**
699
832
 
700
- Any object that responds to `call(event)` works a class with `.call`, a lambda, or a proc:
833
+ Subscribers must be ActiveJob classes. Each subscriber is enqueued as its own job, giving you per-subscriber retry logic, queue configuration, and error isolation out of the box.
701
834
 
702
835
  ```ruby
703
- module Subscribers
704
- class SendWelcomeEmail
705
- def self.call(event)
706
- WelcomeMailer.with(email: event.email).deliver_later
836
+ class Customer
837
+ module Subscribers
838
+ class SendWelcomeEmail < ApplicationJob
839
+ queue_as :default
840
+
841
+ def perform(event)
842
+ WelcomeMailer.with(email: event.email).deliver_later
843
+ end
707
844
  end
708
845
  end
709
846
  end
710
847
  ```
711
848
 
849
+ Since each subscriber is a standalone job, you get native ActiveJob benefits:
850
+ - **Per-subscriber queues** — route critical subscribers to high-priority queues
851
+ - **Per-subscriber retries** — configure `retry_on` per subscriber
852
+ - **Error isolation** — a failing subscriber doesn't affect others
853
+ - **Visibility** — each subscriber appears as its own job in your queue dashboard
854
+
712
855
  **Subscribing to all events:**
713
856
 
714
857
  Subscribe to `RailsSimpleEventSourcing::Event` to receive every event regardless of type — useful for audit loggers or metrics:
@@ -716,39 +859,112 @@ Subscribe to `RailsSimpleEventSourcing::Event` to receive every event regardless
716
859
  ```ruby
717
860
  RailsSimpleEventSourcing::EventBus.subscribe(
718
861
  RailsSimpleEventSourcing::Event,
719
- Subscribers::AuditLogger
862
+ Customer::Subscribers::AuditLogger
720
863
  )
721
864
  ```
722
865
 
723
- If you subscribe the same callable to both a specific event class and `RailsSimpleEventSourcing::Event`, it will be called twice — once for each subscription. This is intentional and consistent with standard pub/sub behaviour.
866
+ 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.
724
867
 
725
868
  **Testing with EventBus:**
726
869
 
727
- Call `RailsSimpleEventSourcing::EventBus.reset!` in your test `setup` to clear all subscriptions between tests:
870
+ Use `ActiveJob::TestHelper` to assert that subscriber jobs are enqueued. Call `RailsSimpleEventSourcing::EventBus.reset!` in your test `setup` to clear all subscriptions between tests:
728
871
 
729
872
  ```ruby
730
873
  class MyTest < ActiveSupport::TestCase
874
+ include ActiveJob::TestHelper
875
+
731
876
  setup do
732
877
  RailsSimpleEventSourcing::EventBus.reset!
733
878
  end
734
879
 
735
- test "sends welcome email on customer created" do
736
- emails = []
880
+ test "enqueues welcome email job on customer created" do
737
881
  RailsSimpleEventSourcing::EventBus.subscribe(
738
882
  Customer::Events::CustomerCreated,
739
- ->(event) { emails << event.email }
883
+ Customer::Subscribers::SendWelcomeEmail
740
884
  )
741
885
 
742
- Customer::Events::CustomerCreated.create!(
743
- first_name: "John", last_name: "Doe", email: "john@example.com",
744
- created_at: Time.zone.now, updated_at: Time.zone.now
745
- )
886
+ assert_enqueued_jobs 1 do
887
+ Customer::Events::CustomerCreated.create!(
888
+ first_name: "John", last_name: "Doe", email: "john@example.com",
889
+ created_at: Time.zone.now, updated_at: Time.zone.now
890
+ )
891
+ end
892
+ end
893
+ end
894
+ ```
895
+
896
+ ### Event Schema Versioning
746
897
 
747
- assert_includes emails, "john@example.com"
898
+ Over time, event schemas may need to change — fields get renamed, split, or added. Since events are immutable and stored forever, the gem provides built-in **upcasting** support to transparently transform old event payloads to the current schema when read.
899
+
900
+ **How it works:**
901
+
902
+ Each event class can declare a `current_version` and register `upcaster` blocks that transform payloads from one version to the next. When an event is loaded from the database, the `payload` method automatically runs the necessary upcasters to bring old data up to the current schema. The stored data is never modified — upcasting happens on read.
903
+
904
+ **Example — splitting a `name` field into `first_name` and `last_name`:**
905
+
906
+ ```ruby
907
+ class Customer
908
+ module Events
909
+ class CustomerCreated < RailsSimpleEventSourcing::Event
910
+ aggregate_class Customer
911
+ current_version 2
912
+ event_attributes :first_name, :last_name, :email, :created_at, :updated_at
913
+
914
+ # v1 had a single "name" field, v2 splits it into first_name and last_name
915
+ upcaster(1) do |payload|
916
+ if payload.key?('name')
917
+ parts = payload.delete('name').to_s.split(' ', 2)
918
+ payload['first_name'] = parts[0]
919
+ payload['last_name'] = parts[1]
920
+ end
921
+ payload
922
+ end
923
+ end
924
+ end
925
+ end
926
+ ```
927
+
928
+ **Chaining multiple upcasters:**
929
+
930
+ Upcasters are applied sequentially. A v1 event will run through `upcaster(1)`, then `upcaster(2)`, and so on until it reaches the `current_version`:
931
+
932
+ ```ruby
933
+ class Customer
934
+ module Events
935
+ class CustomerCreated < RailsSimpleEventSourcing::Event
936
+ aggregate_class Customer
937
+ current_version 3
938
+ event_attributes :first_name, :last_name, :email, :phone, :created_at, :updated_at
939
+
940
+ # v1 -> v2: split "name" into first_name/last_name
941
+ upcaster(1) do |payload|
942
+ if payload.key?('name')
943
+ parts = payload.delete('name').to_s.split(' ', 2)
944
+ payload['first_name'] = parts[0]
945
+ payload['last_name'] = parts[1]
946
+ end
947
+ payload
948
+ end
949
+
950
+ # v2 -> v3: add phone with default
951
+ upcaster(2) do |payload|
952
+ payload['phone'] ||= 'unknown'
953
+ payload
954
+ end
955
+ end
748
956
  end
749
957
  end
750
958
  ```
751
959
 
960
+ **Key points:**
961
+
962
+ - `current_version` is optional — if not called, the schema version defaults to 1
963
+ - New events are stored with the current version, so they skip upcasting entirely
964
+ - If an upcaster is missing for a version in the chain, a `RuntimeError` is raised
965
+ - Upcasting is transparent to `apply` — custom or default `apply` methods receive the already-upcasted payload
966
+ - Events without `aggregate_class` do not use schema versioning (the `schema_version` column is left `nil`)
967
+
752
968
  ## Testing
753
969
 
754
970
  ### Setting Up Tests with Command Handler Registry
@@ -821,7 +1037,7 @@ class Customer::CommandHandlers::CreateTest < ActiveSupport::TestCase
821
1037
  email: "john@example.com"
822
1038
  )
823
1039
 
824
- result = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
1040
+ result = RailsSimpleEventSourcing.dispatch(cmd)
825
1041
 
826
1042
  assert result.success?
827
1043
  assert_instance_of Customer, result.data
@@ -844,7 +1060,7 @@ class Customer::CommandHandlers::CreateTest < ActiveSupport::TestCase
844
1060
  email: "john@example.com"
845
1061
  )
846
1062
 
847
- result = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
1063
+ result = RailsSimpleEventSourcing.dispatch(cmd)
848
1064
 
849
1065
  assert_not result.success?
850
1066
  assert_includes result.errors, "Email has already been taken"
@@ -876,7 +1092,6 @@ end
876
1092
  Be aware of these limitations when using this gem:
877
1093
 
878
1094
  - **PostgreSQL Only** - Requires PostgreSQL 9.4+ for JSONB support
879
- - **No Event Versioning** - No built-in support for evolving event schemas over time
880
1095
  - **No Snapshots** - All aggregate reconstruction done by replaying all events (can be slow for aggregates with many events)
881
1096
  - **No Projections** - No built-in read model or projection support
882
1097
  - **Manual aggregate_id** - Must manually track and pass `aggregate_id` for updates/deletes
@@ -957,7 +1172,7 @@ customer.update(first_name: "Jane")
957
1172
 
958
1173
  # Do this:
959
1174
  cmd = Customer::Commands::Update.new(aggregate_id: customer.id, first_name: "Jane", ...)
960
- RailsSimpleEventSourcing::CommandHandler.new(cmd).call
1175
+ RailsSimpleEventSourcing.dispatch(cmd)
961
1176
  ```
962
1177
 
963
1178
  ### Missing aggregate_id for updates
@@ -10,10 +10,14 @@ module RailsSimpleEventSourcing
10
10
  private
11
11
 
12
12
  def set_event_metadata
13
- CurrentRequest.metadata = event_metadata
13
+ CurrentRequest.metadata = default_event_metadata.merge(custom_event_metadata)
14
14
  end
15
15
 
16
- def event_metadata
16
+ def custom_event_metadata
17
+ {}
18
+ end
19
+
20
+ def default_event_metadata
17
21
  parameter_filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
18
22
 
19
23
  {
@@ -11,6 +11,8 @@ module RailsSimpleEventSourcing
11
11
  def show
12
12
  @event = Event.find(params[:id])
13
13
  @aggregate_state = @event.aggregate_state
14
+ @payload_links = AggregateLinksBuilder.new(@event.payload).call
15
+ @aggregate_state_links = AggregateLinksBuilder.new(@aggregate_state).call
14
16
  find_adjacent_versions
15
17
  end
16
18
 
@@ -19,7 +21,7 @@ module RailsSimpleEventSourcing
19
21
  def event_types
20
22
  return Event.descendants.map(&:name).sort if Rails.env.production?
21
23
 
22
- Event.distinct.pluck(:event_type).sort
24
+ Event.distinct.pluck(:type).sort
23
25
  end
24
26
 
25
27
  def aggregates
@@ -31,7 +33,7 @@ module RailsSimpleEventSourcing
31
33
  def search_events
32
34
  EventSearch.new(
33
35
  scope: Event.all,
34
- event_type: params[:event_type],
36
+ type: params[:event_type],
35
37
  aggregate: params[:aggregate],
36
38
  query: params[:q]
37
39
  ).call
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSimpleEventSourcing
4
+ module SchemaVersioning
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def current_version(version)
9
+ @current_schema_version = version
10
+ end
11
+
12
+ def schema_version_number
13
+ @current_schema_version || 1
14
+ end
15
+
16
+ def upcaster(from_version, &block)
17
+ upcasters[from_version] = block
18
+ end
19
+
20
+ def upcasters
21
+ @upcasters ||= {}
22
+ end
23
+ end
24
+
25
+ included do
26
+ before_validation :set_schema_version, on: :create
27
+ end
28
+
29
+ def payload
30
+ data = super
31
+ return data if new_record?
32
+
33
+ upcast(data)
34
+ end
35
+
36
+ private
37
+
38
+ def upcast(data)
39
+ return data if data.nil? || schema_version.nil?
40
+
41
+ current = schema_version
42
+ target = self.class.schema_version_number
43
+
44
+ while current < target
45
+ upcaster = self.class.upcasters[current]
46
+ raise "Missing upcaster from version #{current} to #{current + 1} for #{self.class}" unless upcaster
47
+
48
+ data = upcaster.call(data)
49
+ current += 1
50
+ end
51
+
52
+ data
53
+ end
54
+
55
+ def set_schema_version
56
+ return unless aggregate_defined?
57
+
58
+ self.schema_version = self.class.schema_version_number
59
+ end
60
+ end
61
+ end
@@ -5,6 +5,7 @@ module RailsSimpleEventSourcing
5
5
  include ReadOnly
6
6
  include EventAttributes
7
7
  include AggregateConfiguration
8
+ include SchemaVersioning
8
9
 
9
10
  belongs_to :eventable, polymorphic: true, optional: true
10
11
  alias aggregate eventable
@@ -24,7 +25,7 @@ module RailsSimpleEventSourcing
24
25
 
25
26
  def apply(aggregate)
26
27
  payload.each do |key, value|
27
- raise "Unknown attribute '#{key}' on #{aggregate.class}" unless aggregate.respond_to?("#{key}=")
28
+ raise ArgumentError, "Unknown attribute '#{key}' on #{aggregate.class}" unless aggregate.respond_to?("#{key}=")
28
29
 
29
30
  aggregate.send("#{key}=", value)
30
31
  end
@@ -49,7 +50,6 @@ module RailsSimpleEventSourcing
49
50
 
50
51
  def setup_event_fields
51
52
  enable_write_access!
52
- self.event_type = self.class
53
53
  self.metadata = CurrentRequest.metadata&.compact&.presence
54
54
  end
55
55
 
@@ -47,7 +47,7 @@
47
47
  .pagination .gap { border: none; color: #888; }
48
48
  .pagination .disabled { color: #ccc; border-color: #eee; pointer-events: none; }
49
49
 
50
- .detail-grid { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; }
50
+ .detail-grid { display: grid; grid-template-columns: max-content 1fr; gap: 8px 16px; }
51
51
  .detail-grid dt { font-weight: 600; color: #555; font-size: 0.85rem; }
52
52
  .detail-grid dd { font-size: 0.9rem; }
53
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; }
@@ -28,7 +28,7 @@
28
28
  <% @paginator.records.each do |event| %>
29
29
  <tr>
30
30
  <td class="text-mono"><%= link_to event.id, event_path(event) %></td>
31
- <td><span class="badge"><%= event.event_type %></span></td>
31
+ <td><span class="badge"><%= event.type %></span></td>
32
32
  <td><%= event.aggregate_class&.name || "-" %></td>
33
33
  <td class="text-mono"><%= event.aggregate_id || "-" %></td>
34
34
  <td><%= event.version %></td>
@@ -3,7 +3,7 @@
3
3
  <div class="card">
4
4
  <dl class="detail-grid">
5
5
  <dt>Event Type</dt>
6
- <dd><span class="badge"><%= @event.event_type %></span></dd>
6
+ <dd><span class="badge"><%= @event.type %></span></dd>
7
7
 
8
8
  <dt>Aggregate</dt>
9
9
  <dd><%= @event.aggregate_class&.name || "-" %></dd>
@@ -27,10 +27,32 @@
27
27
  </dl>
28
28
  </div>
29
29
 
30
- <h2>Payload</h2>
30
+ <% links_on = params[:links] == "1" %>
31
+ <% toggle_label = links_on ? "Hide links" : "Show links" %>
32
+ <% toggle_url = links_on ? event_path(@event) : event_path(@event, links: "1") %>
33
+
34
+ <div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:16px;">
35
+ <h2 style="margin-bottom:0">Payload</h2>
36
+ <%= link_to toggle_label, toggle_url, class: "badge" %>
37
+ </div>
31
38
  <div class="card">
32
39
  <% if @event.payload.present? %>
33
- <pre class="json"><%= JSON.pretty_generate(@event.payload) %></pre>
40
+ <% if links_on %>
41
+ <dl class="detail-grid">
42
+ <% @event.payload.each do |key, value| %>
43
+ <dt class="text-mono"><%= key %></dt>
44
+ <dd>
45
+ <% if (rel = @payload_links[key.to_s]) %>
46
+ <%= link_to value, event_path(rel[:event_id]), title: rel[:aggregate_type] %>
47
+ <% else %>
48
+ <span class="text-mono"><%= value.inspect %></span>
49
+ <% end %>
50
+ </dd>
51
+ <% end %>
52
+ </dl>
53
+ <% else %>
54
+ <pre class="json"><%= JSON.pretty_generate(@event.payload) %></pre>
55
+ <% end %>
34
56
  <% else %>
35
57
  <p class="text-muted">No payload</p>
36
58
  <% end %>
@@ -45,10 +67,28 @@
45
67
  <% end %>
46
68
  </div>
47
69
 
48
- <h2>Aggregate State (at version <%= @event.version %>)</h2>
70
+ <div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:16px;">
71
+ <h2 style="margin-bottom:0">Aggregate State (at version <%= @event.version %>)</h2>
72
+ <%= link_to toggle_label, toggle_url, class: "badge" %>
73
+ </div>
49
74
  <div class="card">
50
75
  <% if @aggregate_state.present? %>
51
- <pre class="json"><%= JSON.pretty_generate(@aggregate_state) %></pre>
76
+ <% if links_on %>
77
+ <dl class="detail-grid">
78
+ <% @aggregate_state.each do |key, value| %>
79
+ <dt class="text-mono"><%= key %></dt>
80
+ <dd>
81
+ <% if (rel = @aggregate_state_links[key.to_s]) %>
82
+ <%= link_to value, event_path(rel[:event_id]), title: rel[:aggregate_type] %>
83
+ <% else %>
84
+ <span class="text-mono"><%= value.inspect %></span>
85
+ <% end %>
86
+ </dd>
87
+ <% end %>
88
+ </dl>
89
+ <% else %>
90
+ <pre class="json"><%= JSON.pretty_generate(@aggregate_state) %></pre>
91
+ <% end %>
52
92
  <% else %>
53
93
  <p class="text-muted">No aggregate</p>
54
94
  <% end %>
@@ -5,16 +5,15 @@ class CreateRailsSimpleEventSourcingEvents < ActiveRecord::Migration[7.1]
5
5
  create_table :rails_simple_event_sourcing_events do |t|
6
6
  t.references :eventable, polymorphic: true
7
7
  t.string :type, null: false
8
- t.string :event_type, null: false
9
8
  t.string :aggregate_id
10
9
  t.bigint :version
11
10
  t.jsonb :payload
12
11
  t.jsonb :metadata
12
+ t.integer :schema_version
13
13
 
14
14
  t.timestamps
15
15
 
16
16
  t.index :type
17
- t.index :event_type
18
17
  t.index %i[eventable_type aggregate_id version],
19
18
  unique: true,
20
19
  name: 'index_events_on_eventable_type_and_aggregate_id_and_version'
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSimpleEventSourcing
4
+ class AggregateLinksBuilder
5
+ def initialize(data)
6
+ @data = data
7
+ end
8
+
9
+ def call
10
+ return {} if @data.blank?
11
+
12
+ @data.filter_map { |key, value| entry_for(key.to_s, value) }.to_h
13
+ end
14
+
15
+ private
16
+
17
+ def entry_for(key, value)
18
+ return unless key.end_with?('_id') && value.present?
19
+
20
+ klass = resolve_klass(key)
21
+ return unless klass
22
+
23
+ latest_event = Event.where(eventable_type: klass.name, aggregate_id: value.to_s).order(version: :desc).first
24
+ return unless latest_event
25
+
26
+ [key, { aggregate_type: klass.name, aggregate_id: value, event_id: latest_event.id }]
27
+ end
28
+
29
+ def resolve_klass(key)
30
+ klass = key.delete_suffix('_id').camelize.safe_constantize
31
+ return klass if event_sourced?(klass)
32
+
33
+ event_sourced_classes.each do |source|
34
+ assoc = source.reflect_on_all_associations(:belongs_to).find { |r| r.foreign_key.to_s == key }
35
+ next unless assoc
36
+
37
+ target = assoc.class_name.safe_constantize
38
+ return target if event_sourced?(target)
39
+ end
40
+
41
+ nil
42
+ end
43
+
44
+ def event_sourced?(klass)
45
+ klass&.ancestors&.include?(RailsSimpleEventSourcing::Events)
46
+ end
47
+
48
+ def event_sourced_classes
49
+ @event_sourced_classes ||=
50
+ Event.where.not(eventable_type: nil)
51
+ .distinct.pluck(:eventable_type)
52
+ .filter_map(&:safe_constantize)
53
+ .select { |k| event_sourced?(k) }
54
+ end
55
+ end
56
+ end
@@ -11,12 +11,12 @@ module RailsSimpleEventSourcing
11
11
  def call
12
12
  return Result.failure(errors: @command.errors) unless @command.valid?
13
13
 
14
- initialize_command_handler.call
14
+ build_handler.call
15
15
  end
16
16
 
17
17
  private
18
18
 
19
- def initialize_command_handler
19
+ def build_handler
20
20
  handler_class = find_handler_class
21
21
  raise CommandHandlerNotFoundError, handler_not_found_message unless handler_class
22
22
 
@@ -24,21 +24,25 @@ module RailsSimpleEventSourcing
24
24
  end
25
25
 
26
26
  def find_handler_class
27
- handler_class = CommandHandlerRegistry.handler_for(@command.class)
27
+ CommandHandlerRegistry.handler_for(@command.class) || convention_handler_class
28
+ end
28
29
 
29
- if handler_class.nil? && RailsSimpleEventSourcing.config.use_naming_convention_fallback
30
- @convention_handler_name = @command.class.to_s.sub('::Commands::', '::CommandHandlers::')
31
- handler_class = @convention_handler_name.safe_constantize
32
- end
30
+ def convention_handler_class
31
+ return unless RailsSimpleEventSourcing.config.use_naming_convention_fallback
33
32
 
34
- handler_class
33
+ convention_handler_name.safe_constantize
34
+ end
35
+
36
+ def convention_handler_name
37
+ @convention_handler_name ||= @command.class.to_s.sub('::Commands::', '::CommandHandlers::')
35
38
  end
36
39
 
37
40
  def handler_not_found_message
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
41
+ msg = "No handler found for #{@command.class}."
42
+ if RailsSimpleEventSourcing.config.use_naming_convention_fallback
43
+ msg += " Tried convention-based lookup: #{convention_handler_name} (not found)."
44
+ end
45
+ msg + " Register one with CommandHandlerRegistry.register(#{@command.class}, YourHandlerClass)"
42
46
  end
43
47
  end
44
48
  end
@@ -3,8 +3,6 @@
3
3
  module RailsSimpleEventSourcing
4
4
  module CommandHandlers
5
5
  class Base
6
- delegate :success, :failure, to: 'RailsSimpleEventSourcing::Result'
7
-
8
6
  def initialize(command:)
9
7
  @command = command
10
8
  end
@@ -12,6 +10,18 @@ module RailsSimpleEventSourcing
12
10
  def call
13
11
  raise NotImplementedError, "You must implement #{self.class}#call"
14
12
  end
13
+
14
+ private
15
+
16
+ attr_reader :command
17
+
18
+ def success(data: nil)
19
+ Result.success(data:)
20
+ end
21
+
22
+ def failure(errors:)
23
+ Result.failure(errors:)
24
+ end
15
25
  end
16
26
  end
17
27
  end
@@ -6,6 +6,7 @@ require_relative 'command_handler'
6
6
  require_relative 'command_handlers/base'
7
7
  require_relative 'commands/base'
8
8
  require_relative 'event_player'
9
+ require_relative 'aggregate_links_builder'
9
10
  require_relative 'event_search'
10
11
  require_relative 'paginator'
11
12
  require_relative 'result'
@@ -6,12 +6,16 @@ module RailsSimpleEventSourcing
6
6
 
7
7
  class << self
8
8
  def subscribe(event_class, subscriber)
9
+ unless subscriber.is_a?(Class) && subscriber < ActiveJob::Base
10
+ raise ArgumentError, "Subscriber must be an ActiveJob class, got #{subscriber}"
11
+ end
12
+
9
13
  @subscriptions[event_class.to_s] << subscriber
10
14
  end
11
15
 
12
16
  def dispatch(event)
13
- ancestors_with_subscriptions(event).each do |subscriber|
14
- subscriber.call(event)
17
+ subscribers_for(event).each do |subscriber|
18
+ subscriber.perform_later(event)
15
19
  end
16
20
  end
17
21
 
@@ -21,7 +25,7 @@ module RailsSimpleEventSourcing
21
25
 
22
26
  private
23
27
 
24
- def ancestors_with_subscriptions(event)
28
+ def subscribers_for(event)
25
29
  event.class.ancestors
26
30
  .select { |ancestor| @subscriptions.key?(ancestor.to_s) }
27
31
  .flat_map { |ancestor| @subscriptions[ancestor.to_s] }
@@ -4,15 +4,15 @@ module RailsSimpleEventSourcing
4
4
  class EventSearch
5
5
  KEY_VALUE_PATTERN = /\A([^:]+):(.+)\z/
6
6
 
7
- def initialize(scope:, event_type: nil, aggregate: nil, query: nil)
7
+ def initialize(scope:, type: nil, aggregate: nil, query: nil)
8
8
  @scope = scope
9
- @event_type = event_type
9
+ @type = type
10
10
  @aggregate = aggregate
11
11
  @query = query&.strip
12
12
  end
13
13
 
14
14
  def call
15
- filter_by_event_type
15
+ filter_by_type
16
16
  filter_by_aggregate
17
17
  filter_by_query
18
18
  @scope
@@ -20,10 +20,10 @@ module RailsSimpleEventSourcing
20
20
 
21
21
  private
22
22
 
23
- def filter_by_event_type
24
- return if @event_type.blank?
23
+ def filter_by_type
24
+ return if @type.blank?
25
25
 
26
- @scope = @scope.where(event_type: @event_type)
26
+ @scope = @scope.where(type: @type)
27
27
  end
28
28
 
29
29
  def filter_by_aggregate
@@ -25,17 +25,21 @@ module RailsSimpleEventSourcing
25
25
  @success
26
26
  end
27
27
 
28
- def on_success(&block)
29
- raise ArgumentError, 'Block required' unless block
28
+ def failure?
29
+ !@success
30
+ end
31
+
32
+ def on_success
33
+ raise ArgumentError, 'Block required' unless block_given?
30
34
 
31
- block.call(data) if success?
35
+ yield data if success?
32
36
  self
33
37
  end
34
38
 
35
- def on_failure(&block)
36
- raise ArgumentError, 'Block required' unless block
39
+ def on_failure
40
+ raise ArgumentError, 'Block required' unless block_given?
37
41
 
38
- block.call(errors) unless success?
42
+ yield errors unless success?
39
43
  self
40
44
  end
41
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsSimpleEventSourcing
4
- VERSION = '1.0.11'
4
+ VERSION = '1.1.0'
5
5
  end
@@ -14,4 +14,8 @@ module RailsSimpleEventSourcing
14
14
  def self.config
15
15
  @config ||= Configuration.new
16
16
  end
17
+
18
+ def self.dispatch(command)
19
+ CommandHandler.new(command).call
20
+ end
17
21
  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.11
4
+ version: 1.1.0
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-12 00:00:00.000000000 Z
11
+ date: 2026-03-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg
@@ -98,6 +98,7 @@ files:
98
98
  - app/models/concerns/rails_simple_event_sourcing/event_attributes.rb
99
99
  - app/models/concerns/rails_simple_event_sourcing/events.rb
100
100
  - app/models/concerns/rails_simple_event_sourcing/read_only.rb
101
+ - app/models/concerns/rails_simple_event_sourcing/schema_versioning.rb
101
102
  - app/models/rails_simple_event_sourcing.rb
102
103
  - app/models/rails_simple_event_sourcing/current_request.rb
103
104
  - app/models/rails_simple_event_sourcing/event.rb
@@ -108,6 +109,7 @@ files:
108
109
  - config/routes.rb
109
110
  - db/migrate/20231231133250_create_rails_simple_event_sourcing_events.rb
110
111
  - lib/rails_simple_event_sourcing.rb
112
+ - lib/rails_simple_event_sourcing/aggregate_links_builder.rb
111
113
  - lib/rails_simple_event_sourcing/aggregate_repository.rb
112
114
  - lib/rails_simple_event_sourcing/command_handler.rb
113
115
  - lib/rails_simple_event_sourcing/command_handler_registry.rb