├── .gitignore
├── .rvmrc
├── Gemfile
├── Guardfile
├── LICENSE
├── README.md
├── Rakefile
├── lib
├── replay.rb
└── replay
│ ├── backends.rb
│ ├── configuration.rb
│ ├── event_declarations.rb
│ ├── event_decorator.rb
│ ├── event_envelope.rb
│ ├── events.rb
│ ├── inflector.rb
│ ├── observer.rb
│ ├── publisher.rb
│ ├── repository.rb
│ ├── repository
│ ├── configuration.rb
│ └── identity_map.rb
│ ├── router.rb
│ ├── router
│ └── default_router.rb
│ ├── rspec.rb
│ ├── subscription_manager.rb
│ ├── subscriptions.rb
│ ├── test.rb
│ ├── test
│ └── test_event_stream.rb
│ └── version.rb
├── proofs
├── all.rb
├── proofs_init.rb
└── replay
│ ├── inflector_proof.rb
│ ├── observer_proof.rb
│ ├── publisher_proof.rb
│ ├── repository_configuration_proof.rb
│ ├── repository_proof.rb
│ ├── subscriber_manager_proof.rb
│ └── test_proof.rb
├── replay.gemspec
└── test
├── replay
├── observer_spec.rb
└── router
│ └── default_router_spec.rb
└── test_helper.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | *.gem
2 | .idea/*
3 | .bundle
4 | Gemfile.lock
5 | pkg/*
6 |
--------------------------------------------------------------------------------
/.rvmrc:
--------------------------------------------------------------------------------
1 | rvm use 2.0.0@replay --create
2 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "http://rubygems.org"
2 |
3 | # Specify your gem's dependencies in replay.gemspec
4 | gemspec
5 |
6 | group :development do
7 | gem 'guard'
8 | gem 'guard-shell'
9 | end
10 |
11 | group :test do
12 | gem "proof", :git => 'https://github.com/Sans/proof.git'
13 | gem 'byebug'
14 | end
15 |
--------------------------------------------------------------------------------
/Guardfile:
--------------------------------------------------------------------------------
1 | # A sample Guardfile
2 | # More info at https://github.com/guard/guard#readme
3 |
4 | # Add files and commands to this file, like the example:
5 | # watch(%r{file/path}) { `command(s)` }
6 | #
7 | guard :shell, :all_on_start => false do
8 | watch(/lib\/(.*).rb/) {|m| `ruby proofs/all.rb` }
9 | watch(/proofs\/(.*).rb/) {|m| `ruby #{m[0]}`}
10 | end
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Keith Gaddis
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Replay
2 | Replay is a gem to support event sourcing data within domain boundaries. With event sourced data, the application stores data as a series of domain events, which are applied to the domain object in order to mutate state.
3 |
4 | ## Disclaimer (5/30/2017)
5 | This repo is here primarily for historical reasons—if you are starting a new project in Ruby and want to eventsource some of your data, I recommend checking out [Eventide Project](https://github.com/eventide-project) and the various libraries that make up that project, which is (as of this writing) under active development by individuals who are much more concerned than I am with moving Ruby forward in that direction.
6 |
7 | ### CQRS/ES 30 second intro
8 | [Command Query Responsibility Segregation](http://codebetter.com/gregyoung/2010/02/16/cqrs-task-based-uis-event-sourcing-agh/) (and [Fowler's explanation](http://martinfowler.com/bliki/CQRS.html) is a pattern popularized by Greg Young and Udi Dahan from within the sphere of Domain Driven Design. The general idea is that within domain models, objects are rarely good at both representing truth and being purposeful for queries and reporting, and therefore we should separate the responsibilities.
9 |
10 | [Event Sourcing](http://martinfowler.com/eaaDev/EventSourcing.html) is a pattern that is not required by (but pairs extremely well with) CQRS. However, by embracing this pattern a system can adapt to new reporting and query requirements at any time with a great deal of flexibility, and the use of messaging/pub-sub along with events creates an easy path to breaking apart monolithic applications and separating domains.
11 |
12 | ### A short example
13 |
14 | class ReplayExample
15 | include Replay::Publisher
16 |
17 | #define events
18 | events do
19 | SomethingHappened(name: String, pid: Integer)
20 | SomethingElseHappened(pid: Integer)
21 | #....
22 | end
23 |
24 | #applying events (changing state)
25 | apply SomethingHappened do |event|
26 | @name = event.name
27 | @pid = event.pid
28 | end
29 |
30 | apply SomethingElseHappened do |event|
31 | @state = :happened_again
32 | @pid = nil if event.pid == @pid
33 | end
34 |
35 | def do_something(pid = nil)
36 | #the command validates inputs
37 | #InvalidCommand is defined by the application
38 | raise InvalidCommand.new("parameters were invalid") unless pid
39 |
40 | #publish events
41 | publish SomethingHappened.new(:name = "foo", :pid => pid)
42 |
43 | #publish with method syntax
44 | publish SomethingElseHappened(pid: pid)
45 | end
46 | end
47 |
48 | There's a couple of things to note about the above example. ReplayExample is a domain object. (Clearly this example is a bit contrived.) [Domain objects](http://martinfowler.com/eaaCatalog/domainModel.html) represent and encapsulate domain logic in its purest sense. No application code should make its way into a domain object, nor should concerns from another bounded context.
49 |
50 | Domain objects publish events in order to mutate state. The events published by this domain object are defined within the `events` block; `ReplayExample::SomethingHappened` is a class defined there, which has two attributes `name` and `pid`, which are `String` and `Integer` respectively. Events may also be defined manually, like any other class. Because they're essentially value objects, with zero behavior, the shorthand form above is usually going to be easier.
51 |
52 | `ReplayExample` instances change state by applying events. These events are handled in the `apply` blocks in the above example (you see what I did there?) This part is, mostly, really simple. You probably did a lot of this state thing in your freshman programming class. More on that later.
53 |
54 | So if we've got the events defined, and we know what events change state in which ways, where do they come from? Commands, of course. The role of a command is to validate its inputs and publish the events if the command is valid. That's it. No changing state allowed there—seriously, none. Ever heard the term [snowflake server](http://martinfowler.com/bliki/SnowflakeServer.html)? Break the state rule and you're going to have snowflake instances and weird bugs.
55 |
56 | Commands are the art and science of CQRS. In the above example, I've implemented it as a method on the domain object (which is also called an aggregate root in the language of DDD.) Its just as frequently done as a class, e.g.
57 |
58 | class ReplayExample::DoSomething
59 | include Replay::Publisher
60 |
61 | def initialize(name, pid=nil)
62 | raise InvalidCommand.new unless pid
63 | @name = name
64 | @pid = pid
65 | end
66 |
67 | def perform
68 | #the publish the event, but don't raise an error if an application block can't be found
69 | publish ReplayExample::SomethingHappened.new(name: @name, pid: @pid), false
70 | end
71 | end
72 |
73 | ReplayExample::DoSomething.new("foo", 123).perform
74 |
75 | The above command class performs the same function, but has some advantages. In a Rails application, you can mix in ActiveModel::Validations to get ActiveRecord-style validators on it. You can also use Virtus (recommended) or ActiveModel to make it ActiveModel compliant and use it as a form object. This pattern is especially useful when you're dealing with non-domain services (e.g. credit card processors.) You can publish events from any model; there's nothing special about that (though its best if you don't do it without good reason, or you'll subvert one of the great advantages of DDD—separation of bounded contexts).
76 |
77 | ## Digging deeper
78 |
79 | ### The Repository
80 | The Repository is an application-defined object (replay will generate one for you) which will load your domain objects from storage. The repository's job is to find the event stream requested and apply the events from the event stream to a newly created object of the supplied type. Every application has at least one repository, and may have several.
81 |
82 | Use it like so:
83 |
84 | example = Repository.load(ReplayExample, some_guid)
85 |
86 | What you'll get back is a newly initialized instance of your object, with all events from the stream applied in sequence. By default, if it doesn't find any events for that stream identifier, it will raise an exception; you can change this behavior by supplying `:create => false` or `:create => true` to `load`. When false, the Repository will not attempt to create the instance. If true, and the object defines a `create` method that takes no parameters, the default implementation will call `create`. (Its standard practice for that method to publish a `Created` event.)
87 |
88 | Your application's repository will look something like this:
89 |
90 | class Repository
91 | include Replay::Repository
92 |
93 | configure do |config|
94 | config.store = :active_record
95 | config.add_default_subscriber EventLogger
96 | end
97 | end
98 |
99 | You can also create a repository for your test environment (though for unit tests its typically unnecessary and for higher levels adding a subscriber will suffice. For example, in Cucumber or its analogues:
100 |
101 | #features/env.rb
102 | Repository.configuration.add_default_listener EventMonitor.new
103 |
104 | ### Observers
105 | Replay provides a default message router for observers of events.
106 |
107 | In your repository implementation, add :replay_router to the configuration's default subscribers:
108 |
109 | class Repository
110 | include Replay::Repository
111 |
112 | configure do |config|
113 | config.add_default_subscriber :replay_router
114 | end
115 | end
116 |
117 | In your application or domain services:
118 |
119 | class MailService
120 | include Replay::Observer
121 |
122 | observe Model::EventHappened do |event|
123 | #handle the event
124 | end
125 | end
126 |
127 | It may be advantageous in some situations to create multiple routers:
128 |
129 | class InternalRouter
130 | include Replay::Router
131 | end
132 |
133 | class Repository
134 | include Replay::Repository
135 |
136 | configure do |config|
137 | config.add_default_subscriber InternalRouter
138 | end
139 | end
140 |
141 | class MailService
142 | include Replay::Observer
143 | router InternalRouter
144 |
145 | #observations...
146 | end
147 |
148 | ## Additional gems
149 |
150 | [replay-rails](http://github.com/karmajunkie/replay-rails) provides a very basic ActiveRecord-based event store. Its a good template for building your own event store and light duties in an application in which aggregates don't receive hundreds or thousands of events.
151 |
152 |
153 | ## TODO
154 | * Implement snapshots for efficient load from repository
155 | * Better documentation
156 | * Build a demonstration app
157 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require 'rake/testtask'
3 |
4 | task :prove_all do
5 | Bundler.setup
6 | Bundler.require :default
7 | require_relative "proofs/all"
8 | end
9 | Rake::TestTask.new do |t|
10 | t.libs.push "lib"
11 | t.libs.push "test"
12 | t.test_files = FileList['test/**/*_spec.rb']
13 | t.verbose = true
14 | end
15 |
16 | task :default => [:test, :prove_all]
17 |
--------------------------------------------------------------------------------
/lib/replay.rb:
--------------------------------------------------------------------------------
1 | require 'virtus'
2 |
3 | module Replay
4 | def self.logger=(logger)
5 | @logger = logger
6 | end
7 |
8 | def self.logger
9 | @logger
10 | end
11 |
12 | class ReplayError < StandardError; end
13 | class UndefinedKeyError < ReplayError; end
14 | class UnhandledEventError < ReplayError; end
15 | class UnknownEventError < ReplayError; end
16 | class InvalidRouterError < ReplayError; end
17 | class InvalidStorageError < ReplayError;
18 | def initialize(*args)
19 | klass = args.shift
20 | super( "Storage #{klass.to_s} does not implement #event_stream(stream, event)", *args)
21 | end
22 | end
23 | class InvalidSubscriberError < ReplayError;
24 | def initialize(*args)
25 | obj = args.shift
26 | super( "Subscriber#{obj.to_s} does not implement #published(stream, event)", *args)
27 | end
28 | end
29 | end
30 |
31 | require 'replay/inflector'
32 | require 'replay/events'
33 | require 'replay/event_decorator'
34 | require 'replay/event_declarations'
35 | require 'replay/event_envelope'
36 | require 'replay/publisher'
37 | require 'replay/subscription_manager'
38 | require 'replay/subscriptions'
39 | require 'replay/backends'
40 | require 'replay/repository'
41 | require 'replay/repository/identity_map'
42 | require 'replay/repository/configuration'
43 | require 'replay/observer'
44 | require 'replay/router'
45 | require 'replay/router/default_router'
46 |
47 |
--------------------------------------------------------------------------------
/lib/replay/backends.rb:
--------------------------------------------------------------------------------
1 | require 'singleton'
2 | module Replay
3 | class Backends
4 | def self.register(shorthand, klass)
5 | @backends ||= {}
6 | @backends[shorthand] = klass
7 | return klass
8 | end
9 | def self.resolve(shorthand)
10 | @backends[shorthand] || shorthand
11 | end
12 |
13 | class MemoryStore
14 | include Singleton
15 | def initialize
16 | @store = {}
17 | end
18 | def self.published(envelope)
19 | instance.published(envelope)
20 | end
21 |
22 | def self.clear
23 | instance.clear
24 | end
25 |
26 | def clear
27 | @store = {}
28 | end
29 |
30 | def published(envelope)
31 | @store[envelope.stream_id] ||= []
32 | @store[envelope.stream_id] << envelope
33 | end
34 |
35 | def event_stream(stream_id)
36 | @store[stream_id] || []
37 | end
38 |
39 | def self.event_stream(stream_id)
40 | instance.event_stream(stream_id)
41 | end
42 | def self.[](stream_id)
43 | instance.event_stream(stream_id)
44 | end
45 | end
46 | register :memory, MemoryStore
47 | end
48 | end
49 |
50 |
51 |
--------------------------------------------------------------------------------
/lib/replay/configuration.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | class Configuration
3 | attr_accessor :storage
4 | attr_writer :reject_load_on_empty_stream
5 |
6 | def storage=(stores)
7 | stores = [stores] unless stores.is_a?(Array)
8 | @storage = stores
9 | end
10 |
11 | def reject_load_on_empty_stream?
12 | @reject_load_on_empty_stream ||= true
13 | @reject_load_on_empty_stream
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/replay/event_declarations.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | module EventDeclarations
3 | def self.included(base)
4 | base.extend(Replay::Events)
5 | end
6 |
7 | def included(base)
8 | self.constants.each do |c|
9 | base.const_set(c, const_get(c).dup)
10 | klass = base.const_get(c)
11 | base.class_eval do
12 | define_method c do |props|
13 | klass.new props
14 | end
15 | end
16 | end
17 | end
18 |
19 | def method_missing(name, *args)
20 | declare_event(self, name, args.first)
21 | end
22 |
23 | def declare_event(base, name, props)
24 | klass = Class.new do
25 | include Replay::EventDecorator
26 | values do
27 | props.keys.each do |prop|
28 | attribute prop, props[prop]
29 | end
30 | end
31 | end
32 | base.const_set name, klass
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/replay/event_decorator.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | #hook class to apply global decorators to events
3 | module EventDecorator
4 | def self.included(base)
5 | base.class_eval do
6 | include Virtus.value_object
7 | attr_accessor :metadata
8 | def inspect
9 | "#{self.type}: #{self.attributes.map{|k, v| "#{k.to_s} = #{v.to_s}"}.join(", ")}"
10 | end
11 | def type
12 | self.class.to_s
13 | end
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/replay/event_envelope.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | class EventEnvelope
3 | attr_reader :stream_id, :event, :metadata
4 | def initialize(stream_id, event, metadata = {})
5 | @metadata = metadata
6 | @event = event
7 | @stream_id = stream_id
8 | end
9 |
10 | def type
11 | @event.type
12 | end
13 |
14 | def explode
15 | return @stream_id, @event, @metadata
16 | end
17 |
18 | def method_missing(method, *args)
19 | return @event.send(method, args) if @event.respond_to?(method)
20 | super
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/replay/events.rb:
--------------------------------------------------------------------------------
1 |
2 | module Replay
3 | module Events
4 | def self.extended(base)
5 | base.extend(ClassMethods)
6 | end
7 | def self.included(base)
8 | base.extend(ClassMethods)
9 | #self.constants.each{|c| base.const_set(c, const_get(c))}
10 | end
11 | module ClassMethods
12 | def events(mod = nil, &block)
13 | unless mod
14 | mod = Module.new do
15 | extend Replay::EventDeclarations
16 | module_eval &block
17 | end
18 | self.const_set(:Events, mod)
19 | end
20 | include mod
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/replay/inflector.rb:
--------------------------------------------------------------------------------
1 | #This class substantially copied from ActiveSupport, licensed under MIT
2 | #Their version is generally better, so use that unless you've got a good reason
3 | #not to do so. also, i've changed certain aspects of behavior.
4 | class Replay::Inflector
5 |
6 | # By default, +camelize+ converts strings to UpperCamelCase. If the argument
7 | # to +camelize+ is set to :lower then +camelize+ produces
8 | # lowerCamelCase.
9 | #
10 | # +camelize+ will also convert '/' to '::' which is useful for converting
11 | # paths to namespaces.
12 | #
13 | # 'active_model'.camelize # => "ActiveModel"
14 | # 'active_model'.camelize(:lower) # => "activeModel"
15 | # 'active_model/errors'.camelize # => "ActiveModel::Errors"
16 | # 'active_model/errors'.camelize(:lower) # => "activeModel::Errors"
17 | #
18 | # As a rule of thumb you can think of +camelize+ as the inverse of
19 | # +underscore+, though there are cases where that does not hold:
20 | #
21 | # 'SSLError'.underscore.camelize # => "SslError"
22 | def self.camelize(term, uppercase_first_letter = false)
23 | string = term.to_s
24 | string = string.sub(/^[A-Z_]/) { $&.downcase }
25 | string.gsub!(/(?:_|(\/))([a-z\d]*)/i) { "#{$1}#{$2.capitalize}" }
26 | string.gsub!('/', '::')
27 | string.gsub!('.', '::')
28 | string
29 | end
30 | # Makes an underscored, lowercase form from the expression in the string.
31 | #
32 | # Changes '::' to '/' to convert namespaces to paths.
33 | #
34 | # 'ActiveModel'.underscore # => "active_model"
35 | # 'ActiveModel::Errors'.underscore # => "active_model/errors"
36 | #
37 | # As a rule of thumb you can think of +underscore+ as the inverse of
38 | # +camelize+, though there are cases where that does not hold:
39 | #
40 | # 'SSLError'.underscore.camelize # => "SslError"
41 | def self.underscore(camel_cased_word)
42 | return camel_cased_word unless camel_cased_word =~ /[A-Z-]|::/
43 | word = camel_cased_word.to_s.gsub('::', '.')
44 | word.gsub!(/(?:([A-Za-z\d])^)(?=\b|[^a-z])/) { "#{$1}#{$2 && '_'}#{$2.downcase}" }
45 | word.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2')
46 | word.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
47 | word.tr!("-", "_")
48 | word.downcase!
49 | word
50 | end
51 |
52 | def self.constantize(class_name)
53 | class_name.to_s.split("::").inject(Kernel){|parent, mod| parent.const_get(mod)}
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/replay/observer.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | module Observer
3 |
4 | def self.included(base)
5 | base.extend(ClassMethods)
6 | base.instance_variable_set(:@router, Replay::Router::DefaultRouter)
7 | end
8 |
9 | module ClassMethods
10 | def router(rtr)
11 | raise Replay::InvalidRouterError.new("Router does not implement add_observer") unless rtr.respond_to?(:add_observer)
12 | @router = rtr
13 | end
14 |
15 | #gives the observer a chance to take itself down to a null state
16 | #in the event of a catchup
17 | #must be overridden in the base class
18 | def reset!
19 | raise "reset must be implemented in the observing class"
20 | end
21 |
22 | def observe(event_type, &block)
23 | raise InvalidRouterError.new("No router defined!") unless @router
24 | @observed_events ||= Set.new
25 | @observed_events.add(event_type)
26 |
27 | @observer_blocks ||= Hash.new
28 | @observer_blocks[Replay::Inflector.underscore(event_type.to_s)] = block
29 |
30 | @router.add_observer self, event_type
31 | end
32 |
33 | def observed_events
34 | @observed_events.dup
35 | end
36 |
37 | def handle(envelope)
38 | published(envelope)
39 | end
40 |
41 | def published(envelope)
42 | blk = @observer_blocks[Replay::Inflector.underscore(envelope.type)]
43 | blk.call(envelope, binding) if blk
44 | end
45 |
46 | private
47 | def handler_method(event_type)
48 | "handle_#{Replay::Inflector.underscore(event_type.to_s).gsub(".", "_")}"
49 | end
50 |
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/replay/publisher.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | module Publisher
3 | def self.included(base)
4 | base.instance_variable_set :@application_blocks, {}
5 | base.extend ClassMethods
6 | base.extend(Replay::Events)
7 | base.class_exec do
8 | include Replay::Subscriptions
9 | end
10 | end
11 |
12 | def apply(events, raise_unhandled = true)
13 | return apply([events], raise_unhandled) unless events.is_a?(Array)
14 |
15 | events.each do |event|
16 | apply_method = apply_method_for(event.class)
17 | if self.respond_to?(apply_method)
18 | self.send(apply_method, event)
19 | end
20 | end
21 | return self
22 | end
23 |
24 | def apply_method_for(klass)
25 | self.class.apply_method_for(klass)
26 | end
27 |
28 | private :apply_method_for
29 |
30 | def publish(event, metadata={})
31 | return publish([event]) unless event.is_a?(Array)
32 | event.each do |evt|
33 | metadata = ({:published_at => Time.now}.merge!(metadata))
34 | apply(evt)
35 | subscription_manager.notify_subscribers(to_stream_id, evt, metadata)
36 | end
37 | return self
38 | end
39 |
40 | def to_stream_id
41 | raise Replay::UndefinedKeyError.new("No key attribute defined for #{self.type}") unless self.key_attr
42 | self.send(self.key_attr).to_s
43 | end
44 |
45 | def key_attr
46 | self.class.key_attr
47 | end
48 |
49 | module ClassMethods
50 | def key(keysym)
51 | @primary_key_method = keysym
52 | end
53 |
54 | def key_attr
55 | @primary_key_method
56 | end
57 |
58 | def apply(event_type, &block)
59 | method_name = apply_method_for(event_type)
60 | define_method method_name, block
61 | end
62 |
63 | def stringify_class(klass)
64 | Replay::Inflector.underscore(klass.to_s.dup)
65 | end
66 |
67 | def apply_method_for(klass)
68 | "handle_#{stringify_class(klass).gsub(".", "_")}"
69 | end
70 | end
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/lib/replay/repository.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | module Repository
3 | def self.included(base)
4 | base.extend(ClassMethods)
5 | end
6 |
7 | module ClassMethods
8 | def configuration
9 | @configuration ||= Configuration.new
10 | end
11 |
12 | # load will always return an initialized instance of the supplied class (unless it doesn't!). if the given
13 | # stream has no events (e.g. is not found, new object, etc), load will attempt to call
14 | # create on the newly initalized instance of klass
15 | #
16 | #options:
17 | # :create => true #if false, do not call create on this instance if no stream is found
18 | def load(klass, stream_id, options={})
19 | repository_load(klass, stream_id, options)
20 | end
21 |
22 | def repository_load(klass_or_instance, stream_id, options={})
23 | stream = store.event_stream(stream_id)
24 | if stream.empty? && configuration.reject_load_on_empty_stream?
25 | raise Errors::EventStreamNotFoundError.new("Could not find any events for stream identifier #{stream_id}") if options[:create].nil?
26 | end
27 |
28 | obj = klass_or_instance.is_a?(Class) ? prepare(klass_or_instance.new, options[:metadata]) : klass_or_instance
29 | obj.create(stream_id) if options[:create] && stream.empty?
30 | obj.apply(stream.map(&:event))
31 |
32 | obj
33 | end
34 |
35 | #refresh reloads the object from the data store
36 | #naive implementation is just a reload. Once deltas are in place
37 | #it can just apply the delta events to the object
38 | def self.refresh(obj)
39 | new_obj = load(obj.class, obj.to_key)
40 | new_obj
41 | end
42 |
43 | def prepare(obj, metadata={})
44 | obj.subscription_manager = SubscriptionManager.new(configuration.logger, metadata || {})
45 | @configuration.subscribers.each do |subscriber|
46 | obj.add_subscriber(subscriber)
47 | end
48 | obj
49 | end
50 |
51 | def configure
52 | @configuration ||= Configuration.default
53 | yield @configuration
54 | @configuration
55 | end
56 |
57 | def store
58 | @configuration.store
59 | end
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/lib/replay/repository/configuration.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | module Repository
3 | class Configuration
4 | attr_accessor :logger
5 | attr_writer :reject_load_on_empty_stream
6 | def initialize(logger = nil)
7 | @default_subscribers =[]
8 | @logger = logger
9 | end
10 |
11 | def self.default
12 | self.new(Replay.logger)
13 | end
14 |
15 | def add_default_subscriber(subscriber)
16 | subscriber = Replay::Backends.resolve(subscriber) if subscriber.is_a?(String) || subscriber.is_a?(Symbol)
17 | @default_subscribers << subscriber
18 | end
19 |
20 | def subscribers
21 | @default_subscribers
22 | end
23 |
24 | def store=(store)
25 | store = Replay::Backends.resolve(store)
26 | raise Replay::InvalidStorageError.new(store) unless store.respond_to?(:event_stream)
27 | raise Replay::InvalidSubscriberError.new(store) unless store.respond_to?(:published)
28 | @store = store
29 | add_default_subscriber(@store)
30 | end
31 |
32 | def reject_load_on_empty_stream?
33 | @reject_load_on_empty_stream ||= true
34 | @reject_load_on_empty_stream
35 | end
36 |
37 | def store
38 | @store
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/replay/repository/identity_map.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | module Repository
3 | module IdentityMap
4 | def self.included(base)
5 | base.extend(ClassMethods)
6 | end
7 | module ClassMethods
8 | def load(klass, stream_id, options={})
9 | #implement an identity map
10 | @_identities ||= {}
11 | return @_identities[[klass,stream_id]] if @_identities[[klass, stream_id]]
12 |
13 | obj=repository_load(klass, stream_id, options)
14 | @_identities[[klass, stream_id]] = obj
15 |
16 | obj
17 | end
18 |
19 | def clear_identity_map
20 | @_identities = {}
21 | end
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/replay/router.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | module Router
3 | def self.included(base)
4 | base.class_exec do
5 | include Replay::Subscriptions
6 | extend ClassMethods if base.include?(Singleton)
7 | end
8 | end
9 |
10 | def add_observer(observer, *events)
11 | add_subscriber observer
12 | end
13 |
14 | module ClassMethods
15 | def add_observer(observer, *events)
16 | instance.add_subscriber(observer)
17 | end
18 |
19 | def published(envelope)
20 | instance.published( envelope)
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/replay/router/default_router.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | module Router
3 | class DefaultRouter
4 | include Singleton
5 | include Replay::Router
6 | end
7 | end
8 | end
9 |
10 | Replay::Backends.register(:replay_router, Replay::Router::DefaultRouter)
11 |
--------------------------------------------------------------------------------
/lib/replay/rspec.rb:
--------------------------------------------------------------------------------
1 | require 'replay/test'
2 |
3 | RSpec::Matchers.define :publish do |expected_event|
4 | match do |proc_or_obj|
5 | if proc_or_obj.respond_to? :call
6 | @result = proc_or_obj.call
7 | @result.published?(expected_event, @fuzzy)
8 | else
9 | proc_or_obj.published?(expected_event, @fuzzy)
10 | end
11 | end
12 |
13 | chain :fuzzy do
14 | @fuzzy = true
15 | end
16 |
17 | def failure_message(expected_event, actual, should = true)
18 | actual = @result if actual.is_a? Proc
19 |
20 | str = "expected that #{domain_obj_interpretation(actual)} would#{should ? ' ': ' not'} generate #{@fuzzy ? 'an event like' : 'the event'} #{event_interpretation(expected_event)}"
21 | similar = actual.similar_events(expected_event)
22 | if similar.empty?
23 | str += "\nNo similar events found."
24 | else
25 | str += "\nThe following events matched type, but not attributes:\n#{similar.map{|s| event_interpretation(s)+"\n"}.join("\t\t")}"
26 | end
27 | end
28 | failure_message_for_should_not do |actual|
29 | failure_message(expected_event, actual, false)
30 | end
31 |
32 | failure_message_for_should do |actual|
33 | failure_message(expected_event, actual )
34 | end
35 |
36 | def domain_obj_interpretation(obj)
37 | if obj.respond_to?(:call) && obj.kind_of?(Proc)
38 | "block"
39 | else
40 | obj.class.to_s
41 | end
42 |
43 | end
44 |
45 | def event_interpretation(event)
46 | "#{event.type} [#{event.attributes.reject{|k,v| v.nil?}.keys.map{|k| "#{k.to_s} = #{event.attributes[k]}"}.join(", ")}]"
47 | end
48 |
49 | end
50 |
51 | RSpec::Matchers.define :observes do |event_class|
52 | match do |observer|
53 | observer.observes?(event_class)
54 | end
55 | end
56 |
57 |
--------------------------------------------------------------------------------
/lib/replay/subscription_manager.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | class SubscriptionManager
3 | def initialize(logger = nil, session_metadata = {})
4 | @subscribers = []
5 | @logger = logger
6 | @session_metadata = session_metadata
7 | end
8 |
9 | def add_subscriber(subscriber)
10 | if subscriber.respond_to?(:published)
11 | @subscribers << subscriber
12 | else
13 | raise Replay::InvalidSubscriberError.new(subscriber)
14 | end
15 | end
16 |
17 | def notify_subscribers(stream_id, event, metadata = {})
18 | @subscribers.each do |sub|
19 | begin
20 | meta = metadata.merge(@session_metadata || {})
21 | sub.published(EventEnvelope.new(stream_id, event.dup, meta))
22 | #sub.published(stream_id, event, metadata)
23 | rescue Exception => e
24 | #hmmmm
25 | if @logger
26 | @logger.error "exception in event subscriber #{sub.class.to_s} while handling event stream #{stream_id} #{event.inspect}: #{e.message}\n#{e.backtrace.join("\n")}"
27 | else
28 | raise e
29 | end
30 | end
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/replay/subscriptions.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | module Subscriptions
3 | def subscription_manager
4 | @subscription_manager ||= Replay::SubscriptionManager.new(Replay.logger)
5 | end
6 |
7 | def subscription_manager=(sm)
8 | @subscription_manager = sm
9 | end
10 |
11 | def add_subscriber(subscriber)
12 | subscription_manager.add_subscriber(subscriber)
13 | end
14 |
15 | def published(envelope)
16 | subscription_manager.notify_subscribers(*(envelope.explode))
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/replay/test.rb:
--------------------------------------------------------------------------------
1 | require 'replay'
2 |
3 | module Replay::EventExaminer
4 | def events
5 | @_events ||= []
6 | end
7 |
8 | def published?(event, fuzzy=false)
9 | if fuzzy
10 | !(events.detect{|e| event.kind_of_matches?(e) }.nil?)
11 | else
12 | events.detect{|e| event.is_a?(Class) ? e.class == event : e == event}
13 | end
14 | end
15 |
16 | def similar_events(event)
17 | events.select{|e| e.class == event.class}
18 | end
19 |
20 | def apply(events, raise_unhandled = true)
21 | return apply([events], raise_unhandled) unless events.is_a?(Array)
22 | retval = super(events, raise_unhandled)
23 | events.each do |event|
24 | self.events << event
25 | end
26 | return retval
27 | end
28 |
29 | def has_subscriber?(subscriber)
30 | @subscription_manager.has_subscriber?(subscriber)
31 | end
32 | end
33 |
34 | Replay::Observer.module_exec do
35 | def observes?(event_class)
36 | @observer_blocks.has_key?(Replay::Inflector.underscore(event_class.to_s))
37 | end
38 | end
39 |
40 | Replay::SubscriptionManager.class_exec do
41 | def has_subscriber?(subscriber)
42 | @subscribers.include?(subscriber)
43 | end
44 | end
45 |
46 | Replay::Router.module_exec do
47 | def inspect
48 | self.class.to_s
49 | end
50 |
51 | def observed_by?(object)
52 | @subscription_manager.has_subscriber?(object)
53 | end
54 | end
55 |
56 | Replay::Publisher::ClassMethods.module_exec do
57 | def self.extended(base)
58 | @publishers ||= []
59 | @publishers << base
60 | base.send(:include, Replay::EventExaminer)
61 | end
62 | end
63 |
64 | Replay::EventDecorator.module_exec do
65 | #receiver's non-nil values are a subset of parameters non-nil values
66 | def kind_of_matches?(event)
67 | relevant_attrs_match = event.attributes.reject{|k,v| v.nil?}
68 | relevant_attrs_self = self.attributes.reject{|k,v| v.nil?}
69 |
70 | keys_self = relevant_attrs_self.keys
71 | if (relevant_attrs_self.keys - relevant_attrs_match.keys).empty?
72 | #publication time is not considered part of the event data
73 | if keys_self.reject{|k| event[k] == self[k]}.any?
74 | return false
75 | else
76 | return true
77 | end
78 | else
79 | return false
80 | end
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/lib/replay/test/test_event_stream.rb:
--------------------------------------------------------------------------------
1 | require 'replay/test'
2 | module Replay
3 | class TestEventStream
4 | include EventExaminer
5 | attr_accessor :events
6 |
7 | def initialize
8 | @events = []
9 | end
10 | def published(event_envelope)
11 | @events << event_envelope
12 | end
13 |
14 | def published_event?(event)
15 | @events.detect{|e| e.event==event}
16 | end
17 |
18 | def published?(stream_id, event)
19 | @events.detect{|e| e.stream_id == stream_id && e.event == event}
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/replay/version.rb:
--------------------------------------------------------------------------------
1 | module Replay
2 | VERSION = "0.2.6"
3 | end
4 |
--------------------------------------------------------------------------------
/proofs/all.rb:
--------------------------------------------------------------------------------
1 | require_relative 'proofs_init.rb'
2 |
3 | files = Dir.glob(File.join(File.dirname(__FILE__), '**/*_proof.rb'))
4 | puts files
5 | Proof::Suite.run "replay/**/*.rb"
6 |
7 |
8 |
--------------------------------------------------------------------------------
/proofs/proofs_init.rb:
--------------------------------------------------------------------------------
1 | $:< "boo"))
27 | ThingObserver.foo == "boo"
28 | end
29 | end
30 |
31 | proof "observers have observed events" do
32 | ThingObserver.prove{ ThingObserver.observed_events.include?(ThingEvent)}
33 | end
34 |
35 |
36 |
--------------------------------------------------------------------------------
/proofs/replay/publisher_proof.rb:
--------------------------------------------------------------------------------
1 | require_relative "../proofs_init.rb"
2 | require 'replay/test'
3 | require 'replay/test/test_event_stream'
4 |
5 | class ReplayTest
6 | include Replay::Publisher
7 | include Replay::EventExaminer
8 |
9 | key :pkey
10 |
11 | def initialize(pkey = 1)
12 | @pkey = pkey
13 | end
14 |
15 | events do
16 | SomeEvent(pid: Integer)
17 | UnhandledEvent(pid: Integer)
18 | end
19 |
20 | apply SomeEvent do |event|
21 | @event_count ||= 0
22 | @event_applied = event.pid
23 | @event_count += 1
24 | end
25 |
26 | def pkey
27 | @pkey
28 | end
29 | end
30 |
31 | module ReplayTest::Proof
32 | def sets_publish_time
33 | ts=Replay::TestEventStream.new
34 | add_subscriber(ts)
35 | publish SomeEvent(pid: 123)
36 | ts.events.last.metadata[:published_at] != nil && (Time.now - ts.events.last.metadata[:published_at]) < 1
37 | end
38 |
39 | def published_at_not_considered_in_equality
40 | event = SomeEvent(pid: 123)
41 | event == event.with(:published_at => Time.now-100)
42 | end
43 |
44 | def defines_events?
45 | self.class.const_defined?(:SomeEvent) && self.class.const_get(:SomeEvent).is_a?(Class)
46 | end
47 | def adds_convenience_method?
48 | respond_to? :SomeEvent
49 | end
50 | def applies_event?(event)
51 | apply(event)
52 | @event_applied == event.pid
53 | end
54 |
55 | def applies_events?(events)
56 | apply(events)
57 | @event_count == events.count
58 | end
59 |
60 | def throws_unhandled_event?(raise_it = true)
61 | begin
62 | apply(UnhandledEvent(pid: 123), raise_it)
63 | rescue Replay::UnhandledEventError => e
64 | true && raise_it
65 | rescue Exception
66 | !raise_it || false
67 | end
68 | end
69 |
70 | def can_publish_events?
71 | event = SomeEvent(pid: 123)
72 | publish(event)
73 | events.detect{|e| e==event}
74 | end
75 |
76 | def subscribers_receive_events
77 | sub = Class.new do
78 | def published(envelope)
79 | @published = true
80 | end
81 | def published?; @published; end
82 | end.new
83 | add_subscriber(sub)
84 | publish(ReplayTest::SomeEvent.new(pid: 123))
85 | sub.published?
86 | end
87 | def subscribes()
88 | sub = Class.new do
89 | def published(stream, event)
90 | @published = true
91 | end
92 | def published?; @published; end
93 | end.new
94 | add_subscriber(sub)
95 | has_subscriber?(sub)
96 | end
97 | end
98 |
99 | title "Publisher"
100 |
101 | proof "Defines events given in the events block" do
102 | r = ReplayTest.new
103 | r.prove{ defines_events? }
104 | end
105 |
106 | proof "Adds a convenience method for an event constructor to the class" do
107 | r = ReplayTest.new
108 | r.prove{ adds_convenience_method? }
109 | end
110 |
111 | proof "Applies events singly" do
112 | r = ReplayTest.new
113 | r.prove{ applies_event? ReplayTest::SomeEvent.new(:pid => 123)}
114 | end
115 |
116 | proof "Applies events in ordered batches" do
117 | r = ReplayTest.new
118 | r.prove do applies_events?([
119 | ReplayTest::SomeEvent.new(:pid => 123),
120 | ReplayTest::SomeEvent.new(:pid => 234),
121 | ReplayTest::SomeEvent.new(:pid => 456)
122 | ])
123 | end
124 | end
125 |
126 | proof "Throws an UnhandledEventError for unhandled events" do
127 | r = ReplayTest.new
128 | r.prove{ throws_unhandled_event? }
129 | end
130 |
131 | proof "Ignores unhandled events if requested" do
132 | r = ReplayTest.new
133 | r.prove{ throws_unhandled_event? false}
134 | end
135 |
136 | proof "Can publish events to the indicated stream" do
137 | r = ReplayTest.new
138 | r.prove { can_publish_events? }
139 | end
140 |
141 | proof "Subscriber can subscribe to events from publisher" do
142 | r = ReplayTest.new
143 | r.prove{ subscribes }
144 | end
145 |
146 | proof "Subscriber receives published events" do
147 | r = ReplayTest.new
148 | r.prove{ subscribers_receive_events }
149 | end
150 |
151 | proof "Returns self from apply" do
152 | r = ReplayTest.new
153 | r.prove{ apply([]) == self}
154 | end
155 | proof "Returns self from publish" do
156 | r = ReplayTest.new
157 | r.prove{ publish([]) == self}
158 | end
159 |
160 | proof "adds the publish time to event metadata" do
161 | r = ReplayTest.new
162 | r.prove{ sets_publish_time }
163 | end
164 |
165 | proof "publish time is not part of equality" do
166 | r = ReplayTest.new
167 | r.prove{ published_at_not_considered_in_equality}
168 | end
169 |
170 | proof "Can implement initializer with arguments" do
171 | r = ReplayTest.new(:foo)
172 | r.prove { pkey == :foo }
173 | end
174 |
--------------------------------------------------------------------------------
/proofs/replay/repository_configuration_proof.rb:
--------------------------------------------------------------------------------
1 | require_relative "../proofs_init.rb"
2 | require 'replay/test'
3 |
4 | class Subscriber
5 | def published(envelope); end
6 | end
7 |
8 | title "Repository configuration"
9 |
10 | module Replay::Repository::Configuration::Proof
11 | def can_configure_store?
12 | self.store = :memory
13 | self.store == Replay::Backends::MemoryStore
14 | end
15 |
16 | def can_add_default_subscriber?
17 | sub = Subscriber.new
18 | self.add_default_subscriber sub
19 | subscribers.include? sub
20 | end
21 |
22 | def subscribers_include_store
23 | self.store = :memory
24 | self.subscribers.include? Replay::Backends::MemoryStore
25 | end
26 |
27 | def requires_store_to_act_like_subscriber
28 | begin
29 | store = Class.new do
30 | def self.event_stream(stream); end
31 | end
32 | self.store = store
33 | rescue Replay::InvalidSubscriberError => e
34 | return true
35 | rescue Exception => e
36 | end
37 | false
38 | end
39 |
40 | def requires_store_to_load_events
41 | begin
42 | self.store = Subscriber.new
43 | rescue Replay::InvalidStorageError
44 | return true
45 | rescue Exception => e
46 | end
47 | false
48 | end
49 | end
50 |
51 | proof "can configure a store" do
52 | Replay::Repository::Configuration.new.prove {can_configure_store?}
53 | end
54 |
55 | proof 'adding a store adds it as a subscriber' do
56 | Replay::Repository::Configuration.new.prove { subscribers_include_store }
57 | end
58 |
59 | proof "can configure a default_subscriber" do
60 | Replay::Repository::Configuration.new.prove {can_add_default_subscriber?}
61 | end
62 |
63 | proof "raises error if store won't load events" do
64 | Replay::Repository::Configuration.new.prove {requires_store_to_load_events }
65 | end
66 |
67 | proof "raises error if store won't act like a subscriber" do
68 | Replay::Repository::Configuration.new.prove {requires_store_to_act_like_subscriber }
69 | end
70 |
--------------------------------------------------------------------------------
/proofs/replay/repository_proof.rb:
--------------------------------------------------------------------------------
1 | require_relative "../proofs_init.rb"
2 | require 'replay/test'
3 |
4 | class Subscriber
5 | def published(stream_id, event); end
6 | end
7 |
8 | class RepositoryTest
9 | include Replay::Repository
10 | include Singleton
11 |
12 | class << self
13 | attr_accessor :proven
14 | end
15 |
16 | def self.can_be_configured
17 | self.configure do |config|
18 | self.proven = true
19 | end
20 |
21 | self.proven
22 | end
23 | end
24 |
25 | title "Repository interface"
26 |
27 | proof "repository can be configured" do
28 | RepositoryTest.prove {can_be_configured }
29 | end
30 |
31 | Proof::Output.class_exec do
32 | writer :pending, :level => :info do |text|
33 | prefix :pending, "PENDING: #{text}"
34 | end
35 | end
36 |
37 | def pending(text = "pending test")
38 | Proof::Output.pending "pending test"
39 | end
40 | proof "loads an instance of supplied class" do; pending; end
41 | proof "load raises an error if event stream isn't found" do; pending; end
42 | proof "load returns uncreated instance when :create is pending" do; false; end
43 | proof "load returns a created instance when :create is true" do; pending; end
44 |
45 | proof "reload returns the supplied instance in its current state" do; pending; end
46 |
47 |
--------------------------------------------------------------------------------
/proofs/replay/subscriber_manager_proof.rb:
--------------------------------------------------------------------------------
1 | require_relative "../proofs_init.rb"
2 | require 'replay/test'
3 |
4 | module Replay::SubscriptionManager::Proof
5 | def only_adds_legit_subs
6 | begin
7 | add_subscriber(Object.new)
8 | rescue Replay::InvalidSubscriberError => e
9 | return true
10 | end
11 | false
12 | end
13 |
14 | def sub_gets_notified
15 | sub = Class.new do
16 | attr_accessor :stream, :event
17 | def published(envelope)
18 | self.stream = envelope.stream_id
19 | self.event = envelope.event
20 | end
21 | end.new
22 | add_subscriber(sub)
23 | notify_subscribers "123", "456"
24 | sub.stream == '123' && sub.event == '456'
25 | end
26 | end
27 |
28 | title "Subscription Manager"
29 |
30 | proof "raises InvalidSubscriberError when subscriber fails to implement #published" do
31 | sm = Replay::SubscriptionManager.new
32 | sm.prove { only_adds_legit_subs }
33 | end
34 |
35 | proof "notifies proper subscriber when informed" do
36 | sm = Replay::SubscriptionManager.new
37 | sm.prove { sub_gets_notified }
38 | end
39 |
40 |
--------------------------------------------------------------------------------
/proofs/replay/test_proof.rb:
--------------------------------------------------------------------------------
1 | require_relative "../proofs_init.rb"
2 | require 'replay/test'
3 |
4 | desc "proof for the test support provided by replay"
5 |
6 | title "Test support"
7 |
8 | proof "fuzzy matching of events" do
9 | class TestEvent
10 | include Replay::EventDecorator
11 |
12 | values do
13 | attribute :one, String
14 | attribute :two, String
15 | end
16 |
17 | def matches_fuzzy(event)
18 | self.kind_of_matches?(event)
19 | end
20 | end
21 |
22 | e1 = TestEvent.new(one: '1')
23 | e2 = TestEvent.new(one: '1', two: '2')
24 | e3 = TestEvent.new(one: '1', two: '2')
25 | e4 = TestEvent.new(one: '1', two: '4')
26 |
27 | e1.prove{ matches_fuzzy(e2)}
28 | e2.prove{ !matches_fuzzy(e1)}
29 | e2.prove{ matches_fuzzy(e2)}
30 | e2.prove{ matches_fuzzy(e3)}
31 | e2.prove{ !matches_fuzzy(e4)}
32 | end
33 |
--------------------------------------------------------------------------------
/replay.gemspec:
--------------------------------------------------------------------------------
1 | # -*- encoding: utf-8 -*-
2 | $:.push File.expand_path("../lib", __FILE__)
3 | require "replay/version"
4 |
5 | Gem::Specification.new do |s|
6 | s.name = "replay"
7 | s.version = Replay::VERSION
8 | s.authors = ["karmajunkie"]
9 | s.email = ["keith.gaddis@gmail.com"]
10 | s.homepage = "https://github.com/karmajunkie/replay"
11 | s.summary = %q{Replay supports event-sourced data models.}
12 | s.description = %q{Replay supports event-sourced data models.}
13 |
14 | s.rubyforge_project = "replay"
15 |
16 | s.files = `git ls-files`.split("\n")
17 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19 | s.require_paths = ["lib"]
20 |
21 | # specify any dependencies here; for example:
22 | s.add_development_dependency "bundler", "~>1.3"
23 | s.add_development_dependency "rake"
24 | s.add_development_dependency "minitest"
25 | #s.add_runtime_dependency "rest-client"
26 | s.add_runtime_dependency "virtus", "~>1.0.0"
27 | end
28 |
--------------------------------------------------------------------------------
/test/replay/observer_spec.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class ObservedEvent
4 | end
5 |
6 | class UnobservedEvent
7 | end
8 |
9 | class ObserverTest
10 | include Replay::Observer
11 | observe ObservedEvent do |stream, event|
12 | @observed = true
13 | end
14 |
15 | def self.observed?
16 | @observed
17 | end
18 |
19 | def self.reset
20 | @observed=false
21 | end
22 | end
23 |
24 | class NonstandardRouter
25 | include Singleton
26 | include Replay::Router
27 | end
28 |
29 | class RoutedObserverTest
30 | include Replay::Observer
31 | router NonstandardRouter.instance
32 |
33 | observe ObservedEvent do |e|
34 | end
35 | end
36 |
37 | describe Replay::Observer do
38 | before do
39 | ObserverTest.reset
40 | end
41 | it "calls the observer block for observed events" do
42 | ObserverTest.published('123', ObservedEvent.new)
43 | ObserverTest.must_be :observed?
44 | end
45 |
46 | it "does not notify of unobserved events" do
47 | ObserverTest.published('123', UnobservedEvent.new)
48 | ObserverTest.wont_be :observed?
49 | end
50 |
51 | it "links to DefaultRouter by default" do
52 | Replay::Router::DefaultRouter.instance.must_be :observed_by?,ObserverTest
53 | end
54 |
55 | it "links to a substitute router when instructed" do
56 | NonstandardRouter.instance.must_be :observed_by?, RoutedObserverTest
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/test/replay/router/default_router_spec.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 | class TypedEvent
3 |
4 | end
5 | class Observer
6 | def self.published(stream, event)
7 | @typed=true
8 | end
9 |
10 | def self.typed_received?
11 | @typed
12 | end
13 | end
14 |
15 | describe Replay::Router::DefaultRouter do
16 | before do
17 | @router = Replay::Router::DefaultRouter.instance
18 | end
19 | describe "adding observers" do
20 | it "tracks the observing object" do
21 | @router.add_observer(Observer)
22 | @router.must_be :observed_by?, Observer
23 | end
24 | end
25 | describe "event publishing" do
26 | it "tells the observing object about events being published" do
27 | @router.add_observer(Observer)
28 | @router.published("123", TypedEvent.new)
29 | assert Observer.typed_received?, "Did not receive notification of event"
30 | end
31 | end
32 | end
33 |
34 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | gem 'minitest'
2 |
3 | require 'minitest/autorun'
4 | require 'minitest/spec'
5 | require 'replay'
6 | require 'replay/router'
7 |
8 | require 'replay/test'
9 |
--------------------------------------------------------------------------------