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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Funes
|
|
2
|
+
# Test helper for testing projections in isolation.
|
|
3
|
+
#
|
|
4
|
+
# Include this module in your test classes to access helper methods that allow you to test
|
|
5
|
+
# individual projection interpretations without needing to process entire event streams.
|
|
6
|
+
#
|
|
7
|
+
# @example Include in your test
|
|
8
|
+
# class InventorySnapshotProjectionTest < ActiveSupport::TestCase
|
|
9
|
+
# include Funes::ProjectionTestHelper
|
|
10
|
+
#
|
|
11
|
+
# test "receiving items increases quantity on hand" do
|
|
12
|
+
# initial_state = InventorySnapshot.new(quantity_on_hand: 10)
|
|
13
|
+
# event = Inventory::ItemReceived.new(quantity: 5, unit_cost: 9.99)
|
|
14
|
+
#
|
|
15
|
+
# result = interpret_event_based_on(InventorySnapshotProjection, event, initial_state)
|
|
16
|
+
#
|
|
17
|
+
# assert_equal 15, result.quantity_on_hand
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
module ProjectionTestHelper
|
|
21
|
+
# Test a single event interpretation in isolation.
|
|
22
|
+
#
|
|
23
|
+
# This method extracts and executes a single interpretation block from a projection,
|
|
24
|
+
# allowing you to test how specific events transform state without processing entire
|
|
25
|
+
# event streams.
|
|
26
|
+
#
|
|
27
|
+
# @param [Class<Funes::Projection>] projection_class The projection class being tested.
|
|
28
|
+
# @param [Funes::Event] event_instance The event to interpret.
|
|
29
|
+
# @param [ActiveModel::Model, ActiveRecord::Base] previous_state The state before applying the event.
|
|
30
|
+
# @param [Time, nil] as_of Optional timestamp for temporal logic. Defaults to Time.current.
|
|
31
|
+
# @return [ActiveModel::Model, ActiveRecord::Base] The new state after applying the interpretation.
|
|
32
|
+
#
|
|
33
|
+
# @example Test a single interpretation
|
|
34
|
+
# initial_state = OrderSnapshot.new(total: 100)
|
|
35
|
+
# event = Order::ItemAdded.new(amount: 50)
|
|
36
|
+
#
|
|
37
|
+
# result = interpret_event_based_on(OrderSnapshotProjection, event, initial_state)
|
|
38
|
+
#
|
|
39
|
+
# assert_equal 150, result.total
|
|
40
|
+
#
|
|
41
|
+
# @example Test with validations
|
|
42
|
+
# state = InventorySnapshot.new(quantity_on_hand: 5)
|
|
43
|
+
# event = Inventory::ItemShipped.new(quantity: 10)
|
|
44
|
+
#
|
|
45
|
+
# result = interpret_event_based_on(InventorySnapshotProjection, event, state)
|
|
46
|
+
#
|
|
47
|
+
# assert_equal -5, result.quantity_on_hand
|
|
48
|
+
# refute result.valid?
|
|
49
|
+
def interpret_event_based_on(projection_class, event_instance, previous_state, as_of = Time.current)
|
|
50
|
+
projection_class.instance_variable_get(:@interpretations)[event_instance.class]
|
|
51
|
+
.call(previous_state, event_instance, as_of)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
module Funes
|
|
2
|
+
class PersistProjectionJob < ApplicationJob
|
|
3
|
+
def perform(event_stream_idx, projection_class, as_of = nil)
|
|
4
|
+
event_stream = EventStream.for(event_stream_idx, as_of)
|
|
5
|
+
projection_class.materialize!(event_stream.events, event_stream.idx, as_of)
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
module Funes
|
|
2
|
+
# Base class for all events in the Funes event sourcing framework.
|
|
3
|
+
#
|
|
4
|
+
# Events are immutable facts that represent something that happened in the system. They use
|
|
5
|
+
# ActiveModel for attributes and validations, making them familiar to Rails developers.
|
|
6
|
+
#
|
|
7
|
+
# ## Event Validation
|
|
8
|
+
#
|
|
9
|
+
# Events support two types of validation:
|
|
10
|
+
#
|
|
11
|
+
# - **Own validation:** Standard ActiveModel validations defined on the event class itself.
|
|
12
|
+
# - **Adjacent state validation:** Validation errors from consistency projections that check
|
|
13
|
+
# if the event would lead to an invalid state.
|
|
14
|
+
#
|
|
15
|
+
# The `valid?` method returns `true` only if both validations pass. The `errors` method
|
|
16
|
+
# merges both types of errors for display.
|
|
17
|
+
#
|
|
18
|
+
# ## Defining Events
|
|
19
|
+
#
|
|
20
|
+
# Events inherit from `Funes::Event` and define attributes using ActiveModel::Attributes:
|
|
21
|
+
#
|
|
22
|
+
# @example Define a simple event
|
|
23
|
+
# class Order::Placed < Funes::Event
|
|
24
|
+
# attribute :total, :decimal
|
|
25
|
+
# attribute :customer_id, :string
|
|
26
|
+
# attribute :at, :datetime, default: -> { Time.current }
|
|
27
|
+
#
|
|
28
|
+
# validates :total, presence: true, numericality: { greater_than: 0 }
|
|
29
|
+
# validates :customer_id, presence: true
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @example Using the event
|
|
33
|
+
# event = Order::Placed.new(total: 99.99, customer_id: "cust-123")
|
|
34
|
+
# stream.append!(event)
|
|
35
|
+
#
|
|
36
|
+
# @example Handling validation errors
|
|
37
|
+
# event = stream.append!(Order::Placed.new(total: -10))
|
|
38
|
+
# unless event.valid?
|
|
39
|
+
# puts event.own_errors.full_messages # => Event's own validation errors
|
|
40
|
+
# puts event.state_errors.full_messages # => Consistency projection errors
|
|
41
|
+
# puts event.errors.full_messages # => All errors merged
|
|
42
|
+
# end
|
|
43
|
+
class Event
|
|
44
|
+
include ActiveModel::Model
|
|
45
|
+
include ActiveModel::Attributes
|
|
46
|
+
|
|
47
|
+
# @!attribute [rw] adjacent_state_errors
|
|
48
|
+
# @return [ActiveModel::Errors] Validation errors from consistency projections.
|
|
49
|
+
attr_accessor :adjacent_state_errors
|
|
50
|
+
|
|
51
|
+
# @!attribute [rw] event_errors
|
|
52
|
+
# @return [ActiveModel::Errors, nil] The event's own validation errors (internal use).
|
|
53
|
+
attr_accessor :event_errors
|
|
54
|
+
|
|
55
|
+
# @!visibility private
|
|
56
|
+
def initialize(*args, **kwargs)
|
|
57
|
+
super(*args, **kwargs)
|
|
58
|
+
@adjacent_state_errors = ActiveModel::Errors.new(nil)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @!visibility private
|
|
62
|
+
def persist!(idx, version)
|
|
63
|
+
Funes::EventEntry.create!(klass: self.class.name, idx:, version:, props: attributes)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Custom string representation of the event.
|
|
67
|
+
#
|
|
68
|
+
# @return [String] A string showing the event class name and attributes.
|
|
69
|
+
#
|
|
70
|
+
# @example
|
|
71
|
+
# event = Order::Placed.new(total: 99.99)
|
|
72
|
+
# event.inspect # => "<Order::Placed: {:total=>99.99}>"
|
|
73
|
+
def inspect
|
|
74
|
+
"<#{self.class.name}: #{attributes}>"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Check if the event is valid.
|
|
78
|
+
#
|
|
79
|
+
# An event is valid only if both its own validations pass AND it doesn't lead to an
|
|
80
|
+
# invalid state (no adjacent_state_errors from consistency projections).
|
|
81
|
+
#
|
|
82
|
+
# @return [Boolean] `true` if the event is valid, `false` otherwise.
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# event = Order::Placed.new(total: 99.99, customer_id: "cust-123")
|
|
86
|
+
# event.valid? # => true or false
|
|
87
|
+
def valid?
|
|
88
|
+
super && (adjacent_state_errors.nil? || adjacent_state_errors.empty?)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get validation errors from consistency projections.
|
|
92
|
+
#
|
|
93
|
+
# These are errors that indicate the event would lead to an invalid state, even if
|
|
94
|
+
# the event itself is valid.
|
|
95
|
+
#
|
|
96
|
+
# @return [ActiveModel::Errors] Errors from consistency projection validation.
|
|
97
|
+
#
|
|
98
|
+
# @example
|
|
99
|
+
# event = stream.append!(Inventory::ItemShipped.new(quantity: 9999))
|
|
100
|
+
# event.state_errors.full_messages # => ["Quantity on hand must be >= 0"]
|
|
101
|
+
def state_errors
|
|
102
|
+
adjacent_state_errors
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Get the event's own validation errors (excluding state errors).
|
|
106
|
+
#
|
|
107
|
+
# @return [ActiveModel::Errors] Only the event's own validation errors.
|
|
108
|
+
#
|
|
109
|
+
# @example
|
|
110
|
+
# event = Order::Placed.new(total: -10)
|
|
111
|
+
# event.own_errors.full_messages # => ["Total must be greater than 0"]
|
|
112
|
+
def own_errors
|
|
113
|
+
event_errors || errors
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get all validation errors (both event and state errors merged).
|
|
117
|
+
#
|
|
118
|
+
# This method merges the event's own validation errors with any errors from consistency
|
|
119
|
+
# projections, prefixing state errors with a localized message.
|
|
120
|
+
#
|
|
121
|
+
# @return [ActiveModel::Errors] All validation errors combined.
|
|
122
|
+
#
|
|
123
|
+
# @example
|
|
124
|
+
# event.errors.full_messages
|
|
125
|
+
# # => ["Total must be greater than 0", "Led to invalid state: Quantity on hand must be >= 0"]
|
|
126
|
+
def errors
|
|
127
|
+
return super if @event_errors.nil?
|
|
128
|
+
|
|
129
|
+
tmp_errors = ActiveModel::Errors.new(nil)
|
|
130
|
+
tmp_errors.merge!(event_errors)
|
|
131
|
+
adjacent_state_errors.each do |error|
|
|
132
|
+
tmp_errors.add(:base, "#{I18n.t("funes.events.led_to_invalid_state_prefix")}: #{error.full_message}")
|
|
133
|
+
end
|
|
134
|
+
tmp_errors
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
module Funes
|
|
2
|
+
# Projections perform the necessary pattern-matching to compute and aggregate the interpretations that the system
|
|
3
|
+
# gives to a particular collection of events. A projection consists of a series of interpretations defined by the
|
|
4
|
+
# programmer and an aggregated representation of these interpretations (therefore, a representation of transient
|
|
5
|
+
# and final states) which is referenced as the *materialized representation*.
|
|
6
|
+
#
|
|
7
|
+
# A projection can be *virtual* or *persistent*. In practical terms, what defines this characteristic is the type
|
|
8
|
+
# of *materialized representation* configured in the projection.
|
|
9
|
+
#
|
|
10
|
+
# - **Virtual projection:** has an ActiveModel instance as its materialized representation. An instance like this
|
|
11
|
+
# is not persistent, but can be calculated at runtime and has characteristics like validation that are quite
|
|
12
|
+
# valuable in the context of a projection.
|
|
13
|
+
#
|
|
14
|
+
# - **Persistent projection:** has an ActiveRecord instance as its materialized representation. A persistent
|
|
15
|
+
# projection has the same validation capabilities but can be persisted and serve the search patterns needed for
|
|
16
|
+
# the application (read model or even [eager read derivation](https://martinfowler.com/bliki/EagerReadDerivation.html)).
|
|
17
|
+
#
|
|
18
|
+
# ## Temporal Queries with `as_of`
|
|
19
|
+
#
|
|
20
|
+
# The `as_of` parameter enables temporal queries by allowing projections to be computed as they would have been
|
|
21
|
+
# at a specific point in time. When processing events, only events created before or at the `as_of` timestamp
|
|
22
|
+
# are included, enabling point-in-time snapshots of the system state.
|
|
23
|
+
#
|
|
24
|
+
# All interpretation blocks (`initial_state`, `interpretation_for`, and `final_state`) receive the `as_of` parameter,
|
|
25
|
+
# allowing custom temporal logic within projections.
|
|
26
|
+
class Projection
|
|
27
|
+
class << self
|
|
28
|
+
# Registers an interpretation block for a given event type.
|
|
29
|
+
#
|
|
30
|
+
# @param [Class<Funes::Event>] event_type The event class constant that will be interpreted.
|
|
31
|
+
# @yield [state, event, as_of] Block invoked with the current state, the event and the as_of marker. It should return a new version of the transient state
|
|
32
|
+
# @yieldparam [ActiveModel::Model, ActiveRecord::Base] transient_state The current transient state
|
|
33
|
+
# @yieldparam [Funes::Event] event Event instance.
|
|
34
|
+
# @yieldparam [Time] as_of Context or timestamp used when interpreting.
|
|
35
|
+
# @yieldreturn [ActiveModel::Model, ActiveRecord::Base] the new transient state
|
|
36
|
+
# @return [void]
|
|
37
|
+
#
|
|
38
|
+
# @example
|
|
39
|
+
# class YourProjection < Funes::Projection
|
|
40
|
+
# interpretation_for Order::Placed do |transient_state, current_event, _as_of|
|
|
41
|
+
# transient_state.assign_attributes(total: (transient_state.total || 0) + current_event.amount)
|
|
42
|
+
# transient_state
|
|
43
|
+
# end
|
|
44
|
+
# end
|
|
45
|
+
def interpretation_for(event_type, &block)
|
|
46
|
+
@interpretations ||= {}
|
|
47
|
+
@interpretations[event_type] = block
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Registers the initial transient state that will be used for the current projection's interpretations.
|
|
51
|
+
#
|
|
52
|
+
# *Default behavior:* When no block is provided the initial state defaults to a new instance of
|
|
53
|
+
# the configured materialization model.
|
|
54
|
+
#
|
|
55
|
+
# @yield [materialization_model, as_of] Block invoked to produce the initial state.
|
|
56
|
+
# @yieldparam [Class<ActiveRecord::Base>, Class<ActiveModel::Model>] materialization_model The materialization model constant.
|
|
57
|
+
# @yieldparam [Time] as_of Context or timestamp used when interpreting.
|
|
58
|
+
# @yieldreturn [ActiveModel::Model, ActiveRecord::Base] the new transient state
|
|
59
|
+
# @return [void]
|
|
60
|
+
#
|
|
61
|
+
# @example
|
|
62
|
+
# class YourProjection < Funes::Projection
|
|
63
|
+
# initial_state do |materialization_model, _as_of|
|
|
64
|
+
# materialization_model.new(some: :specific, value: 42)
|
|
65
|
+
# end
|
|
66
|
+
# end
|
|
67
|
+
def initial_state(&block)
|
|
68
|
+
@interpretations ||= {}
|
|
69
|
+
@interpretations[:init] = block
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Register a final interpretation of the state.
|
|
73
|
+
#
|
|
74
|
+
# *Default behavior:* when this is not defined the projection does nothing after the interpretations
|
|
75
|
+
#
|
|
76
|
+
# @yield [transient_state, as_of] Block invoked to produce the final state.
|
|
77
|
+
# @yieldparam [ActiveModel::Model, ActiveRecord::Base] transient_state The current transient state after all interpretations.
|
|
78
|
+
# @yieldparam [Time] as_of Context or timestamp used when interpreting.
|
|
79
|
+
# @yieldreturn [ActiveModel::Model, ActiveRecord::Base] the final transient state instance
|
|
80
|
+
# @return [void]
|
|
81
|
+
#
|
|
82
|
+
# @example
|
|
83
|
+
# class YourProjection < Funes::Projection
|
|
84
|
+
# final_state do |transient_state, as_of|
|
|
85
|
+
# # TODO...
|
|
86
|
+
# end
|
|
87
|
+
# end
|
|
88
|
+
def final_state(&block)
|
|
89
|
+
@interpretations ||= {}
|
|
90
|
+
@interpretations[:final] = block
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Register the projection's materialization model
|
|
94
|
+
#
|
|
95
|
+
# @param [Class<ActiveRecord::Base>, Class<ActiveModel::Model>] active_record_or_model The materialization model class.
|
|
96
|
+
# @return [void]
|
|
97
|
+
#
|
|
98
|
+
# @example Virtual projection (non-persistent, ActiveModel)
|
|
99
|
+
# class YourProjection < Funes::Projection
|
|
100
|
+
# materialization_model YourActiveModelClass
|
|
101
|
+
# end
|
|
102
|
+
#
|
|
103
|
+
# @example Persistent projection (persisted read model, ActiveRecord)
|
|
104
|
+
# class YourProjection < Funes::Projection
|
|
105
|
+
# materialization_model YourActiveRecordClass
|
|
106
|
+
# end
|
|
107
|
+
def materialization_model(active_record_or_model)
|
|
108
|
+
@materialization_model = active_record_or_model
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# It changes the sensibility of the projection about events that it doesn't know how to interpret
|
|
112
|
+
#
|
|
113
|
+
# By default, a projection ignores events that it doesn't have interpretations for. This method informs the
|
|
114
|
+
# projection that in cases like that an Funes::UnknownEvent should be raised and the DB transaction should be
|
|
115
|
+
# rolled back.
|
|
116
|
+
#
|
|
117
|
+
# @return [void]
|
|
118
|
+
#
|
|
119
|
+
# @example
|
|
120
|
+
# class YourProjection < Funes::Projection
|
|
121
|
+
# raise_on_unknown_events
|
|
122
|
+
# end
|
|
123
|
+
def raise_on_unknown_events
|
|
124
|
+
@throws_on_unknown_events = true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @!visibility private
|
|
128
|
+
def process_events(events_collection, as_of)
|
|
129
|
+
new(self.instance_variable_get(:@interpretations),
|
|
130
|
+
self.instance_variable_get(:@materialization_model),
|
|
131
|
+
self.instance_variable_get(:@throws_on_unknown_events))
|
|
132
|
+
.process_events(events_collection, as_of)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @!visibility private
|
|
136
|
+
def materialize!(events_collection, idx, as_of)
|
|
137
|
+
new(self.instance_variable_get(:@interpretations),
|
|
138
|
+
self.instance_variable_get(:@materialization_model),
|
|
139
|
+
self.instance_variable_get(:@throws_on_unknown_events))
|
|
140
|
+
.materialize!(events_collection, idx, as_of)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @!visibility private
|
|
145
|
+
def initialize(interpretations, materialization_model, throws_on_unknown_events)
|
|
146
|
+
@interpretations = interpretations
|
|
147
|
+
@materialization_model = materialization_model
|
|
148
|
+
@throws_on_unknown_events = throws_on_unknown_events
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @!visibility private
|
|
152
|
+
def process_events(events_collection, as_of)
|
|
153
|
+
initial_state = interpretations[:init].present? ? interpretations[:init].call(@materialization_model, as_of) : @materialization_model.new
|
|
154
|
+
state = events_collection.inject(initial_state) do |previous_state, event|
|
|
155
|
+
fn = @interpretations[event.class]
|
|
156
|
+
if fn.nil? && throws_on_unknown_events?
|
|
157
|
+
raise Funes::UnknownEvent, "Events of the type #{event.class} are not processable"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
fn.nil? ? previous_state : fn.call(previous_state, event, as_of)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
state = interpretations[:final].call(state, as_of) if interpretations[:final].present?
|
|
164
|
+
state
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# @!visibility private
|
|
168
|
+
def materialize!(events_collection, idx, as_of)
|
|
169
|
+
raise Funes::UnknownMaterializationModel,
|
|
170
|
+
"There is no materialization model configured on #{self.class.name}" unless @materialization_model.present?
|
|
171
|
+
|
|
172
|
+
state = process_events(events_collection, as_of)
|
|
173
|
+
materialized_model_instance = materialized_model_instance_based_on(state)
|
|
174
|
+
if materialization_model_is_persistable?
|
|
175
|
+
state.assign_attributes(idx:)
|
|
176
|
+
persist_based_on!(state)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
materialized_model_instance
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
def throws_on_unknown_events?
|
|
184
|
+
@throws_on_unknown_events || false
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def interpretations
|
|
188
|
+
@interpretations || {}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def materialized_model_instance_based_on(state)
|
|
192
|
+
@materialization_model.new(state.attributes)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def materialization_model_is_persistable?
|
|
196
|
+
@materialization_model.present? && @materialization_model <= ActiveRecord::Base
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def persist_based_on!(state)
|
|
200
|
+
@materialization_model.upsert(state.attributes, unique_by: :idx)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Funes</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<%= stylesheet_link_tag "funes/application", media: "all" %>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
|
|
14
|
+
<%= yield %>
|
|
15
|
+
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
data/config/routes.rb
ADDED
data/lib/funes/engine.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Funes
|
|
2
|
+
# Raised when a projection encounters an event type it doesn't know how to interpret.
|
|
3
|
+
#
|
|
4
|
+
# By default, projections silently ignore unknown events. This error is only raised when
|
|
5
|
+
# a projection is configured with `raise_on_unknown_events`, which enforces strict event
|
|
6
|
+
# handling to catch missing interpretation blocks during development.
|
|
7
|
+
#
|
|
8
|
+
# @example Configure a projection to raise on unknown events
|
|
9
|
+
# class StrictProjection < Funes::Projection
|
|
10
|
+
# raise_on_unknown_events
|
|
11
|
+
#
|
|
12
|
+
# interpretation_for OrderPlaced do |state, event, as_of|
|
|
13
|
+
# # ...
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example Handling unknown events
|
|
18
|
+
# begin
|
|
19
|
+
# projection.process_events(events, Time.current)
|
|
20
|
+
# rescue Funes::UnknownEvent => e
|
|
21
|
+
# Rails.logger.error "Missing interpretation: #{e.message}"
|
|
22
|
+
# end
|
|
23
|
+
class UnknownEvent < StandardError; end
|
|
24
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Funes
|
|
2
|
+
# Raised when attempting to materialize a projection without a configured materialization model.
|
|
3
|
+
#
|
|
4
|
+
# Projections require a materialization model (ActiveModel or ActiveRecord class) to be
|
|
5
|
+
# configured via `materialization_model` before they can be materialized or persisted.
|
|
6
|
+
# This error occurs when calling `materialize!` on a projection that hasn't been properly
|
|
7
|
+
# configured.
|
|
8
|
+
#
|
|
9
|
+
# @example Projection without materialization model (raises error)
|
|
10
|
+
# class IncompleteProjection < Funes::Projection
|
|
11
|
+
# interpretation_for OrderPlaced do |state, event, as_of|
|
|
12
|
+
# # ...
|
|
13
|
+
# end
|
|
14
|
+
# # Missing: materialization_model SomeModel
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# # This will raise Funes::UnknownMaterializationModel
|
|
18
|
+
# IncompleteProjection.materialize!(events, "entity-123", Time.current)
|
|
19
|
+
#
|
|
20
|
+
# @example Properly configured projection
|
|
21
|
+
# class OrderProjection < Funes::Projection
|
|
22
|
+
# materialization_model OrderSnapshot
|
|
23
|
+
#
|
|
24
|
+
# interpretation_for OrderPlaced do |state, event, as_of|
|
|
25
|
+
# # ...
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
class UnknownMaterializationModel < StandardError; end
|
|
29
|
+
end
|
data/lib/funes.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "funes/version"
|
|
2
|
+
require "funes/engine"
|
|
3
|
+
|
|
4
|
+
# Funes is an event sourcing framework for Ruby on Rails.
|
|
5
|
+
#
|
|
6
|
+
# Instead of updating state in place, Funes stores immutable events as the source of truth,
|
|
7
|
+
# then derives current state by replaying them through projections. This provides complete
|
|
8
|
+
# audit trails, temporal queries, and the ability to create multiple read models from the
|
|
9
|
+
# same event stream.
|
|
10
|
+
#
|
|
11
|
+
# ## Core Concepts
|
|
12
|
+
#
|
|
13
|
+
# - **Events** ({Funes::Event}): Immutable facts representing something that happened
|
|
14
|
+
# - **Event Streams** ({Funes::EventStream}): Append-only sequences of events for a specific entity
|
|
15
|
+
# - **Projections** ({Funes::Projection}): Transform events into read models (state)
|
|
16
|
+
#
|
|
17
|
+
# ## Getting Started
|
|
18
|
+
#
|
|
19
|
+
# 1. Install the gem and run migrations:
|
|
20
|
+
# ```bash
|
|
21
|
+
# $ bin/rails generate funes:install
|
|
22
|
+
# $ bin/rails db:migrate
|
|
23
|
+
# ```
|
|
24
|
+
#
|
|
25
|
+
# 2. Define your events:
|
|
26
|
+
# ```ruby
|
|
27
|
+
# class Order::Placed < Funes::Event
|
|
28
|
+
# attribute :total, :decimal
|
|
29
|
+
# attribute :customer_id, :string
|
|
30
|
+
# validates :total, presence: true
|
|
31
|
+
# end
|
|
32
|
+
# ```
|
|
33
|
+
#
|
|
34
|
+
# 3. Define a projection:
|
|
35
|
+
# ```ruby
|
|
36
|
+
# class OrderProjection < Funes::Projection
|
|
37
|
+
# materialization_model OrderSnapshot
|
|
38
|
+
#
|
|
39
|
+
# interpretation_for Order::Placed do |state, event, as_of|
|
|
40
|
+
# state.assign_attributes(total: event.total)
|
|
41
|
+
# state
|
|
42
|
+
# end
|
|
43
|
+
# end
|
|
44
|
+
# ```
|
|
45
|
+
#
|
|
46
|
+
# 4. Create an event stream:
|
|
47
|
+
# ```ruby
|
|
48
|
+
# class OrderEventStream < Funes::EventStream
|
|
49
|
+
# add_transactional_projection OrderProjection
|
|
50
|
+
# end
|
|
51
|
+
# ```
|
|
52
|
+
#
|
|
53
|
+
# 5. Append events:
|
|
54
|
+
# ```ruby
|
|
55
|
+
# stream = OrderEventStream.for("order-123")
|
|
56
|
+
# event = stream.append!(Order::Placed.new(total: 99.99, customer_id: "cust-1"))
|
|
57
|
+
# ```
|
|
58
|
+
#
|
|
59
|
+
# ## Three-Tier Consistency Model
|
|
60
|
+
#
|
|
61
|
+
# Funes provides fine-grained control over projection execution:
|
|
62
|
+
#
|
|
63
|
+
# - **Consistency Projection**: Validates business rules before persisting events
|
|
64
|
+
# - **Transactional Projections**: Execute synchronously in the same database transaction
|
|
65
|
+
# - **Async Projections**: Execute asynchronously via ActiveJob
|
|
66
|
+
#
|
|
67
|
+
# @see Funes::Event
|
|
68
|
+
# @see Funes::EventStream
|
|
69
|
+
# @see Funes::Projection
|
|
70
|
+
# @see Funes::ProjectionTestHelper
|
|
71
|
+
module Funes
|
|
72
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Funes
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
desc "Funes rules!!!"
|
|
11
|
+
|
|
12
|
+
def self.next_migration_number(dirname)
|
|
13
|
+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create_migration_file
|
|
17
|
+
migration_template("migration.rb",
|
|
18
|
+
"db/migrate/create_funes_events_table.rb",
|
|
19
|
+
migration_version: migration_version)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def show_readme
|
|
23
|
+
readme "README.md" if File.exist?(File.join(self.class.source_root, "README.md"))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def json_column_type
|
|
29
|
+
postgres? ? "jsonb" : "json"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def migration_version
|
|
33
|
+
"[#{::Rails::VERSION::MAJOR}.#{::Rails::VERSION::MINOR}]"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def postgres?
|
|
37
|
+
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).first&.adapter == "postgresql"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateFunesEventsTable < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :events, id: false do |t|
|
|
4
|
+
t.column :klass, :string, null: false
|
|
5
|
+
t.column :entity_id, :string, null: false
|
|
6
|
+
t.column :props, :<%= json_column_type %>, null: false
|
|
7
|
+
t.column :version, :bigint, default: 1, null: false
|
|
8
|
+
t.column :created_at, :datetime, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :events, :entity_id
|
|
12
|
+
add_index :events, :created_at
|
|
13
|
+
add_index :events, [:entity_id, :version], unique: true
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|