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.
@@ -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,4 @@
1
+ module Funes
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ 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,6 @@
1
+ module Funes
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module Funes
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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,9 @@
1
+ module Funes
2
+ class EventEntry < ApplicationRecord
3
+ self.table_name = "event_entries"
4
+
5
+ def to_klass_instance
6
+ klass.constantize.new(props.symbolize_keys)
7
+ end
8
+ end
9
+ 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>
@@ -0,0 +1,5 @@
1
+ en:
2
+ funes:
3
+ events:
4
+ racing_condition_on_insert: "The current event led to an invalid state"
5
+ led_to_invalid_state_prefix: "Led to invalid state"
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Funes::Engine.routes.draw do
2
+ end
@@ -0,0 +1,10 @@
1
+ module Funes
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Funes
4
+
5
+ initializer "funes.autoload", before: :set_autoload_paths do |app|
6
+ engine_root = config.root
7
+ app.config.autoload_paths << engine_root.join("lib")
8
+ end
9
+ end
10
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module Funes
2
+ VERSION = "0.1.0"
3
+ 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
+
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :funes do
3
+ # # Task goes here
4
+ # end