akasha 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 1294447dab3ae4202502a232dcc069d06dcdbf5d
4
- data.tar.gz: 296cfc35578c3f15a90d55f0f0ed1db018458541
3
+ metadata.gz: '09372b1685568c249f091ff1bbbf4915bb574b91'
4
+ data.tar.gz: 982ef7993d2087b2bd55f424228ba5bdf31d867a
5
5
  SHA512:
6
- metadata.gz: 0b177423fce21ab067cee6c37f0d50e1d132056611791af3af17c56b2b8a2ba6ea20d76a11462e99459814b1ce24259ea86d01bc794818110da1ea49283fcbbc
7
- data.tar.gz: 3cbe93b9aeafeca8b3541d01feaacfb6bdd0395bf3ed92d9be73a5ee7df3dfdb593deb8ad55e9d9647748f51a069ae881c9fd970aa473fc73f5087f63d012c3e
6
+ metadata.gz: 59730ad9b8f01e86d5fdfd01945fea0586002972571595f03810d7af600f9d864a5d5c22fc1e7092a68067f6fd310c16f825980b8515d71f7933a05f2362dfc1
7
+ data.tar.gz: 1fb02d4091efe00a925af1ec4d15990c0cc0c61aaaf64a1c64de36a63dcc3eea3b73465d5e558880ad46393d90146dfb6a788d5e5544f7afcdf68bda67f38d78
@@ -2,3 +2,5 @@ Style/FrozenStringLiteralComment:
2
2
  Enabled: false
3
3
  Metrics/LineLength:
4
4
  Max: 120
5
+ Metrics/MethodLength:
6
+ Max: 15
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## Version 0.2.0
4
+
5
+ * Synchronous event listeners (see `examples/sinatra/app.rb`).
6
+ * HTTP-based Eventstore storage.
7
+
3
8
 
4
9
  ## Version 0.1.0
5
10
 
@@ -1,7 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- akasha (0.1.0)
4
+ akasha (0.2.0)
5
+ http_event_store
5
6
 
6
7
  GEM
7
8
  remote: https://rubygems.org/
@@ -9,7 +10,19 @@ GEM
9
10
  byebug (10.0.2)
10
11
  coderay (1.1.2)
11
12
  diff-lcs (1.3)
13
+ faraday (0.15.2)
14
+ multipart-post (>= 1.2, < 3)
15
+ faraday_middleware (0.12.2)
16
+ faraday (>= 0.7.4, < 1.0)
17
+ hashie (3.5.7)
18
+ http_event_store (0.2.2)
19
+ faraday
20
+ faraday_middleware
21
+ hashie
22
+ json
23
+ json (2.1.0)
12
24
  method_source (0.9.0)
25
+ multipart-post (2.0.0)
13
26
  pry (0.11.3)
14
27
  coderay (~> 1.1.0)
15
28
  method_source (~> 0.9.0)
data/README.md CHANGED
@@ -29,7 +29,7 @@ require 'sinatra'
29
29
 
30
30
  class User < Akasha::Aggregate
31
31
  def sign_up(email:, password:, admin: false, **)
32
- changeset << Akasha::Event.new(:user_signed_up, email: email, password: password, admin: admin)
32
+ changeset.append(:user_signed_up, email: email, password: password, admin: admin)
33
33
  end
34
34
 
35
35
  def on_user_signed_up(email:, password:, admin:, **)
@@ -73,11 +73,13 @@ end
73
73
  ## Next steps
74
74
 
75
75
  - [x] Command routing (default and user-defined)
76
- - [ ] EventHandler (relying only on Eventstore)
77
- - [ ] HTTP Eventstore storage backend
76
+ - [x] Synchronous EventHandler
77
+ - [x] HTTP Eventstore storage backend
78
+ - [ ] Telemetry (Dogstatsd)
79
+ - [ ] Event#id for better idempotence (validate this claim)
78
80
  - [ ] Namespacing for events and aggregates
79
81
  - [ ] Version-based concurrency
80
- - [ ] Rake task for running EventHandlers
82
+ - [ ] Async EventHandlers (storing cursors in Eventstore, configurable durability guarantees)
81
83
  - [ ] Socket-based Eventstore storage backend
82
84
 
83
85
  ## Development
@@ -23,6 +23,8 @@ Gem::Specification.new do |spec|
23
23
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
24
  spec.require_paths = ['lib']
25
25
 
26
+ spec.add_dependency 'http_event_store'
27
+
26
28
  spec.add_development_dependency 'bundler', '~> 1.16'
27
29
  spec.add_development_dependency 'rake', '~> 10.0'
28
30
  spec.add_development_dependency 'rspec', '~> 3.7'
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'sinatra'
4
+ gem 'akasha'
@@ -0,0 +1,24 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ akasha (0.1.0)
5
+ mustermann (1.0.2)
6
+ rack (2.0.5)
7
+ rack-protection (2.0.1)
8
+ rack
9
+ sinatra (2.0.1)
10
+ mustermann (~> 1.0)
11
+ rack (~> 2.0)
12
+ rack-protection (= 2.0.1)
13
+ tilt (~> 2.0)
14
+ tilt (2.0.8)
15
+
16
+ PLATFORMS
17
+ ruby
18
+
19
+ DEPENDENCIES
20
+ akasha
21
+ sinatra
22
+
23
+ BUNDLED WITH
24
+ 1.16.2
@@ -0,0 +1,67 @@
1
+ require 'akasha'
2
+ require 'sinatra'
3
+
4
+ class User < Akasha::Aggregate
5
+ attr_reader :email
6
+
7
+ def sign_up(email:, password:, admin: false, **)
8
+ changeset.append(:user_signed_up, email: email, password: password, admin: admin)
9
+ end
10
+
11
+ def on_user_signed_up(email:, password:, admin:, **)
12
+ @email = email
13
+ @password = password
14
+ @admin = admin
15
+ end
16
+ end
17
+
18
+ class Notifier < Akasha::EventListener
19
+ def on_user_signed_up(user_id, **)
20
+ notify_about_signup(user_id)
21
+ end
22
+
23
+ private
24
+
25
+ def notify_about_signup(user_id)
26
+ # Here we could just grab email from the event but let's demonstrate
27
+ # how to load an aggregate from events.
28
+ user = User.find_or_create(user_id)
29
+ email = <<~EOS
30
+ User #{user.email} just signed up!
31
+ EOS
32
+ # Let's not send any emails... :)
33
+ puts email
34
+ end
35
+ end
36
+
37
+ before do
38
+ @command_router = Akasha::CommandRouter.new
39
+
40
+ # Aggregates will load from and save to in-memory storage.
41
+ repository = Akasha::Repository.new(Akasha::Storage::MemoryEventStore.new)
42
+ Akasha::Aggregate.connect!(repository)
43
+
44
+ # Set up event listeners.
45
+ @event_router = Akasha::EventRouter.new
46
+ @event_router.register_event_listener(:user_signed_up, Notifier)
47
+ repository.subscribe(@event_router)
48
+
49
+ # This is how you link commands to aggregates.
50
+ @command_router.register_default_route(:sign_up, User)
51
+
52
+ # Nearly identital to the default handling above but we're setting the admin
53
+ # flag to demo custom command handling.
54
+ @command_router.register_route(:sign_up_admin) do |aggregate_id, **data|
55
+ user = User.find_or_create(aggregate_id)
56
+ user.sign_up(email: data[:email], password: data[:password], admin: true)
57
+ user.save!
58
+ end
59
+ end
60
+
61
+ post '/users/:user_id' do # With CQRS client pass unique aggregate ids.
62
+ @command_router.route!(:sign_up,
63
+ params[:user_id],
64
+ email: params[:email],
65
+ password: params[:password])
66
+ 'OK'
67
+ end
@@ -1,5 +1,8 @@
1
1
  require 'akasha/event'
2
2
  require 'akasha/aggregate'
3
3
  require 'akasha/command_router'
4
+ require 'akasha/event_listener'
5
+ require 'akasha/event_router'
4
6
  require 'akasha/repository'
7
+ require 'akasha/storage/http_event_store'
5
8
  require 'akasha/storage/memory_event_store'
@@ -12,6 +12,12 @@ module Akasha
12
12
  @data = data
13
13
  end
14
14
 
15
+ def metadata
16
+ {
17
+ created_at: @created_at
18
+ }
19
+ end
20
+
15
21
  def ==(other)
16
22
  self.class == other.class &&
17
23
  name == other.name &&
@@ -0,0 +1,5 @@
1
+ module Akasha
2
+ # Event listener base class.
3
+ class EventListener
4
+ end
5
+ end
@@ -0,0 +1,38 @@
1
+ module Akasha
2
+ # Routes events to event listeners.
3
+ class EventRouter
4
+ def initialize
5
+ @routes = Hash.new { |hash, key| hash[key] = [] }
6
+ end
7
+
8
+ # Registers a new event listener, derived from
9
+ # `Akasha::EventListener`.
10
+ def register_event_listener(event_name, listener_class)
11
+ @routes[event_name] << listener_class
12
+ end
13
+
14
+ # Routes an event.
15
+ def route(event_name, aggregate_id, **data)
16
+ @routes[event_name].each do |listener_class|
17
+ listener = listener_class.new
18
+ begin
19
+ listener.public_send(:"on_#{event_name}", aggregate_id, **data)
20
+ rescue RuntimeError => e
21
+ log "Error handling event #{event_name.inspect}: #{e}"
22
+ end
23
+ end
24
+ end
25
+
26
+ # Routes an event (an Akasha::Event instance).
27
+ # This is interface allowing subscription via `Akasha::Repository#subscribe`.
28
+ def call(aggregate_id, event)
29
+ route(event.name, aggregate_id, **event.data)
30
+ end
31
+
32
+ private
33
+
34
+ def log(msg)
35
+ puts msg
36
+ end
37
+ end
38
+ end
@@ -5,25 +5,43 @@ module Akasha
5
5
  class Repository
6
6
  STREAM_NAME_SEP = '-'.freeze
7
7
 
8
+ # Creates a new repository using the underlying `store` (e.g. `MemoryEventStore`).
8
9
  def initialize(store)
9
10
  @store = store
11
+ @subscribers = []
10
12
  end
11
13
 
14
+ # Loads an aggregate identified by `id` and `klass` from the repository.
15
+ # Returns an aggregate instance of class `klass` constructed by applying events from the corresponding
16
+ # stream.
12
17
  def load_aggregate(klass, id)
13
18
  agg = klass.new(id)
14
19
 
15
20
  start = 0
16
- chunk_size = 100
17
- stream(klass, id).read_events(start, chunk_size) do |events|
21
+ page_size = 20
22
+ stream(klass, id).read_events(start, page_size) do |events|
18
23
  agg.apply_events(events)
19
24
  end
20
25
 
21
26
  agg
22
27
  end
23
28
 
29
+ # Saves an aggregate to the repository, appending events to the corresponding stream.
24
30
  def save_aggregate(aggregate)
25
31
  changeset = aggregate.changeset
26
32
  stream(aggregate.class, changeset.aggregate_id).write_events(changeset.events)
33
+ notify_subscribers(aggregate)
34
+ end
35
+
36
+ # Subscribes to event streams passing either a lambda or a block.
37
+ # Example:
38
+ #
39
+ # repo.subscribe do |aggregate_id, event|
40
+ # ... handle the event ...
41
+ # end
42
+ def subscribe(lambda = nil, &block)
43
+ callable = lambda || block
44
+ @subscribers << callable
27
45
  end
28
46
 
29
47
  private
@@ -35,5 +53,15 @@ module Akasha
35
53
  def stream(aggregate_klass, aggregate_id)
36
54
  @store.streams[stream_name(aggregate_klass, aggregate_id)]
37
55
  end
56
+
57
+ def notify_subscribers(aggregate)
58
+ id = aggregate.changeset.aggregate_id
59
+ events = aggregate.changeset.events
60
+ @subscribers.each do |subscriber|
61
+ events.each do |event|
62
+ subscriber.call(id, event)
63
+ end
64
+ end
65
+ end
38
66
  end
39
67
  end
@@ -0,0 +1,24 @@
1
+ require_relative 'http_event_store/stream'
2
+ require 'http_event_store'
3
+
4
+ module Akasha
5
+ module Storage
6
+ # HTTP-based interface to Eventstore (https://geteventstore.com)
7
+ class HttpEventStore
8
+ def initialize(host: 'localhost', port: 2113)
9
+ @client = ::HttpEventStore::Connection.new do |config|
10
+ config.endpoint = host
11
+ config.port = port
12
+ end
13
+ end
14
+
15
+ def streams
16
+ self # Use the `[]` method on self.
17
+ end
18
+
19
+ def [](stream_name)
20
+ Stream.new(@client, stream_name)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,50 @@
1
+ module Akasha
2
+ module Storage
3
+ class HttpEventStore
4
+ # HTTP Eventstore stream.
5
+ class Stream
6
+ def initialize(client, stream_name)
7
+ @client = client
8
+ @stream_name = stream_name
9
+ end
10
+
11
+ # Appends events to the stream.
12
+ def write_events(events)
13
+ event_hashes = events.map do |event|
14
+ {
15
+ event_type: event.name,
16
+ data: event.data,
17
+ metadata: event.metadata
18
+ }
19
+ end
20
+ @client.append_to_stream(@stream_name, event_hashes)
21
+ end
22
+
23
+ # Reads events from the stream starting from `start` inclusive.
24
+ # If block given, reads all events from the position in pages of `page_size`.
25
+ # If block not given, reads `size` events from the position.
26
+ def read_events(start, page_size)
27
+ if block_given?
28
+ position = start
29
+ loop do
30
+ events = read_events(position, page_size)
31
+ return if events.empty?
32
+ yield(events)
33
+ position += events.size
34
+ end
35
+ else
36
+ safe_read_events(start, page_size)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def safe_read_events(start, page_size)
43
+ @client.read_events_forward(@stream_name, start, page_size)
44
+ rescue ::HttpEventStore::StreamNotFound
45
+ []
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -12,15 +12,14 @@ module Akasha
12
12
  @events += events
13
13
  end
14
14
 
15
- # Reads events from the stream starting from `position` inclusive.
16
- # If block given, reads all events from the position in chunks
17
- # of `size`.
18
- # If block not given, reads `size` events from the position.
19
- def read_events(position, size, &block)
15
+ # Reads events from the stream starting from `start` inclusive.
16
+ # If block given, reads all events from the start in pages of `page_size`.
17
+ # If block not given, reads `page_size` events from the start.
18
+ def read_events(start, page_size, &block)
20
19
  if block_given?
21
- @events.lazy.drop(position).each_slice(size, &block)
20
+ @events.lazy.drop(start).each_slice(page_size, &block)
22
21
  else
23
- @events[position..position + size]
22
+ @events[start..start + page_size]
24
23
  end
25
24
  end
26
25
  end
@@ -1,3 +1,3 @@
1
1
  module Akasha
2
- VERSION='0.1.0'
2
+ VERSION = '0.2.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: akasha
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marcin Bilski
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-06-04 00:00:00.000000000 Z
11
+ date: 2018-06-05 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: http_event_store
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: bundler
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -101,6 +115,9 @@ files:
101
115
  - akasha.gemspec
102
116
  - bin/console
103
117
  - bin/setup
118
+ - examples/sinatra/Gemfile
119
+ - examples/sinatra/Gemfile.lock
120
+ - examples/sinatra/app.rb
104
121
  - lib/akasha.rb
105
122
  - lib/akasha/aggregate.rb
106
123
  - lib/akasha/aggregate/syntax_helpers.rb
@@ -108,7 +125,11 @@ files:
108
125
  - lib/akasha/command_router.rb
109
126
  - lib/akasha/command_router/default_handler.rb
110
127
  - lib/akasha/event.rb
128
+ - lib/akasha/event_listener.rb
129
+ - lib/akasha/event_router.rb
111
130
  - lib/akasha/repository.rb
131
+ - lib/akasha/storage/http_event_store.rb
132
+ - lib/akasha/storage/http_event_store/stream.rb
112
133
  - lib/akasha/storage/memory_event_store.rb
113
134
  - lib/akasha/storage/memory_event_store/stream.rb
114
135
  - lib/akasha/version.rb