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 +4 -4
- data/README.md +272 -57
- 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/command_handlers/base.rb +12 -2
- 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 +10 -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
|
|
|
@@ -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
|
|
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?
|
|
209
|
-
result.
|
|
210
|
-
result.
|
|
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:
|
|
227
|
-
last_name:
|
|
228
|
-
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:
|
|
249
|
-
last_name:
|
|
250
|
-
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
- `
|
|
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
|
-
**
|
|
536
|
-
Override
|
|
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
|
|
543
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
|
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 "
|
|
736
|
-
emails = []
|
|
880
|
+
test "enqueues welcome email job on customer created" do
|
|
737
881
|
RailsSimpleEventSourcing::EventBus.subscribe(
|
|
738
882
|
Customer::Events::CustomerCreated,
|
|
739
|
-
|
|
883
|
+
Customer::Subscribers::SendWelcomeEmail
|
|
740
884
|
)
|
|
741
885
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
@@ -25,17 +25,21 @@ module RailsSimpleEventSourcing
|
|
|
25
25
|
@success
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def
|
|
29
|
-
|
|
28
|
+
def failure?
|
|
29
|
+
!@success
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def on_success
|
|
33
|
+
raise ArgumentError, 'Block required' unless block_given?
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
yield data if success?
|
|
32
36
|
self
|
|
33
37
|
end
|
|
34
38
|
|
|
35
|
-
def on_failure
|
|
36
|
-
raise ArgumentError, 'Block required' unless
|
|
39
|
+
def on_failure
|
|
40
|
+
raise ArgumentError, 'Block required' unless block_given?
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
yield errors unless success?
|
|
39
43
|
self
|
|
40
44
|
end
|
|
41
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
|