funes-rails 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa0d0b4e8fb74b347944d1cd0796b57e545a2bcd5387afb0d4c1291fd80f70db
4
- data.tar.gz: 4a1ce3b83983e298c27ff627a4510ab04eaf23108a64fc4988b51c683efdd892
3
+ metadata.gz: fa21a99408ee10d9f9b17e06af87f2f51d099fd593fadec8672c71b15110a53e
4
+ data.tar.gz: 893a107d52489ffc4e7680f558dbe8dfce0c8bdf88ad05bd4a704efd0eeb97ce
5
5
  SHA512:
6
- metadata.gz: 14aa532ec93bd4513e462394ba9b88043e99cacb1f903020bec50f4f9678aa9ee405640fee98e8470cc734260007b31e659608bc411843bc112f9b383480fd14
7
- data.tar.gz: 97c2655d30a097463aa8549faef89763421c629bbb2a67d9c456c2a1a9d551c8ab011e9207f571419412e04659816816f11fc010c03cc90d6534f9b2082dab70
6
+ metadata.gz: eddd2d0d5c46fe75338a095098a5391eb3aa81b29196f4fd352458ba6e6efc75673959ac59b1b40c555ec1a2f8914524503f718017845f636b030577578dc3f6
7
+ data.tar.gz: c57f6bb79093b02c19abc8c5246bb741c4cf23d16c24dc2e1857e7941457e02ddd4a2e83441b767302bf6c4322bb88a087703bf2480031ac0bc7aa5e86470607
data/README.md CHANGED
@@ -90,25 +90,6 @@ Funes gives you fine-grained control over when and how projections run:
90
90
  * **Validation before persistence:** before upserting the materialization, Funes runs ActiveRecord validations on the materialization model. If the model is invalid, an `ActiveRecord::RecordInvalid` exception is raised, the transaction rolls back, and the event is not persisted.
91
91
  * **Fail-loud on errors:** if a projection fails with a database error (e.g., constraint violation) or a validation error, the transaction rolls back, the event is marked as not persisted (`persisted?` returns `false`), and the exception (`ActiveRecord::StatementInvalid` or `ActiveRecord::RecordInvalid`) propagates. This ensures bugs are immediately visible rather than silently hidden, while keeping the event in a consistent state for any rescue logic in your application.
92
92
 
93
- ### Host-managed transactions with `append!`
94
-
95
- When you need to wrap `append` inside your own `ActiveRecord::Base.transaction` — for example, to keep a sibling update in lockstep with the event, or to append to two streams atomically — use `append!`. It mirrors Rails' `save` / `save!` pair: on any failure it raises `ActiveRecord::RecordInvalid`, which rolls back the enclosing transaction.
96
-
97
- ```ruby
98
- event = Order::Placed.new(total: 99.99)
99
- begin
100
- ActiveRecord::Base.transaction do
101
- customer.update!(last_ordered_at: Time.current)
102
- OrderEventStream.for(order_id).append!(event)
103
- end
104
- rescue ActiveRecord::RecordInvalid
105
- event.persisted? # => false
106
- event.errors.any? # => true
107
- end
108
- ```
109
-
110
- The failed event stays queryable after the rescue (`persisted?`, `errors`), just like a record that failed `save!`. Async projections are only enqueued once the outer transaction commits, so a rolled-back transaction never schedules a job.
111
-
112
93
  ### Async projections
113
94
 
114
95
  * **Background processing:** these are offloaded to `ActiveJob`, ensuring that heavy computations don't slow down the write path.
@@ -122,12 +103,22 @@ The failed event stays queryable after the rescue (`persisted?`, `errors`), just
122
103
 
123
104
  Guides and full API documentation are available at [docs.funes.org](https://docs.funes.org).
124
105
 
106
+ ## Performance
107
+
108
+ Precise benchmarks are notoriously hard to pin down: workloads vary, and absolute numbers depend heavily on hardware, configuration, and the shape of the data. So treat the figures we publish as directional rather than definitive.
109
+
110
+ That said, our measurements consistently show that the complexity of event stream operations stays **sub-linear, comfortably within `O(n)`** as the log grows — which is an important property for medium/long-lived streams.
111
+
112
+ You can browse the latest measurements and join the conversation in the [Performance Measurements](https://github.com/funes-org/funes/discussions/categories/performance-measurements) discussions.
113
+
125
114
  ## Compatibility
126
115
 
127
- - **Ruby:** 3.1+
128
- - **Rails:** 7.2+
116
+ Funes supports the following runtimes:
117
+
118
+ - **Ruby** 3.1 or newer
119
+ - **Rails** 7.2 or newer
129
120
 
130
- Rails 8.0+ requires Ruby 3.2 or higher.
121
+ If you're on Rails 8.0 or above, you'll need Ruby 3.2 or newer — that's a Rails 8 requirement, not a Funes one.
131
122
 
132
123
  ## License
133
124
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -1,5 +1,6 @@
1
1
  require "funes/unknown_event"
2
2
  require "funes/unknown_materialization_model"
3
+ require "funes/invalid_materialization_state"
3
4
 
4
5
  module Funes
5
6
  # Projections perform the necessary pattern-matching to compute and aggregate the interpretations that the system
@@ -122,6 +123,31 @@ module Funes
122
123
  @materialization_model = active_record_or_model
123
124
  end
124
125
 
126
+ # Registers an instance method on the materialization model that owns the persistence step,
127
+ # replacing the framework's default upsert.
128
+ #
129
+ # When set, the named method is invoked on the in-memory state after all interpretations have
130
+ # run and +idx+ has been assigned. The method takes no arguments and is expected to raise on
131
+ # failure. The framework still calls +state.valid?+ and raises
132
+ # +Funes::InvalidMaterializationState+ before delegating, so the custom method only runs
133
+ # against valid state.
134
+ #
135
+ # Declaring +persist_materialization_model_with+ also lifts the requirement that the
136
+ # materialization model be an +ActiveRecord::Base+ subclass: a plain +ActiveModel+ class is
137
+ # enough as long as it exposes +assign_attributes+, +attributes+, +valid?+ and +errors+.
138
+ #
139
+ # @param [Symbol] method_name The instance method on the materialization model that performs the write.
140
+ # @return [void]
141
+ #
142
+ # @example Persisting a projection to a JSON file
143
+ # class YourProjection < Funes::Projection
144
+ # materialization_model YourMaterializationModel
145
+ # persist_materialization_model_with :save_to_object_storage!
146
+ # end
147
+ def persist_materialization_model_with(method_name)
148
+ @persist_method = method_name
149
+ end
150
+
125
151
  # It changes the sensibility of the projection about events that it doesn't know how to interpret
126
152
  #
127
153
  # By default, a projection ignores events that it doesn't have interpretations for. This method informs the
@@ -142,7 +168,8 @@ module Funes
142
168
  def process_events(events_collection, at: nil, consistency: false)
143
169
  new(self.instance_variable_get(:@interpretations),
144
170
  self.instance_variable_get(:@materialization_model),
145
- self.instance_variable_get(:@throws_on_unknown_events))
171
+ self.instance_variable_get(:@throws_on_unknown_events),
172
+ self.instance_variable_get(:@persist_method))
146
173
  .process_events(events_collection, at: at, consistency: consistency)
147
174
  end
148
175
 
@@ -150,13 +177,14 @@ module Funes
150
177
  def materialize!(events_collection, idx, at: nil)
151
178
  new(self.instance_variable_get(:@interpretations),
152
179
  self.instance_variable_get(:@materialization_model),
153
- self.instance_variable_get(:@throws_on_unknown_events))
180
+ self.instance_variable_get(:@throws_on_unknown_events),
181
+ self.instance_variable_get(:@persist_method))
154
182
  .materialize!(events_collection, idx, at: at)
155
183
  end
156
184
  end
157
185
 
158
186
  # @!visibility private
159
- def initialize(interpretations, materialization_model, throws_on_unknown_events)
187
+ def initialize(interpretations, materialization_model, throws_on_unknown_events, persist_method = nil)
160
188
  @interpretations = interpretations
161
189
  @materialization_model = materialization_model
162
190
  raise Funes::UnknownMaterializationModel,
@@ -164,6 +192,7 @@ module Funes
164
192
 
165
193
 
166
194
  @throws_on_unknown_events = throws_on_unknown_events
195
+ @persist_method = persist_method
167
196
  end
168
197
 
169
198
  # @!visibility private
@@ -191,13 +220,13 @@ module Funes
191
220
  # @!visibility private
192
221
  def materialize!(events_collection, idx, at: nil)
193
222
  state = process_events(events_collection, at: at)
194
- materialized_model_instance = materialized_model_instance_based_on(state)
195
- if materialization_model_is_persistable?
223
+ if persistable?
196
224
  state.assign_attributes(idx:)
197
225
  persist_based_on!(state)
226
+ return state if @persist_method
198
227
  end
199
228
 
200
- materialized_model_instance
229
+ materialized_model_instance_based_on(state)
201
230
  end
202
231
 
203
232
  private
@@ -213,13 +242,23 @@ module Funes
213
242
  @materialization_model.new(state.attributes)
214
243
  end
215
244
 
216
- def materialization_model_is_persistable?
217
- @materialization_model.present? && @materialization_model <= ActiveRecord::Base
245
+ def persistable?
246
+ return false unless @materialization_model.present?
247
+ return true if @persist_method
248
+ @materialization_model <= ActiveRecord::Base
218
249
  end
219
250
 
220
251
  def persist_based_on!(state)
221
- raise ActiveRecord::RecordInvalid.new(state) unless state.valid?
222
- @materialization_model.upsert(state.attributes, unique_by: :idx)
252
+ unless state.valid?
253
+ raise Funes::InvalidMaterializationState.new(state) if @persist_method
254
+ raise ActiveRecord::RecordInvalid.new(state)
255
+ end
256
+
257
+ if @persist_method
258
+ state.public_send(@persist_method)
259
+ else
260
+ @materialization_model.upsert(state.attributes, unique_by: :idx)
261
+ end
223
262
  end
224
263
 
225
264
  def warn_about_ineffective_errors(event)
@@ -0,0 +1,31 @@
1
+ module Funes
2
+ # Raised when a projection configured with `persist_materialization_model_with` produces an
3
+ # invalid materialization state.
4
+ #
5
+ # When a projection declares a custom persistence method via `persist_materialization_model_with`,
6
+ # Funes still validates the materialization (via `state.valid?`) before delegating the write.
7
+ # If validation fails, this exception is raised and the custom persist method is *not* invoked.
8
+ #
9
+ # The failed materialization is exposed via `#record`, mirroring the ergonomics of
10
+ # `ActiveRecord::RecordInvalid` — but without coupling the custom-persist path to ActiveRecord.
11
+ # Like any unrescued exception, it propagates out of `materialize!` and rolls back any enclosing
12
+ # `ActiveRecord::Base.transaction`, which is the desired behaviour for transactional projections.
13
+ #
14
+ # @example Handling an invalid materialization state
15
+ # begin
16
+ # YourProjection.materialize!(events, "some-id", at: Time.current)
17
+ # rescue Funes::InvalidMaterializationState => e
18
+ # Rails.logger.error "Invalid materialization: #{e.message}"
19
+ # e.record.errors.full_messages # the validation errors on the materialization
20
+ # end
21
+ #
22
+ # @see Funes::Projection.persist_materialization_model_with
23
+ class InvalidMaterializationState < StandardError
24
+ attr_reader :record
25
+
26
+ def initialize(record)
27
+ @record = record
28
+ super("Invalid materialization state: #{record.errors.full_messages.to_sentence}")
29
+ end
30
+ end
31
+ end
data/lib/funes/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Funes
2
- VERSION = "0.2.2"
2
+ VERSION = "0.2.3"
3
3
  end
data/lib/funes.rb CHANGED
@@ -5,6 +5,7 @@ require "funes/event_metainformation_builder"
5
5
  require "funes/invalid_event_metainformation"
6
6
  require "funes/conflicting_actual_time_error"
7
7
  require "funes/missing_actual_time_attribute_error"
8
+ require "funes/invalid_materialization_state"
8
9
  require "funes/engine"
9
10
 
10
11
  # Funes is an event sourcing framework for Ruby on Rails.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: funes-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vinícius Almeida da Silva
@@ -216,6 +216,7 @@ files:
216
216
  - lib/funes/event_metainformation_builder.rb
217
217
  - lib/funes/inspection.rb
218
218
  - lib/funes/invalid_event_metainformation.rb
219
+ - lib/funes/invalid_materialization_state.rb
219
220
  - lib/funes/missing_actual_time_attribute_error.rb
220
221
  - lib/funes/unknown_event.rb
221
222
  - lib/funes/unknown_materialization_model.rb