├── .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 | --------------------------------------------------------------------------------