funes-rails 0.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +180 -0
- data/Rakefile +135 -0
- data/app/assets/stylesheets/funes/application.css +15 -0
- data/app/controllers/funes/application_controller.rb +4 -0
- data/app/event_streams/funes/event_stream.rb +274 -0
- data/app/helpers/funes/application_helper.rb +4 -0
- data/app/helpers/funes/projection_test_helper.rb +54 -0
- data/app/jobs/funes/application_job.rb +4 -0
- data/app/jobs/funes/persist_projection_job.rb +8 -0
- data/app/mailers/funes/application_mailer.rb +6 -0
- data/app/models/funes/application_record.rb +5 -0
- data/app/models/funes/event.rb +137 -0
- data/app/models/funes/event_entry.rb +9 -0
- data/app/projections/funes/projection.rb +203 -0
- data/app/views/layouts/funes/application.html.erb +17 -0
- data/config/locales/en.yml +5 -0
- data/config/routes.rb +2 -0
- data/lib/funes/engine.rb +10 -0
- data/lib/funes/unknown_event.rb +24 -0
- data/lib/funes/unknown_materialization_model.rb +29 -0
- data/lib/funes/version.rb +3 -0
- data/lib/funes.rb +72 -0
- data/lib/generators/funes/install_generator.rb +41 -0
- data/lib/generators/funes/templates/migration.rb.tt +16 -0
- data/lib/tasks/funes_tasks.rake +4 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b28b73445f6209c360034c351b80e0677356b42c14834908e9598a6b6f7edd02
|
|
4
|
+
data.tar.gz: 7587dfa95f04e2e2a1b0eb505c19f0433541b2c46fa49e83471dee1386fec79b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 828e4c5762de480e79f264124d12e7d4792ffc4856c3941dc4e757b8ebc9a31e0e645af98113a127df5f0d22f38398fe6a3c1e50511a9e59cd7e59b9cf05d8fd
|
|
7
|
+
data.tar.gz: 9feb8d0618335d5d1eb23027d5ec50cea4b91753b016bfe0b2cea3ba3567dfe43303f8173c07f06857b62e0aaa71ba3da33ba625bf5b4a35afb655a649b18254
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Vinícius Almeida da Silva
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Funes
|
|
2
|
+
|
|
3
|
+
Event sourcing for Ruby on Rails — append-only events as your source of truth, with flexible projections for reads.
|
|
4
|
+
|
|
5
|
+
## Event Sourcing?
|
|
6
|
+
|
|
7
|
+
Traditional Rails apps update state in place. You `update!` a record and the previous value is gone. Event sourcing takes a different approach: store *what happened* as immutable events, then derive current state by replaying them.
|
|
8
|
+
|
|
9
|
+
This gives you:
|
|
10
|
+
|
|
11
|
+
- **Complete audit trail** — every state change is recorded, forever
|
|
12
|
+
- **Temporal queries** — "what was the balance on December 1st?"
|
|
13
|
+
- **Multiple read models** — same events, different projections for different use cases
|
|
14
|
+
- **Safer refactoring** — rebuild any projection from the event log
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add to your Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "funes-rails"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Run the installation:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
$ bin/bundle install
|
|
28
|
+
$ bin/rails generate funes:install
|
|
29
|
+
$ bin/rails db:migrate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Three-Tier Consistency Model
|
|
33
|
+
|
|
34
|
+
Funes gives you fine-grained control over when and how projections run:
|
|
35
|
+
|
|
36
|
+
| Tier | When it runs | Use case |
|
|
37
|
+
|:--------------------------|:-----------------------------|:------------------------------------------------|
|
|
38
|
+
| Consistency Projection | Before event is persisted | Validate business rules against resulting state |
|
|
39
|
+
| Transactional Projections | Same DB transaction as event | Critical read models needing strong consistency |
|
|
40
|
+
| Async Projections | Background job (ActiveJob) | Reports, analytics, non-critical read models |
|
|
41
|
+
|
|
42
|
+
### Consistency Projection
|
|
43
|
+
|
|
44
|
+
Runs before the event is saved. If the resulting state is invalid, the event is rejected:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class InventoryEventStream < Funes::EventStream
|
|
48
|
+
consistency_projection InventorySnapshotProjection
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class InventorySnapshot
|
|
52
|
+
include ActiveModel::Model
|
|
53
|
+
include ActiveModel::Attributes
|
|
54
|
+
|
|
55
|
+
attribute :quantity_on_hand, :integer, default: 0
|
|
56
|
+
|
|
57
|
+
validates :quantity_on_hand, numericality: { greater_than_or_equal_to: 0 }
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Now if someone tries to ship more than available:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
|
|
65
|
+
event = stream.append!(Inventory::ItemShipped.new(quantity: 9999))
|
|
66
|
+
event.valid? # => false
|
|
67
|
+
event.errors[:quantity_on_hand] # => ["must be greater than or equal to 0"]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The event is never persisted. Your invariants are protected.
|
|
71
|
+
|
|
72
|
+
### Transactional Projections
|
|
73
|
+
|
|
74
|
+
Update read models in the same database transaction. If anything fails, everything rolls back:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
add_transactional_projection InventoryLedgerProjection
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Async Projections
|
|
81
|
+
|
|
82
|
+
Schedule background jobs with full ActiveJob options:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
add_async_projection ReportingProjection, queue: :low, wait: 5.minutes
|
|
86
|
+
add_async_projection AnalyticsProjection, wait_until: Date.tomorrow.midnight
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### Controlling the `as_of` Timestamp
|
|
90
|
+
|
|
91
|
+
By default, async projections use the creation time of the last event. You can customize this behavior:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
# Use job execution time instead of event time
|
|
95
|
+
add_async_projection RealtimeProjection, as_of: :job_time
|
|
96
|
+
|
|
97
|
+
# Custom logic with a proc
|
|
98
|
+
add_async_projection EndOfDayProjection,
|
|
99
|
+
as_of: ->(last_event) { last_event.created_at.beginning_of_day }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Available `as_of` strategies:
|
|
103
|
+
- `:last_event_time` (default) — Uses the creation time of the last event
|
|
104
|
+
- `:job_time` — Uses `Time.current` when the job executes
|
|
105
|
+
- `Proc/Lambda` — Custom logic that receives the last event and returns a `Time` object
|
|
106
|
+
|
|
107
|
+
## Temporal Queries
|
|
108
|
+
|
|
109
|
+
Every event is timestamped. Query your stream at any point in time:
|
|
110
|
+
|
|
111
|
+
### Current state
|
|
112
|
+
```ruby
|
|
113
|
+
stream = InventoryEventStream.for("sku-12345")
|
|
114
|
+
stream.events # => all events
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### State as of last month
|
|
118
|
+
```ruby
|
|
119
|
+
stream = InventoryEventStream.for("sku-12345", 1.month.ago)
|
|
120
|
+
stream.events # => events up to that timestamp
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Projections receive the as_of parameter, so you can build point-in-time snapshots:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# in your projection
|
|
127
|
+
interpretation_for(Inventory::ItemReceived) do |state, event, as_of|
|
|
128
|
+
# as_of is available if you need temporal logic
|
|
129
|
+
state.quantity_on_hand += event.quantity
|
|
130
|
+
state
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Concurrency
|
|
135
|
+
|
|
136
|
+
Funes uses optimistic concurrency control. Each event in a stream gets an incrementing version number with a unique constraint on (idx, version).
|
|
137
|
+
|
|
138
|
+
If two processes try to append to the same stream simultaneously, one succeeds and the other gets a validation error — no locks, no blocking:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
event = stream.append!(SomeEvent.new)
|
|
142
|
+
unless event.valid?
|
|
143
|
+
event.errors[:version] # => ["has already been taken"]
|
|
144
|
+
# Reload and retry your business logic
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Testing
|
|
149
|
+
|
|
150
|
+
Funes provides helpers for testing projections in isolation:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
class InventorySnapshotProjectionTest < ActiveSupport::TestCase
|
|
154
|
+
include Funes::ProjectionTestHelper
|
|
155
|
+
|
|
156
|
+
test "receiving items increases quantity on hand" do
|
|
157
|
+
initial_state = InventorySnapshot.new(quantity_on_hand: 10)
|
|
158
|
+
event = Inventory::ItemReceived.new(quantity: 5, unit_cost: 9.99)
|
|
159
|
+
|
|
160
|
+
result = interpret_event_based_on(InventorySnapshotProjection, event, initial_state)
|
|
161
|
+
|
|
162
|
+
assert_equal 15, result.quantity_on_hand
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Strict Mode
|
|
168
|
+
|
|
169
|
+
By default, projections ignore events they don't have interpretations for. Enable strict mode to catch missing handlers:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class StrictProjection < Funes::Projection
|
|
173
|
+
raise_on_unknown_events
|
|
174
|
+
|
|
175
|
+
# Now forgetting to handle an event type raises Funes::UnknownEvent
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
|
|
3
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
4
|
+
load "rails/tasks/engine.rake"
|
|
5
|
+
|
|
6
|
+
load "rails/tasks/statistics.rake"
|
|
7
|
+
|
|
8
|
+
require "bundler/gem_tasks"
|
|
9
|
+
|
|
10
|
+
namespace :docs do
|
|
11
|
+
desc "Generate YARD documentation for current version"
|
|
12
|
+
task :generate do
|
|
13
|
+
require_relative "lib/funes/version"
|
|
14
|
+
version = Funes::VERSION
|
|
15
|
+
output_dir = "docs/v#{version}"
|
|
16
|
+
|
|
17
|
+
puts "Generating documentation for version #{version}..."
|
|
18
|
+
system("yard doc --output-dir #{output_dir}") || abort("Failed to generate documentation")
|
|
19
|
+
|
|
20
|
+
# Copy assets to root docs directory
|
|
21
|
+
FileUtils.mkdir_p("docs")
|
|
22
|
+
%w[css js].each do |asset_dir|
|
|
23
|
+
if Dir.exist?("#{output_dir}/#{asset_dir}")
|
|
24
|
+
FileUtils.cp_r("#{output_dir}/#{asset_dir}", "docs/#{asset_dir}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
puts "Documentation generated in #{output_dir}/"
|
|
29
|
+
Rake::Task["docs:build_index"].invoke
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "Build version selector index page"
|
|
33
|
+
task :build_index do
|
|
34
|
+
versions = Dir.glob("docs/v*").map { |d| File.basename(d) }.sort.reverse
|
|
35
|
+
|
|
36
|
+
if versions.empty?
|
|
37
|
+
puts "No versions found. Run 'rake docs:generate' first."
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
latest_version = versions.first
|
|
42
|
+
|
|
43
|
+
html = <<~HTML
|
|
44
|
+
<!DOCTYPE html>
|
|
45
|
+
<html>
|
|
46
|
+
<head>
|
|
47
|
+
<meta charset="utf-8">
|
|
48
|
+
<title>Funes Documentation</title>
|
|
49
|
+
<link rel="stylesheet" href="css/style.css">
|
|
50
|
+
<style>
|
|
51
|
+
body {
|
|
52
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
53
|
+
max-width: 800px;
|
|
54
|
+
margin: 50px auto;
|
|
55
|
+
padding: 20px;
|
|
56
|
+
line-height: 1.6;
|
|
57
|
+
}
|
|
58
|
+
h1 {
|
|
59
|
+
color: #333;
|
|
60
|
+
border-bottom: 2px solid #0066cc;
|
|
61
|
+
padding-bottom: 10px;
|
|
62
|
+
}
|
|
63
|
+
.version-list {
|
|
64
|
+
list-style: none;
|
|
65
|
+
padding: 0;
|
|
66
|
+
}
|
|
67
|
+
.version-list li {
|
|
68
|
+
margin: 10px 0;
|
|
69
|
+
padding: 15px;
|
|
70
|
+
background: #f5f5f5;
|
|
71
|
+
border-radius: 5px;
|
|
72
|
+
}
|
|
73
|
+
.version-list a {
|
|
74
|
+
text-decoration: none;
|
|
75
|
+
color: #0066cc;
|
|
76
|
+
font-size: 18px;
|
|
77
|
+
font-weight: 500;
|
|
78
|
+
}
|
|
79
|
+
.version-list a:hover {
|
|
80
|
+
text-decoration: underline;
|
|
81
|
+
}
|
|
82
|
+
.latest-badge {
|
|
83
|
+
background: #0066cc;
|
|
84
|
+
color: white;
|
|
85
|
+
padding: 3px 8px;
|
|
86
|
+
border-radius: 3px;
|
|
87
|
+
font-size: 12px;
|
|
88
|
+
margin-left: 10px;
|
|
89
|
+
}
|
|
90
|
+
.description {
|
|
91
|
+
color: #666;
|
|
92
|
+
margin-top: 20px;
|
|
93
|
+
}
|
|
94
|
+
</style>
|
|
95
|
+
</head>
|
|
96
|
+
<body>
|
|
97
|
+
<h1>Funes Documentation</h1>
|
|
98
|
+
<p class="description">Event Sourcing for Rails - Select a version to view documentation</p>
|
|
99
|
+
|
|
100
|
+
<ul class="version-list">
|
|
101
|
+
HTML
|
|
102
|
+
|
|
103
|
+
versions.each do |version|
|
|
104
|
+
is_latest = version == latest_version
|
|
105
|
+
badge = is_latest ? '<span class="latest-badge">latest</span>' : ''
|
|
106
|
+
html += " <li><a href=\"#{version}/index.html\">#{version}#{badge}</a></li>\n"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
html += <<~HTML
|
|
110
|
+
</ul>
|
|
111
|
+
|
|
112
|
+
<p class="description">
|
|
113
|
+
<a href="https://github.com/funes-org/funes">View on GitHub</a> |
|
|
114
|
+
<a href="https://funes.org/">Official Website</a>
|
|
115
|
+
</p>
|
|
116
|
+
</body>
|
|
117
|
+
</html>
|
|
118
|
+
HTML
|
|
119
|
+
|
|
120
|
+
File.write("docs/index.html", html)
|
|
121
|
+
puts "Version index page created at docs/index.html"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
desc "List all documented versions"
|
|
125
|
+
task :list do
|
|
126
|
+
versions = Dir.glob("docs/v*").map { |d| File.basename(d) }.sort.reverse
|
|
127
|
+
|
|
128
|
+
if versions.empty?
|
|
129
|
+
puts "No versions documented yet."
|
|
130
|
+
else
|
|
131
|
+
puts "Documented versions:"
|
|
132
|
+
versions.each { |v| puts " - #{v}" }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
module Funes
|
|
2
|
+
# EventStream manages the append-only sequence of events for a specific entity.
|
|
3
|
+
# Each stream is identified by an `idx` (entity identifier) and provides methods for appending
|
|
4
|
+
# events and configuring how projections are triggered.
|
|
5
|
+
#
|
|
6
|
+
# EventStreams implement a three-tier consistency model:
|
|
7
|
+
#
|
|
8
|
+
# - **Consistency Projection:** Validates business rules before persisting the event. If invalid, the event is rejected.
|
|
9
|
+
# - **Transactional Projections:** Execute synchronously in the same database transaction as the event.
|
|
10
|
+
# - **Async Projections:** Execute asynchronously via ActiveJob after the event is committed.
|
|
11
|
+
#
|
|
12
|
+
# ## Temporal Queries
|
|
13
|
+
#
|
|
14
|
+
# EventStreams support temporal queries through the `as_of` parameter. When an EventStream is created
|
|
15
|
+
# with a specific timestamp, only events created before or at that timestamp are included, enabling
|
|
16
|
+
# point-in-time state reconstruction.
|
|
17
|
+
#
|
|
18
|
+
# ## Concurrency Control
|
|
19
|
+
#
|
|
20
|
+
# EventStreams use optimistic concurrency control with version numbers. Each event gets an incrementing
|
|
21
|
+
# version number with a unique constraint on `(idx, version)`, preventing race conditions when multiple
|
|
22
|
+
# processes append to the same stream simultaneously.
|
|
23
|
+
#
|
|
24
|
+
# @example Define an event stream with projections
|
|
25
|
+
# class OrderEventStream < Funes::EventStream
|
|
26
|
+
# consistency_projection OrderValidationProjection
|
|
27
|
+
# add_transactional_projection OrderSnapshotProjection
|
|
28
|
+
# add_async_projection OrderReportProjection, queue: :reports
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# @example Append events to a stream
|
|
32
|
+
# stream = OrderEventStream.for("order-123")
|
|
33
|
+
# event = stream.append!(Order::Placed.new(total: 99.99))
|
|
34
|
+
#
|
|
35
|
+
# if event.valid?
|
|
36
|
+
# puts "Event persisted with version #{event.version}"
|
|
37
|
+
# else
|
|
38
|
+
# puts "Event rejected: #{event.errors.full_messages}"
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# @example Temporal query - get stream state as of a specific time
|
|
42
|
+
# stream = OrderEventStream.for("order-123", 1.month.ago)
|
|
43
|
+
# stream.events # => only events up to 1 month ago
|
|
44
|
+
class EventStream
|
|
45
|
+
class << self
|
|
46
|
+
# Register a consistency projection that validates business rules before persisting events.
|
|
47
|
+
#
|
|
48
|
+
# The consistency projection runs before the event is saved. If the resulting state is invalid,
|
|
49
|
+
# the event is rejected and not persisted to the database.
|
|
50
|
+
#
|
|
51
|
+
# @param [Class<Funes::Projection>] projection The projection class that will validate the state.
|
|
52
|
+
# @return [void]
|
|
53
|
+
#
|
|
54
|
+
# @example
|
|
55
|
+
# class InventoryEventStream < Funes::EventStream
|
|
56
|
+
# consistency_projection InventoryValidationProjection
|
|
57
|
+
# end
|
|
58
|
+
def consistency_projection(projection)
|
|
59
|
+
@consistency_projection = projection
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Register a transactional projection that executes synchronously in the same database transaction.
|
|
63
|
+
#
|
|
64
|
+
# Transactional projections run after the event is persisted but within the same database transaction.
|
|
65
|
+
# If a transactional projection fails, the entire transaction (including the event) is rolled back.
|
|
66
|
+
#
|
|
67
|
+
# @param [Class<Funes::Projection>] projection The projection class to execute transactionally.
|
|
68
|
+
# @return [void]
|
|
69
|
+
#
|
|
70
|
+
# @example
|
|
71
|
+
# class OrderEventStream < Funes::EventStream
|
|
72
|
+
# add_transactional_projection OrderSnapshotProjection
|
|
73
|
+
# end
|
|
74
|
+
def add_transactional_projection(projection)
|
|
75
|
+
@transactional_projections ||= []
|
|
76
|
+
@transactional_projections << projection
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Register an async projection that executes in a background job after the event is committed.
|
|
80
|
+
#
|
|
81
|
+
# Async projections are scheduled via ActiveJob after the event transaction commits. You can
|
|
82
|
+
# pass any ActiveJob options (queue, wait, wait_until, priority, etc.) to control job scheduling.
|
|
83
|
+
#
|
|
84
|
+
# The `as_of` parameter controls the timestamp used when the projection job executes:
|
|
85
|
+
# - `:last_event_time` (default) - Uses the creation time of the last event
|
|
86
|
+
# - `:job_time` - Uses Time.current when the job executes
|
|
87
|
+
# - Proc/Lambda - Custom logic that receives the last event and returns a Time object
|
|
88
|
+
#
|
|
89
|
+
# @param [Class<Funes::Projection>] projection The projection class to execute asynchronously.
|
|
90
|
+
# @param [Symbol, Proc] as_of Strategy for determining the as_of timestamp (:last_event_time, :job_time, or Proc).
|
|
91
|
+
# @param [Hash] options ActiveJob options for scheduling (queue, wait, wait_until, priority, etc.).
|
|
92
|
+
# @return [void]
|
|
93
|
+
#
|
|
94
|
+
# @example Schedule with custom queue
|
|
95
|
+
# class OrderEventStream < Funes::EventStream
|
|
96
|
+
# add_async_projection OrderReportProjection, queue: :reports
|
|
97
|
+
# end
|
|
98
|
+
#
|
|
99
|
+
# @example Schedule with delay
|
|
100
|
+
# class OrderEventStream < Funes::EventStream
|
|
101
|
+
# add_async_projection AnalyticsProjection, wait: 5.minutes
|
|
102
|
+
# end
|
|
103
|
+
#
|
|
104
|
+
# @example Use job execution time instead of event time
|
|
105
|
+
# class OrderEventStream < Funes::EventStream
|
|
106
|
+
# add_async_projection RealtimeProjection, as_of: :job_time
|
|
107
|
+
# end
|
|
108
|
+
#
|
|
109
|
+
# @example Custom as_of logic with proc
|
|
110
|
+
# class OrderEventStream < Funes::EventStream
|
|
111
|
+
# add_async_projection EndOfDayProjection, as_of: ->(last_event) { last_event.created_at.beginning_of_day }
|
|
112
|
+
# end
|
|
113
|
+
def add_async_projection(projection, as_of: :last_event_time, **options)
|
|
114
|
+
@async_projections ||= []
|
|
115
|
+
@async_projections << { class: projection, as_of_strategy: as_of, options: options }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Create a new EventStream instance for the given entity identifier.
|
|
119
|
+
#
|
|
120
|
+
# @param [String] idx The entity identifier.
|
|
121
|
+
# @param [Time, nil] as_of Optional timestamp for temporal queries. If provided, only events
|
|
122
|
+
# created before or at this timestamp will be included. Defaults to Time.current.
|
|
123
|
+
# @return [Funes::EventStream] A new EventStream instance.
|
|
124
|
+
#
|
|
125
|
+
# @example Current state
|
|
126
|
+
# stream = OrderEventStream.for("order-123")
|
|
127
|
+
#
|
|
128
|
+
# @example State as of a specific time
|
|
129
|
+
# stream = OrderEventStream.for("order-123", 1.month.ago)
|
|
130
|
+
def for(idx, as_of = nil)
|
|
131
|
+
new(idx, as_of)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @!attribute [r] idx
|
|
136
|
+
# @return [String] The entity identifier for this event stream.
|
|
137
|
+
attr_reader :idx
|
|
138
|
+
|
|
139
|
+
# Append a new event to the stream.
|
|
140
|
+
#
|
|
141
|
+
# This method validates the event, runs the consistency projection (if configured), persists the event
|
|
142
|
+
# with an incremented version number, and triggers transactional and async projections.
|
|
143
|
+
#
|
|
144
|
+
# @param [Funes::Event] new_event The event to append to the stream.
|
|
145
|
+
# @return [Funes::Event] The event object (check `valid?` to see if it was persisted).
|
|
146
|
+
#
|
|
147
|
+
# @example Successful append
|
|
148
|
+
# event = stream.append!(Order::Placed.new(total: 99.99))
|
|
149
|
+
# if event.valid?
|
|
150
|
+
# puts "Event persisted with version #{event.version}"
|
|
151
|
+
# end
|
|
152
|
+
#
|
|
153
|
+
# @example Handling validation failure
|
|
154
|
+
# event = stream.append!(InvalidEvent.new)
|
|
155
|
+
# unless event.valid?
|
|
156
|
+
# puts "Event rejected: #{event.errors.full_messages}"
|
|
157
|
+
# end
|
|
158
|
+
#
|
|
159
|
+
# @example Handling concurrency conflict
|
|
160
|
+
# event = stream.append!(SomeEvent.new)
|
|
161
|
+
# if event.errors[:base].present?
|
|
162
|
+
# # Race condition detected, retry logic here
|
|
163
|
+
# end
|
|
164
|
+
def append!(new_event)
|
|
165
|
+
return new_event unless new_event.valid?
|
|
166
|
+
return new_event if consistency_projection.present? &&
|
|
167
|
+
compute_projection_with_new_event(consistency_projection, new_event).invalid?
|
|
168
|
+
begin
|
|
169
|
+
@instance_new_events << new_event.persist!(@idx, incremented_version)
|
|
170
|
+
rescue ActiveRecord::RecordNotUnique
|
|
171
|
+
new_event.errors.add(:base, I18n.t("funes.events.racing_condition_on_insert"))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
run_transactional_projections
|
|
175
|
+
schedule_async_projections
|
|
176
|
+
|
|
177
|
+
new_event
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @!visibility private
|
|
181
|
+
def initialize(entity_id, as_of = nil)
|
|
182
|
+
@idx = entity_id
|
|
183
|
+
@instance_new_events = []
|
|
184
|
+
@as_of = as_of ? as_of : Time.current
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Get all events in the stream as event instances.
|
|
188
|
+
#
|
|
189
|
+
# Returns both previously persisted events (up to `as_of` timestamp) and any new events
|
|
190
|
+
# appended in this session.
|
|
191
|
+
#
|
|
192
|
+
# @return [Array<Funes::Event>] Array of event instances.
|
|
193
|
+
#
|
|
194
|
+
# @example
|
|
195
|
+
# stream = OrderEventStream.for("order-123")
|
|
196
|
+
# stream.events.each do |event|
|
|
197
|
+
# puts "#{event.class.name} at #{event.created_at}"
|
|
198
|
+
# end
|
|
199
|
+
def events
|
|
200
|
+
(previous_events + @instance_new_events).map(&:to_klass_instance)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
def run_transactional_projections
|
|
205
|
+
transactional_projections.each do |projection_class|
|
|
206
|
+
Funes::PersistProjectionJob.perform_now(@idx, projection_class, last_event_creation_date)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def schedule_async_projections
|
|
211
|
+
async_projections.each do |projection|
|
|
212
|
+
as_of = resolve_as_of_strategy(projection[:as_of_strategy])
|
|
213
|
+
Funes::PersistProjectionJob.set(projection[:options]).perform_later(@idx, projection[:class], as_of)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def previous_events
|
|
218
|
+
@previous_events ||= Funes::EventEntry
|
|
219
|
+
.where(idx: @idx, created_at: ..@as_of)
|
|
220
|
+
.order("created_at")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def last_event_creation_date
|
|
224
|
+
(@instance_new_events.last || previous_events.last).created_at
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def resolve_as_of_strategy(strategy)
|
|
228
|
+
last_event = @instance_new_events.last || previous_events.last
|
|
229
|
+
|
|
230
|
+
case strategy
|
|
231
|
+
when :last_event_time
|
|
232
|
+
last_event.created_at
|
|
233
|
+
when :job_time
|
|
234
|
+
nil # Job will use Time.current
|
|
235
|
+
when Proc
|
|
236
|
+
result = strategy.call(last_event)
|
|
237
|
+
unless result.is_a?(Time)
|
|
238
|
+
raise ArgumentError, "Proc must return a Time object, got #{result.class}. " \
|
|
239
|
+
"Use :job_time symbol for job execution time behavior."
|
|
240
|
+
end
|
|
241
|
+
result
|
|
242
|
+
else
|
|
243
|
+
raise ArgumentError, "Invalid as_of strategy: #{strategy.inspect}. " \
|
|
244
|
+
"Expected :last_event_time, :job_time, or a Proc"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def incremented_version
|
|
249
|
+
(@instance_new_events.last&.version || previous_events.last&.version || 0) + 1
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def compute_projection_with_new_event(projection_class, new_event)
|
|
253
|
+
materialization = projection_class.process_events(events + [ new_event ], @as_of)
|
|
254
|
+
unless materialization.valid?
|
|
255
|
+
new_event.event_errors = new_event.errors
|
|
256
|
+
new_event.adjacent_state_errors = materialization.errors
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
materialization
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def consistency_projection
|
|
263
|
+
self.class.instance_variable_get(:@consistency_projection) || nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def transactional_projections
|
|
267
|
+
self.class.instance_variable_get(:@transactional_projections) || []
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def async_projections
|
|
271
|
+
self.class.instance_variable_get(:@async_projections) || []
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|