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 +4 -4
- data/README.md +260 -47
- data/app/controllers/concerns/rails_simple_event_sourcing/set_current_request_details.rb +6 -2
- data/app/controllers/rails_simple_event_sourcing/events_controller.rb +4 -2
- data/app/models/concerns/rails_simple_event_sourcing/schema_versioning.rb +61 -0
- data/app/models/rails_simple_event_sourcing/event.rb +2 -2
- data/app/views/layouts/rails_simple_event_sourcing/application.html.erb +1 -1
- data/app/views/rails_simple_event_sourcing/events/index.html.erb +1 -1
- data/app/views/rails_simple_event_sourcing/events/show.html.erb +45 -5
- data/db/migrate/20231231133250_create_rails_simple_event_sourcing_events.rb +1 -2
- data/lib/rails_simple_event_sourcing/aggregate_links_builder.rb +56 -0
- data/lib/rails_simple_event_sourcing/command_handler.rb +16 -12
- data/lib/rails_simple_event_sourcing/engine.rb +1 -0
- data/lib/rails_simple_event_sourcing/event_bus.rb +7 -3
- data/lib/rails_simple_event_sourcing/event_search.rb +6 -6
- data/lib/rails_simple_event_sourcing/result.rb +6 -6
- data/lib/rails_simple_event_sourcing/version.rb +1 -1
- data/lib/rails_simple_event_sourcing.rb +4 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a12c3adaddba63ac71f028aa700a4c14826999551657b1cafe758d9d7c1a66f
|
|
4
|
+
data.tar.gz: 480fcef167c89edb26e9ebe04596f3a90fe9cc04e58064bcb46309abba5926f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
- `
|
|
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
|
-
**
|
|
538
|
-
Override
|
|
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
|
|
545
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
|
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 "
|
|
738
|
-
emails = []
|
|
880
|
+
test "enqueues welcome email job on customer created" do
|
|
739
881
|
RailsSimpleEventSourcing::EventBus.subscribe(
|
|
740
882
|
Customer::Events::CustomerCreated,
|
|
741
|
-
|
|
883
|
+
Customer::Subscribers::SendWelcomeEmail
|
|
742
884
|
)
|
|
743
885
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
13
|
+
CurrentRequest.metadata = default_event_metadata.merge(custom_event_metadata)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def
|
|
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(:
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
14
|
+
build_handler.call
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
private
|
|
18
18
|
|
|
19
|
-
def
|
|
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
|
-
|
|
27
|
+
CommandHandlerRegistry.handler_for(@command.class) || convention_handler_class
|
|
28
|
+
end
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
14
|
-
subscriber.
|
|
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
|
|
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:,
|
|
7
|
+
def initialize(scope:, type: nil, aggregate: nil, query: nil)
|
|
8
8
|
@scope = scope
|
|
9
|
-
@
|
|
9
|
+
@type = type
|
|
10
10
|
@aggregate = aggregate
|
|
11
11
|
@query = query&.strip
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def call
|
|
15
|
-
|
|
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
|
|
24
|
-
return if @
|
|
23
|
+
def filter_by_type
|
|
24
|
+
return if @type.blank?
|
|
25
25
|
|
|
26
|
-
@scope = @scope.where(
|
|
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
|
|
33
|
-
raise ArgumentError, 'Block required' unless
|
|
32
|
+
def on_success
|
|
33
|
+
raise ArgumentError, 'Block required' unless block_given?
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
yield data if success?
|
|
36
36
|
self
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def on_failure
|
|
40
|
-
raise ArgumentError, 'Block required' unless
|
|
39
|
+
def on_failure
|
|
40
|
+
raise ArgumentError, 'Block required' unless block_given?
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
yield errors unless success?
|
|
43
43
|
self
|
|
44
44
|
end
|
|
45
45
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_simple_event_sourcing
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.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-
|
|
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
|