eventsimple 1.0.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.
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