rails_simple_event_sourcing 1.0.12 → 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: d389be62e627ddf2443c6e7d781d1abbab7aaacf7b908741a753e4dbe370c978
4
- data.tar.gz: 4dea2cb2546bd903a566edff8f0fb192be5b02ae1f45f36004cf58ba7d30a64a
3
+ metadata.gz: 1a12c3adaddba63ac71f028aa700a4c14826999551657b1cafe758d9d7c1a66f
4
+ data.tar.gz: 480fcef167c89edb26e9ebe04596f3a90fe9cc04e58064bcb46309abba5926f6
5
5
  SHA512:
6
- metadata.gz: 2e7671967605ab8714cdb39dec920b7fc1449bf4c4adedd77cf409fa89db7497e2f462c1176decb149d9191b01cdd973e88708de836c8f7f88bef98328246608
7
- data.tar.gz: a3543ef2e0dd176bb85099be1b4d65e9cc0f148644eb5011435920325447114218f84fff9fa6a4721d462e35c2d1568f9ba4665648ff27b26fc7d3cdab550526
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
 
@@ -337,6 +350,7 @@ end
337
350
  **Understanding the Event Structure:**
338
351
  - `aggregate_class Customer` - Specifies which model this event modifies
339
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)
340
354
  - `apply(aggregate)` - Optional method; only implement if you need custom logic beyond automatic attribute assignment
341
355
  - `aggregate_id` - Auto-generated for creates, must be provided for updates/deletes
342
356
 
@@ -344,6 +358,55 @@ end
344
358
  - Optional - you can have events without an aggregate (e.g., `UserLoginFailed` for logging only)
345
359
  - The corresponding model should include `RailsSimpleEventSourcing::Events` for read-only protection
346
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
+
347
410
  ### Registering Command Handlers
348
411
 
349
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.
@@ -389,7 +452,7 @@ class CustomersController < ApplicationController
389
452
  email: params[:email]
390
453
  )
391
454
 
392
- RailsSimpleEventSourcing::CommandHandler.new(cmd).call
455
+ RailsSimpleEventSourcing.dispatch(cmd)
393
456
  .on_success { |data| render json: data }
394
457
  .on_failure { |errors| render json: { errors: }, status: :unprocessable_entity }
395
458
  end
@@ -410,7 +473,7 @@ class CustomersController < ApplicationController
410
473
  email: params[:email]
411
474
  )
412
475
 
413
- RailsSimpleEventSourcing::CommandHandler.new(cmd).call
476
+ RailsSimpleEventSourcing.dispatch(cmd)
414
477
  .on_success { |data| render json: data }
415
478
  .on_failure { |errors| render json: { errors: }, status: :unprocessable_entity }
416
479
  end
@@ -424,7 +487,7 @@ class CustomersController < ApplicationController
424
487
  def destroy
425
488
  cmd = Customer::Commands::Delete.new(aggregate_id: params[:id])
426
489
 
427
- RailsSimpleEventSourcing::CommandHandler.new(cmd).call
490
+ RailsSimpleEventSourcing.dispatch(cmd)
428
491
  .on_success { head :no_content }
429
492
  .on_failure { |errors| render json: { errors: }, status: :unprocessable_entity }
430
493
  end
@@ -479,7 +542,7 @@ event.metadata
479
542
  # "request_user_agent"=>"curl/8.6.0", ...}
480
543
 
481
544
  # Query events by type
482
- RailsSimpleEventSourcing::Event.where(event_type: "Customer::Events::CustomerCreated")
545
+ RailsSimpleEventSourcing::Event.where(type: "Customer::Events::CustomerCreated")
483
546
 
484
547
  # Get events in a date range
485
548
  customer.events.where(created_at: 1.week.ago..Time.now)
@@ -510,7 +573,7 @@ latest_event.aggregate_state
510
573
  **Event Structure:**
511
574
  - `payload` - Contains the event attributes you defined (as JSON)
512
575
  - `metadata` - Contains request context (request ID, IP, user agent, params)
513
- - `event_type` - The event class name
576
+ - `type` - The event class name (Rails STI column)
514
577
  - `aggregate_id` - Links to the aggregate instance
515
578
  - `eventable` - Polymorphic relation to the aggregate
516
579
 
@@ -534,23 +597,36 @@ By default, the following is captured:
534
597
  - `request_ip` - Client IP address
535
598
  - `request_params` - Request parameters (filtered using Rails parameter filter)
536
599
 
537
- **Customizing Metadata:**
538
- 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:
539
602
 
540
603
  ```ruby
541
604
  class ApplicationController < ActionController::Base
542
605
  include RailsSimpleEventSourcing::SetCurrentRequestDetails
543
606
 
544
- def event_metadata
545
- 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
546
624
 
625
+ def default_event_metadata
547
626
  {
548
627
  request_id: request.uuid,
549
- request_user_agent: request.user_agent,
550
- request_ip: request.ip,
551
- request_params: parameter_filter.filter(request.params),
552
- current_user_id: current_user&.id, # Add custom fields
553
- tenant_id: current_tenant&.id
628
+ request_ip: request.ip
629
+ # only keep what you need
554
630
  }
555
631
  end
556
632
  end
@@ -671,9 +747,64 @@ class Customer::Events::CustomerDeleted < RailsSimpleEventSourcing::Event
671
747
  end
672
748
  ```
673
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
+
674
805
  ### Event Subscriptions
675
806
 
676
- 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.
677
808
 
678
809
  **Registering subscribers:**
679
810
 
@@ -682,35 +813,45 @@ The `EventBus` lets you react to events after they are persisted and committed t
682
813
  Rails.application.config.after_initialize do
683
814
  RailsSimpleEventSourcing::EventBus.subscribe(
684
815
  Customer::Events::CustomerCreated,
685
- Subscribers::SendWelcomeEmail
816
+ Customer::Subscribers::SendWelcomeEmail
686
817
  )
687
818
 
688
819
  RailsSimpleEventSourcing::EventBus.subscribe(
689
820
  Customer::Events::CustomerCreated,
690
- Subscribers::CreateStripeCustomer
821
+ Customer::Subscribers::CreateStripeCustomer
691
822
  )
692
823
 
693
824
  RailsSimpleEventSourcing::EventBus.subscribe(
694
825
  Customer::Events::CustomerDeleted,
695
- Subscribers::CancelStripeSubscription
826
+ Customer::Subscribers::CancelStripeSubscription
696
827
  )
697
828
  end
698
829
  ```
699
830
 
700
831
  **Writing a subscriber:**
701
832
 
702
- 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.
703
834
 
704
835
  ```ruby
705
- module Subscribers
706
- class SendWelcomeEmail
707
- def self.call(event)
708
- 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
709
844
  end
710
845
  end
711
846
  end
712
847
  ```
713
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
+
714
855
  **Subscribing to all events:**
715
856
 
716
857
  Subscribe to `RailsSimpleEventSourcing::Event` to receive every event regardless of type — useful for audit loggers or metrics:
@@ -718,39 +859,112 @@ Subscribe to `RailsSimpleEventSourcing::Event` to receive every event regardless
718
859
  ```ruby
719
860
  RailsSimpleEventSourcing::EventBus.subscribe(
720
861
  RailsSimpleEventSourcing::Event,
721
- Subscribers::AuditLogger
862
+ Customer::Subscribers::AuditLogger
722
863
  )
723
864
  ```
724
865
 
725
- 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.
726
867
 
727
868
  **Testing with EventBus:**
728
869
 
729
- 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:
730
871
 
731
872
  ```ruby
732
873
  class MyTest < ActiveSupport::TestCase
874
+ include ActiveJob::TestHelper
875
+
733
876
  setup do
734
877
  RailsSimpleEventSourcing::EventBus.reset!
735
878
  end
736
879
 
737
- test "sends welcome email on customer created" do
738
- emails = []
880
+ test "enqueues welcome email job on customer created" do
739
881
  RailsSimpleEventSourcing::EventBus.subscribe(
740
882
  Customer::Events::CustomerCreated,
741
- ->(event) { emails << event.email }
883
+ Customer::Subscribers::SendWelcomeEmail
742
884
  )
743
885
 
744
- Customer::Events::CustomerCreated.create!(
745
- first_name: "John", last_name: "Doe", email: "john@example.com",
746
- created_at: Time.zone.now, updated_at: Time.zone.now
747
- )
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
748
897
 
749
- 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
750
956
  end
751
957
  end
752
958
  ```
753
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
+
754
968
  ## Testing
755
969
 
756
970
  ### Setting Up Tests with Command Handler Registry
@@ -823,7 +1037,7 @@ class Customer::CommandHandlers::CreateTest < ActiveSupport::TestCase
823
1037
  email: "john@example.com"
824
1038
  )
825
1039
 
826
- result = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
1040
+ result = RailsSimpleEventSourcing.dispatch(cmd)
827
1041
 
828
1042
  assert result.success?
829
1043
  assert_instance_of Customer, result.data
@@ -846,7 +1060,7 @@ class Customer::CommandHandlers::CreateTest < ActiveSupport::TestCase
846
1060
  email: "john@example.com"
847
1061
  )
848
1062
 
849
- result = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
1063
+ result = RailsSimpleEventSourcing.dispatch(cmd)
850
1064
 
851
1065
  assert_not result.success?
852
1066
  assert_includes result.errors, "Email has already been taken"
@@ -878,7 +1092,6 @@ end
878
1092
  Be aware of these limitations when using this gem:
879
1093
 
880
1094
  - **PostgreSQL Only** - Requires PostgreSQL 9.4+ for JSONB support
881
- - **No Event Versioning** - No built-in support for evolving event schemas over time
882
1095
  - **No Snapshots** - All aggregate reconstruction done by replaying all events (can be slow for aggregates with many events)
883
1096
  - **No Projections** - No built-in read model or projection support
884
1097
  - **Manual aggregate_id** - Must manually track and pass `aggregate_id` for updates/deletes
@@ -959,7 +1172,7 @@ customer.update(first_name: "Jane")
959
1172
 
960
1173
  # Do this:
961
1174
  cmd = Customer::Commands::Update.new(aggregate_id: customer.id, first_name: "Jane", ...)
962
- RailsSimpleEventSourcing::CommandHandler.new(cmd).call
1175
+ RailsSimpleEventSourcing.dispatch(cmd)
963
1176
  ```
964
1177
 
965
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
@@ -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
@@ -29,17 +29,17 @@ module RailsSimpleEventSourcing
29
29
  !@success
30
30
  end
31
31
 
32
- def on_success(&block)
33
- raise ArgumentError, 'Block required' unless block
32
+ def on_success
33
+ raise ArgumentError, 'Block required' unless block_given?
34
34
 
35
- block.call(data) if success?
35
+ yield data if success?
36
36
  self
37
37
  end
38
38
 
39
- def on_failure(&block)
40
- raise ArgumentError, 'Block required' unless block
39
+ def on_failure
40
+ raise ArgumentError, 'Block required' unless block_given?
41
41
 
42
- block.call(errors) unless success?
42
+ yield errors unless success?
43
43
  self
44
44
  end
45
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsSimpleEventSourcing
4
- VERSION = '1.0.12'
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.12
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-15 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