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 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,4 @@
1
+ module Funes
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -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
@@ -0,0 +1,4 @@
1
+ module Funes
2
+ module ApplicationHelper
3
+ end
4
+ end