eventsimple 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +18 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +164 -0
  7. data/Gemfile +6 -0
  8. data/Gemfile.lock +320 -0
  9. data/Guardfile +35 -0
  10. data/LICENSE +22 -0
  11. data/README.md +510 -0
  12. data/Rakefile +17 -0
  13. data/app/controllers/eventsimple/application_controller.rb +15 -0
  14. data/app/controllers/eventsimple/entities_controller.rb +59 -0
  15. data/app/controllers/eventsimple/home_controller.rb +5 -0
  16. data/app/controllers/eventsimple/models_controller.rb +10 -0
  17. data/app/views/eventsimple/entities/show.html.erb +109 -0
  18. data/app/views/eventsimple/home/index.html.erb +0 -0
  19. data/app/views/eventsimple/models/show.html.erb +19 -0
  20. data/app/views/eventsimple/shared/_header.html.erb +26 -0
  21. data/app/views/eventsimple/shared/_sidebar.html.erb +19 -0
  22. data/app/views/eventsimple/shared/_style.html.erb +105 -0
  23. data/app/views/layouts/eventsimple/application.html.erb +76 -0
  24. data/catalog-info.yaml +11 -0
  25. data/config/routes.rb +11 -0
  26. data/eventsimple.gemspec +42 -0
  27. data/lib/dry_types.rb +5 -0
  28. data/lib/eventsimple/configuration.rb +36 -0
  29. data/lib/eventsimple/data_type.rb +48 -0
  30. data/lib/eventsimple/dispatcher.rb +17 -0
  31. data/lib/eventsimple/engine.rb +37 -0
  32. data/lib/eventsimple/entity.rb +54 -0
  33. data/lib/eventsimple/event.rb +189 -0
  34. data/lib/eventsimple/event_dispatcher.rb +93 -0
  35. data/lib/eventsimple/generators/install_generator.rb +42 -0
  36. data/lib/eventsimple/generators/outbox/install_generator.rb +31 -0
  37. data/lib/eventsimple/generators/outbox/templates/create_outbox_cursor.erb +13 -0
  38. data/lib/eventsimple/generators/templates/create_events.erb +21 -0
  39. data/lib/eventsimple/generators/templates/event.erb +8 -0
  40. data/lib/eventsimple/invalid_transition.rb +14 -0
  41. data/lib/eventsimple/message.rb +23 -0
  42. data/lib/eventsimple/metadata.rb +11 -0
  43. data/lib/eventsimple/metadata_type.rb +38 -0
  44. data/lib/eventsimple/outbox/consumer.rb +52 -0
  45. data/lib/eventsimple/outbox/models/cursor.rb +25 -0
  46. data/lib/eventsimple/reactor_worker.rb +18 -0
  47. data/lib/eventsimple/support/spec_helpers.rb +47 -0
  48. data/lib/eventsimple/version.rb +5 -0
  49. data/lib/eventsimple.rb +41 -0
  50. data/log/development.log +0 -0
  51. data/sonar-project.properties +4 -0
  52. metadata +304 -0
@@ -0,0 +1,109 @@
1
+ <div class="row align-items-start">
2
+ <!-- Pane: Entity Details Start -->
3
+ <div class="col g-4 col-sm-8">
4
+ <h2><%= @model_name%>: <%= @aggregate_id %></h2>
5
+ <hr/>
6
+
7
+ <ul class="nav nav-tabs" role="tablist">
8
+ <li class="nav-item" role="presentation">
9
+ <a href="#" class="nav-link action-entity-query <%= @tab_id == 'entity' ? 'active' : '' %>" id="nav-tab-entity" data-param="t" data-value="entity">Entity</a>
10
+ </li>
11
+ <li class="nav-item" role="presentation">
12
+ <a href="#" class="nav-link action-entity-query <%= @tab_id == 'event' ? 'active' : '' %>" id="nav-tab-event" data-param="t" data-value="event">Event</a>
13
+ </li>
14
+ </ul>
15
+
16
+ <div class="tab-content pt-4 px-3">
17
+ <!-- Tab: Entity Start -->
18
+ <div id="tab-entity" class="tab-pane <%= @tab_id == 'entity' ? 'active' : '' %>" role="tabpanel" aria-labelledby="nav-tab-entity">
19
+ <p>
20
+ Shows the changes made before and after the currently selected event was applied.
21
+ </p>
22
+ <div class="table-responsive">
23
+ <table class="table table-striped">
24
+ <thead>
25
+ <tr>
26
+ <th scope="col" width="15%"></th>
27
+ <th scope="col" width="25%">Before</th>
28
+ <th scope="col" width="25%">After</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody>
32
+ <% @entity_changes.each do |property| %>
33
+ <tr class="<%= property[:is_changed] ? 'table-info' : '' %>">
34
+ <th scope="row"><%= property[:label] %></th>
35
+ <td><code class="entity-property"><%= property[:historical_value].nil? ? '-' : property[:historical_value] %></code></th>
36
+ <td><code class="entity-property"><%= property[:current_value].nil? ? '-' : property[:current_value] %></code></td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ </div>
42
+ </div>
43
+ <!-- Tab: Entity End -->
44
+
45
+ <!-- Tab: Event Start -->
46
+ <div id="tab-event" class="tab-pane <%= @tab_id == 'event' ? 'active' : '' %>" role="tabpanel" aria-labelledby="nav-tab-event">
47
+ <p>
48
+ The properties of the <code><%= @selected_event.type %></code> event.
49
+ </p>
50
+ <table class="table">
51
+ <tbody>
52
+ <tr>
53
+ <th scope="row">Identifier</th>
54
+ <td><code class="entity-property"><%= @selected_event.id %></code></td>
55
+ </tr>
56
+ <tr>
57
+ <th scope="row">Timestamp</th>
58
+ <td><code class="entity-property"><%= @selected_event.created_at %></code></td>
59
+ </tr>
60
+ <tr>
61
+ <th scope="row" colspan="2">Data</th>
62
+ </tr>
63
+ <% if @selected_event.data.present? %>
64
+ <% @selected_event.data.attributes.each do |attr_name, attr_value| %>
65
+ <tr>
66
+ <td>&nbsp;&nbsp;&nbsp;&nbsp;<%= attr_name %></td>
67
+ <td><code class="entity-property"><%= attr_value %></code></td>
68
+ </tr>
69
+ <% end %>
70
+ <% end %>
71
+ </tr>
72
+ <tr>
73
+ <th scope="row" colspan="2">Metadata</th>
74
+ </tr>
75
+ <% @selected_event.metadata.attributes.each do |attr_name, attr_value| %>
76
+ <tr>
77
+ <td>&nbsp;&nbsp;&nbsp;&nbsp;<%= attr_name %></td>
78
+ <td><code class="entity-property">: <%= attr_value %></code></td>
79
+ </tr>
80
+ <% end %>
81
+ </tbody>
82
+ </table>
83
+ </div>
84
+ <!-- Tab: Event End -->
85
+ </div>
86
+ </div>
87
+ <!-- Pane: Entity Details End -->
88
+
89
+ <!-- Pane: Time Travel Start -->
90
+ <div class="col g-4 col-sm-4">
91
+ <h3>Time Travel</h3>
92
+ <ul class="list-group">
93
+ <% @entity_event_history.each_with_index do | event, index | %>
94
+ <% is_active_list_item = event == @selected_event ? 'list-group-item-primary' : '' %>
95
+ <% is_active_link = event == @selected_event ? 'link-dark' : 'link-dark' %>
96
+ <% event_timestamp = index.zero? ? 'Current' : "#{time_ago_in_words(event.created_at)} ago" %>
97
+ <li class="list-group-item <%= is_active_list_item %>">
98
+ <a href="#" class="text-decoration-none action-entity-query <%= is_active_link %>" data-param="e" data-value="<%= event.id %>">
99
+ <div class="d-flex w-100 justify-content-between">
100
+ <span title="<%= event.type %>" style="width: 75%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;"><%= event.type.demodulize %></span>
101
+ <small><%= event_timestamp %></small>
102
+ </div>
103
+ </a>
104
+ </li>
105
+ <% end %>
106
+ </ul>
107
+ </div>
108
+ <!-- Pane: Time Travel End -->
109
+ </div>
File without changes
@@ -0,0 +1,19 @@
1
+
2
+ <table class="table table-striped">
3
+ <thead>
4
+ <tr>
5
+ <th scope="col" width="15%">Time</th>
6
+ <th scope="col" width="30%">Identifier</th>
7
+ <th scope="col" width="65%">Event</th>
8
+ </tr>
9
+ </thead>
10
+ <tbody>
11
+ <% @latest_entities.each do |event| %>
12
+ <tr>
13
+ <td scope="row"><%= "#{time_ago_in_words(event.created_at)} ago" %></td>
14
+ <td><%= link_to event.aggregate_id, model_entity_path(@model_name, event.aggregate_id) %></td>
15
+ <td><%= event.type %></td>
16
+ </tr>
17
+ <% end %>
18
+ </tbody>
19
+ </table>
@@ -0,0 +1,26 @@
1
+ <!-- Header Search Start -->
2
+ <header class="container mb-4">
3
+ <div class="row">
4
+ <div class="col col-sm-3">
5
+ <select id="form-search-model-name" class="form-select" aria-label="Model select" onchange="document.location.href=this.value" required>
6
+ <option value="">Choose event source model</option>
7
+ <% event_class_names.each do |klass| %>
8
+ <% is_active = klass === @model_name ? 'selected' : '' %>
9
+ <option value="<%= model_path(klass) %>" <%= is_active %>><%= klass %></option>
10
+ <% end %>
11
+ </select>
12
+ </div>
13
+
14
+ <% if @model_name %>
15
+ <div class="col col-sm-7">
16
+ <%= form_with url: model_entity_path(@model_name, ''), id: 'model-search' do |f| %>
17
+ <%= f.search_field :event_id, class: 'form-control', placeholder: 'Canonical identifier', value: @aggregate_id, required: true, aria: { label: 'Entity canonical identifier' } %>
18
+ </div>
19
+ <div class="col col-sm-2">
20
+ <%= f.submit 'Search', class: 'btn btn-primary' %>
21
+ <% end %>
22
+ </div>
23
+ <% end %>
24
+ </div>
25
+ </header>
26
+ <!-- Header Search End -->
@@ -0,0 +1,19 @@
1
+ <!-- Sidebar Nav Start -->
2
+ <nav class="d-flex flex-column flex-shrink-0 p-3 text-white bg-dark">
3
+ <%= link_to root_path, class: 'd-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none' do %>
4
+ <span class="fs-4">Eventsimple</span>
5
+ <% end %>
6
+ <hr>
7
+ <ul class="nav nav-pills flex-column mb-auto">
8
+ <% event_class_names.each do |klass| %>
9
+ <% is_active = klass === @model_name ? 'active text-white' : 'text-white' %>
10
+ <li class="nav-item text-white overflow-hidden" style="width: 100%;">
11
+ <%= link_to model_path(klass), class: "nav-link #{is_active}", style: "overflow: hidden; white-space: nowrap; text-overflow: ellipsis;" do %>
12
+ <i class="bi bi-table"></i>
13
+ <%= klass %>
14
+ <% end %>
15
+ </li>
16
+ <% end %>
17
+ </ul>
18
+ </nav>
19
+ <!-- Sidebar Nav End -->
@@ -0,0 +1,105 @@
1
+ <style>
2
+ // Structure
3
+ body {
4
+ min-height: 100vh;
5
+ min-height: -webkit-fill-available;
6
+ }
7
+
8
+ html {
9
+ height: -webkit-fill-available;
10
+ }
11
+
12
+ #page {
13
+ display: flex;
14
+ flex-wrap: nowrap;
15
+ height: 100vh;
16
+ height: -webkit-fill-available;
17
+ max-height: 100vh;
18
+ overflow-x: auto;
19
+ overflow-y: hidden;
20
+ }
21
+
22
+ nav {
23
+ width: 20%;
24
+ }
25
+
26
+ main {
27
+ flex-shrink: 0;
28
+ height: 100vh;
29
+ width: 80%;
30
+ overflow-y: auto;
31
+ }
32
+
33
+ // Sidebar Nav
34
+
35
+ .bi {
36
+ vertical-align: -.125em;
37
+ pointer-events: none;
38
+ fill: currentColor;
39
+ }
40
+
41
+ .dropdown-toggle { outline: 0; }
42
+
43
+ .nav-flush .nav-link {
44
+ border-radius: 0;
45
+ }
46
+
47
+ .btn-toggle {
48
+ display: inline-flex;
49
+ align-items: center;
50
+ padding: .25rem .5rem;
51
+ font-weight: 600;
52
+ color: rgba(0, 0, 0, .65);
53
+ background-color: transparent;
54
+ border: 0;
55
+ }
56
+ .btn-toggle:hover,
57
+ .btn-toggle:focus {
58
+ color: rgba(0, 0, 0, .85);
59
+ background-color: #d2f4ea;
60
+ }
61
+
62
+ .btn-toggle::before {
63
+ width: 1.25em;
64
+ line-height: 0;
65
+ content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%280,0,0,.5%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");
66
+ transition: transform .35s ease;
67
+ transform-origin: .5em 50%;
68
+ }
69
+
70
+ .btn-toggle[aria-expanded="true"] {
71
+ color: rgba(0, 0, 0, .85);
72
+ }
73
+ .btn-toggle[aria-expanded="true"]::before {
74
+ transform: rotate(90deg);
75
+ }
76
+
77
+ .btn-toggle-nav a {
78
+ display: inline-flex;
79
+ padding: .1875rem .5rem;
80
+ margin-top: .125rem;
81
+ margin-left: 1.25rem;
82
+ text-decoration: none;
83
+ }
84
+ .btn-toggle-nav a:hover,
85
+ .btn-toggle-nav a:focus {
86
+ background-color: #d2f4ea;
87
+ }
88
+
89
+ .scrollarea {
90
+ overflow-y: auto;
91
+ }
92
+
93
+ .fw-semibold { font-weight: 600; }
94
+ .lh-tight { line-height: 1.25; }
95
+
96
+ // Styles
97
+
98
+ code {
99
+ color: deeppink;
100
+ }
101
+
102
+ code.entity-property {
103
+ color: #333;
104
+ }
105
+ </style>
@@ -0,0 +1,76 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="description" content="">
7
+ <title>Eventsimple</title>
8
+ <%= csrf_meta_tags %>
9
+ <%= csp_meta_tag %>
10
+
11
+ <%= stylesheet_link_tag "https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css",
12
+ media: "all",
13
+ integrity: 'sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65',
14
+ crossorigin: 'anonymous'
15
+ %>
16
+ <%= stylesheet_link_tag "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.3/font/bootstrap-icons.css",
17
+ media: "all",
18
+ crossorigin: 'anonymous'
19
+ %>
20
+ <%= javascript_include_tag "https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js",
21
+ integrity: "sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V",
22
+ crossorigin: "anonymous"
23
+ %>
24
+ <%= javascript_include_tag "https://cdn.jsdelivr.net/npm/jquery@3.6.3/dist/jquery.min.js",
25
+ crossorigin: "anonymous"
26
+ %>
27
+
28
+ <%= render partial: 'eventsimple/shared/style' %>
29
+ </head>
30
+ <body>
31
+ <div id="page">
32
+ <%= render partial: 'eventsimple/shared/sidebar' %>
33
+
34
+ <!-- Main Container Start -->
35
+ <main class="pt-4 px-2">
36
+ <%= render partial: 'eventsimple/shared/header' %>
37
+
38
+ <!-- Content Start -->
39
+ <article class="container">
40
+ <% if @error_message.present? %>
41
+ <div class="alert alert-warning d-flex" role="alert">
42
+ <i class="bi bi-exclamation-triangle-fill me-2"></i>
43
+ <div>
44
+ <%= @error_message %>
45
+ </div>
46
+ </div>
47
+ <% else %>
48
+ <%= yield %>
49
+ <% end %>
50
+ </article>
51
+ <!-- Content End -->
52
+
53
+ </main>
54
+ <!-- Main Container End -->
55
+
56
+ <script>
57
+ $( document ).ready(() => {
58
+ $('.action-entity-query').click((event) => {
59
+ const link = $(event.currentTarget);
60
+ const { param, value } = link.data();
61
+ const query = new URLSearchParams(window.location.search)
62
+ query.set(param, value);
63
+ link.attr("href",window.location.pathname + '?' + query.toString());
64
+ });
65
+
66
+ $('#model-search').on('submit', (event) => {
67
+ event.preventDefault();
68
+ const form = $(event.currentTarget);
69
+ window.location = form.attr('action') + form.children('#event_id').val();
70
+ });
71
+ });
72
+ </script>
73
+
74
+ </div>
75
+ </body>
76
+ </html>
data/catalog-info.yaml ADDED
@@ -0,0 +1,11 @@
1
+ ---
2
+ apiVersion: backstage.io/v1alpha1
3
+ kind: Component
4
+ metadata:
5
+ name: eventsimple
6
+ description: Event Driven Toolkit for Rails
7
+ tags:
8
+ - ruby
9
+ spec:
10
+ type: library
11
+ lifecycle: production
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ Eventsimple::Engine.routes.draw do
2
+ root to: 'home#index'
3
+
4
+ resources :models, only: [:show], param: :name do
5
+ member do
6
+ post :search
7
+ end
8
+
9
+ resources :entities, only: :show
10
+ end
11
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/eventsimple/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'eventsimple'
7
+ spec.version = Eventsimple::VERSION
8
+ spec.authors = ['Zulfiqar Ali']
9
+ spec.email = ['zulfiqar@wealthsimple.com']
10
+
11
+ spec.summary = 'Event driven toolkit using Rails and Sidekiq'
12
+ spec.description = 'Event driven architecture using Rails and Sidekiq'
13
+ spec.homepage = 'https://github.com/wealthsimple/eventsimple'
14
+ spec.required_ruby_version = ">= 3.2.0"
15
+
16
+ spec.metadata['changelog_uri'] = "https://github.com/wealthsimple/eventsimple/blob/main/CHANGELOG.md"
17
+ spec.license = 'MIT'
18
+
19
+ spec.files = Dir.chdir(__dir__) do
20
+ `git ls-files -z`.split("\x0").reject do |f|
21
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git))})
22
+ end
23
+ end
24
+ spec.require_paths = ['lib']
25
+
26
+ spec.add_runtime_dependency 'dry-struct', '~> 1.6'
27
+ spec.add_runtime_dependency 'dry-types', '~> 1.7'
28
+ spec.add_runtime_dependency 'pg', '~> 1.4'
29
+ spec.add_runtime_dependency 'rails', '~> 7.0'
30
+ spec.add_runtime_dependency 'retriable', '~> 3.1'
31
+ spec.add_runtime_dependency 'sidekiq', '~> 7.0'
32
+
33
+ spec.add_development_dependency 'bundle-audit'
34
+ spec.add_development_dependency 'fuubar'
35
+ spec.add_development_dependency 'git'
36
+ spec.add_development_dependency 'guard-rspec'
37
+ spec.add_development_dependency 'parse_a_changelog'
38
+ spec.add_development_dependency 'pry'
39
+ spec.add_development_dependency 'puma'
40
+ spec.add_development_dependency 'rspec-rails'
41
+ spec.add_development_dependency 'ws-style'
42
+ end
data/lib/dry_types.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DryTypes
4
+ include Dry.Types()
5
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eventsimple
4
+ class Configuration
5
+ attr_reader :max_concurrency_retries, :dispatchers
6
+ attr_writer :metadata_klass
7
+
8
+ attr_accessor :ui_visible_models
9
+
10
+ def initialize
11
+ @dispatchers = []
12
+ @max_concurrency_retries = 2
13
+ @metadata_klass = 'Eventsimple::Metadata'
14
+
15
+ @ui_visible_models = [] # internal use only
16
+ end
17
+
18
+ def max_concurrency_retries=(value)
19
+ unless value.is_a?(Integer) && value.positive?
20
+ raise ArgumentError, 'max_concurrency_retries must be a positive integer'
21
+ end
22
+
23
+ @max_concurrency_retries = value
24
+ end
25
+
26
+ def dispatchers=(value)
27
+ raise ArgumentError, 'dispatchers must be an array' unless value.is_a?(Array)
28
+
29
+ @dispatchers = value
30
+ end
31
+
32
+ def metadata_klass
33
+ @klass ||= @metadata_klass.constantize # rubocop:disable Naming/MemoizedInstanceVariableName
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eventsimple
4
+ class DataType < ActiveModel::Type::Value
5
+ def initialize(event_klass)
6
+ @event_klass = event_klass
7
+ super()
8
+ end
9
+
10
+ attr_reader :event_klass
11
+
12
+ def type
13
+ :data_type
14
+ end
15
+
16
+ def cast_value(value)
17
+ case value
18
+ when String
19
+ decoded = ActiveSupport::JSON.decode(value)
20
+ return event_klass::Message.new(decoded) if event_klass.const_defined?(:Message)
21
+
22
+ decoded
23
+ when Hash
24
+ return event_klass::Message.new(value) if event_klass.const_defined?(:Message)
25
+
26
+ value
27
+ when event_klass::Message
28
+ value
29
+ end
30
+ end
31
+
32
+ def serialize(value)
33
+ case value
34
+ when Hash, event_klass::Message
35
+ ActiveSupport::JSON.encode(value)
36
+ else
37
+ super
38
+ end
39
+ end
40
+
41
+ def deserialize(value)
42
+ decoded = ActiveSupport::JSON.decode(value)
43
+ return event_klass::Message.new(decoded) if event_klass.const_defined?(:Message)
44
+
45
+ decoded
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,17 @@
1
+ module Eventsimple
2
+ class Dispatcher
3
+ def self.on(*events, sync: [], async: [])
4
+ # Register Reactors to Events.
5
+ # * Reactors registered with `sync` will be synced synchronously
6
+ # * Reactors registered with `async` will be synced asynchronously via a Sidekiq Job
7
+ #
8
+ # Example:
9
+ #
10
+ # on BaseEvent, sync: LogEvent, async: TrackEvent
11
+ # on PledgeCancelled, PaymentFailed, async: [NotifyAdmin, CreateTask]
12
+ # on [PledgeCancelled, PaymentFailed], async: [NotifyAdmin, CreateTask]
13
+ #
14
+ EventDispatcher.rules.register(events: events.flatten, sync: Array(sync), async: Array(async))
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ require 'rails'
2
+
3
+ module Eventsimple
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Eventsimple
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ g.helper false
10
+ g.view_specs false
11
+ end
12
+
13
+ config.after_initialize do
14
+ dispatchers = Eventsimple.configuration.dispatchers.map(&:constantize)
15
+
16
+ unless dispatchers.all? { |dispatcher| dispatcher.superclass == Eventsimple::Dispatcher }
17
+ raise ArgumentError, 'dispatchers must inherit from Eventsimple::Dispatcher'
18
+ end
19
+
20
+ retry_intervals = Array.new(Eventsimple.configuration.max_concurrency_retries) { 0 }
21
+
22
+ Retriable.configure do |c|
23
+ c.contexts[:reactor] = {
24
+ tries: 7,
25
+ base_interval: 1.0,
26
+ multiplier: 1.0,
27
+ rand_factor: 0.0,
28
+ on: ActiveRecord::RecordNotFound,
29
+ }
30
+ c.contexts[:optimistic_locking] = {
31
+ intervals: retry_intervals,
32
+ on: ActiveRecord::StaleObjectError,
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,54 @@
1
+ module Eventsimple
2
+ module Entity
3
+ DEFAULT_IGNORE_PROPS = %w[id lock_version].freeze
4
+
5
+ def event_driven_by(event_klass, aggregate_id:)
6
+ has_many :events, class_name: event_klass.name.to_s,
7
+ foreign_key: :aggregate_id,
8
+ primary_key: aggregate_id,
9
+ dependent: :delete_all,
10
+ inverse_of: model_name.element.to_sym,
11
+ autosave: false,
12
+ validate: false
13
+
14
+ class_attribute :ignored_for_projection, default: []
15
+
16
+ # disable automatic timestamp updates
17
+ self.record_timestamps = false
18
+
19
+ Eventsimple.configuration.ui_visible_models |= [self]
20
+
21
+ include InstanceMethods
22
+ extend ClassMethods
23
+ end
24
+
25
+ module InstanceMethods
26
+ def projection_matches_events?
27
+ reprojected = self.class.find(id).reproject
28
+
29
+ attributes == reprojected.attributes
30
+ end
31
+
32
+ def reproject(at: nil)
33
+ event_history = at ? events.where('created_at <= ?', at).load : events.load
34
+ ignore_props = (DEFAULT_IGNORE_PROPS + ignored_for_projection).map(&:to_s)
35
+ assign_attributes(self.class.column_defaults.except(*ignore_props))
36
+
37
+ event_history.each do |event|
38
+ event.apply(self)
39
+ event.apply_timestamps(self)
40
+ end
41
+
42
+ self
43
+ end
44
+ end
45
+
46
+ module ClassMethods
47
+ def event_class
48
+ reflect_on_all_associations(:has_many).find { |association|
49
+ association.name == :events
50
+ }.klass
51
+ end
52
+ end
53
+ end
54
+ end