sourced 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'console' #  comes with async gem
4
+ require 'sourced/types'
5
+ require 'sourced/backends/test_backend'
6
+
7
+ module Sourced
8
+ class Configuration
9
+ #  Backends must expose these methods
10
+ BackendInterface = Types::Interface[
11
+ :installed?,
12
+ :reserve_next_for_reactor,
13
+ :append_to_stream,
14
+ :read_correlation_batch,
15
+ :read_event_stream,
16
+ :schedule_commands,
17
+ :next_command,
18
+ :transaction
19
+ ]
20
+
21
+ attr_accessor :logger
22
+ attr_reader :backend
23
+
24
+ def initialize
25
+ @logger = Console
26
+ @backend = Backends::TestBackend.new
27
+ end
28
+
29
+ def backend=(bnd)
30
+ @backend = case bnd.class.name
31
+ when 'Sequel::Postgres::Database', 'Sequel::SQLite::Database'
32
+ require 'sourced/backends/sequel_backend'
33
+ Sourced::Backends::SequelBackend.new(bnd)
34
+ else
35
+ BackendInterface.parse(bnd)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ module Consumer
5
+ class ConsumerInfo < Types::Data
6
+ ToBlock = Types::Any.transform(Proc) { |v| -> { v } }
7
+ StartFromBeginning = Types::Value[:beginning] >> Types::Static[nil] >> ToBlock
8
+ StartFromNow = Types::Value[:now] >> Types::Static[-> { Time.now - 5 }.freeze]
9
+ StartFromTime = Types::Interface[:call].check('must return a Time') { |v| v.call.is_a?(Time) }
10
+
11
+ StartFrom = (
12
+ StartFromBeginning | StartFromNow | StartFromTime
13
+ ).default { -> { nil } }
14
+
15
+ attribute :group_id, Types::String.present, writer: true
16
+ attribute :start_from, StartFrom, writer: true
17
+ attribute :async, Types::Boolean.default(true), writer: true
18
+
19
+ def sync!
20
+ self.async = false
21
+ end
22
+
23
+ def async!
24
+ self.async = true
25
+ end
26
+ end
27
+
28
+ def consumer_info
29
+ @consumer_info ||= ConsumerInfo.new(group_id: name, start_from: :beginning)
30
+ end
31
+
32
+ def consumer(&)
33
+ return consumer_info unless block_given?
34
+
35
+ info = ConsumerInfo.new(group_id: name)
36
+ yield info
37
+ raise Plumb::ParseError, info.errors unless info.valid?
38
+
39
+ @consumer_info = info
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ module Decide
5
+ PREFIX = 'command'
6
+
7
+ def self.included(base)
8
+ super
9
+ base.extend ClassMethods
10
+ end
11
+
12
+ # Run command handler methods
13
+ # defined with the .decide(Command, &) macro
14
+ # The signature will depend on how the command handler is defined
15
+ # Example:
16
+ # decide(state, command)
17
+ # decide(command)
18
+ def decide(*args)
19
+ events = case args
20
+ in [command]
21
+ send(Sourced.message_method_name(PREFIX, command.class.name), command)
22
+ in [state, command]
23
+ send(Sourced.message_method_name(PREFIX, command.class.name), state, command)
24
+ end
25
+ [events].flatten.compact
26
+ end
27
+
28
+ module ClassMethods
29
+ def inherited(subclass)
30
+ super
31
+ handled_commands.each do |cmd_type|
32
+ subclass.handled_commands << cmd_type
33
+ end
34
+ end
35
+
36
+ def handle_command(_command)
37
+ raise NoMethodError, "implement .handle_command(Command) in #{self}"
38
+ end
39
+
40
+ def handled_commands
41
+ @handled_commands ||= []
42
+ end
43
+
44
+ def decide(cmd_type, &block)
45
+ handled_commands << cmd_type
46
+ define_method(Sourced.message_method_name(PREFIX, cmd_type.name), &block)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ class Decider
5
+ include Evolve
6
+ include React
7
+ include Sync
8
+ extend Consumer
9
+
10
+ PREFIX = 'decide'
11
+
12
+ class << self
13
+ def inherited(subclass)
14
+ super
15
+ handled_commands.each do |cmd_type|
16
+ subclass.handled_commands << cmd_type
17
+ end
18
+ end
19
+
20
+ # Register as a Reactor
21
+ def handled_events = self.handled_events_for_react
22
+
23
+ # The Reactor interface
24
+ # @param events [Array<Message>]
25
+ def handle_events(events)
26
+ load(events.first.stream_id).handle_events(events)
27
+ end
28
+
29
+ # The Decider interface
30
+ # @param cmd [Command]
31
+ def handle_command(cmd)
32
+ load(cmd.stream_id).handle_command(cmd)
33
+ end
34
+
35
+ # Load a Decider from event history
36
+ #
37
+ # @param stream_id [String] the stream id
38
+ # @return [Decider]
39
+ def load(stream_id, upto: nil)
40
+ new(stream_id).load(upto:)
41
+ end
42
+
43
+ def handled_commands
44
+ @handled_commands ||= []
45
+ end
46
+
47
+ def decide(cmd_type, &block)
48
+ handled_commands << cmd_type
49
+ define_method(Sourced.message_method_name(PREFIX, cmd_type.name), &block)
50
+ end
51
+
52
+ # Define a command class, register a command handler
53
+ # and define a method to send the command
54
+ # Example:
55
+ # command :add_item, name: String do |cmd|
56
+ # cmd.follow(ItemAdded, item_id: SecureRandom.uuid, name: cmd.payload.name)
57
+ # end
58
+ #
59
+ # # The exmaple above will define a command class `AddItem` in the current namespace:
60
+ # AddItem = Message.define('namespace.add_item', payload_schema: { name: String })
61
+ #
62
+ # # Optionally you can pass an explicit command type string:
63
+ # command :add_item, 'todos.items.add', name: String do |cmd|
64
+ #
65
+ # # It will also register the command handler:
66
+ # decide AddItem do |cmd|
67
+ # cmd.follow(ItemAdded, item_id: SecureRandom.uuid, name: cmd.payload.name)
68
+ # end
69
+ #
70
+ # # And an :add_item method to send the command:
71
+ # def add_item(name:)
72
+ # issue_command AddItem, name:
73
+ # end
74
+ #
75
+ # This method can be used on Decider instances:
76
+ # aggregate.add_item(name: 'Buy milk')
77
+ #
78
+ # Payload schema is a Plumb Hash schema.
79
+ # See: https://github.com/ismasan/plumb#typeshash
80
+ #
81
+ # The helper method will instantiate an instance of the command class
82
+ # and validate its attributes with #valid?
83
+ # Only valid commands will be issued to the handler.
84
+ # The method returns the command instance. If #valid? is false, then the command was not issued.
85
+ # Example:
86
+ # cmd = aggregate.add_item(name: 10)
87
+ # cmd.valid? # => false
88
+ # cmd.errors # => { name: 'must be a String' }
89
+ #
90
+ # @param cmd_name [Symbol] example: :add_item
91
+ # @param payload_schema [Hash] A Plumb Hash schema. example: { name: String }
92
+ # @param block [Proc] The command handling code
93
+ # @return [Message] the command instance, which can be #valid? or not
94
+ def command(*args, &block)
95
+ raise ArgumentError, 'command block expects signature (state, command)' unless block.arity == 2
96
+
97
+ message_type = nil
98
+ cmd_name = nil
99
+ payload_schema = {}
100
+ segments = name.split('::').map(&:downcase)
101
+
102
+ case args
103
+ in [cmd_name]
104
+ message_type = [*segments, cmd_name].join('.')
105
+ in [cmd_name, Hash => payload_schema]
106
+ message_type = [*segments, cmd_name].join('.')
107
+ in [cmd_name, String => message_type, Hash => payload_schema]
108
+ in [cmd_name, String => message_type]
109
+ else
110
+ raise ArgumentError, "Invalid arguments for #{self}.command"
111
+ end
112
+
113
+ klass_name = cmd_name.to_s.split('_').map(&:capitalize).join
114
+ cmd_class = Command.define(message_type, payload_schema:)
115
+ const_set(klass_name, cmd_class)
116
+ decide cmd_class, &block
117
+ define_method(cmd_name) do |**payload|
118
+ issue_command cmd_class, payload
119
+ end
120
+ end
121
+ end
122
+
123
+ attr_reader :id, :seq, :state, :uncommitted_events
124
+
125
+ def initialize(id = SecureRandom.uuid, backend: Sourced.config.backend, logger: Sourced.config.logger)
126
+ @id = id
127
+ @backend = backend
128
+ @logger = logger
129
+ @seq = 0
130
+ @state = init_state(id)
131
+ @uncommitted_events = []
132
+ @__current_command = nil
133
+ end
134
+
135
+ def inspect
136
+ %(<#{self.class} id:#{id} seq:#{seq}>)
137
+ end
138
+
139
+ def ==(other)
140
+ other.is_a?(self.class) && id == other.id && seq == other.seq
141
+ end
142
+
143
+ def init_state(id)
144
+ nil
145
+ end
146
+
147
+ def load(after: nil, upto: nil)
148
+ events = backend.read_event_stream(id, after:, upto:)
149
+ if events.any?
150
+ @seq = events.last.seq
151
+ evolve(state, events)
152
+ end
153
+ self
154
+ end
155
+
156
+ def catch_up
157
+ seq_was = seq
158
+ load(after: seq_was)
159
+ [seq_was, seq]
160
+ end
161
+
162
+ def events(upto: seq)
163
+ backend.read_event_stream(id, upto:)
164
+ end
165
+
166
+ def decide(command)
167
+ command = __set_current_command(command)
168
+ send(Sourced.message_method_name(PREFIX, command.class.name), state, command)
169
+ @__current_command = nil
170
+ [state, uncommitted_events]
171
+ end
172
+
173
+ def apply(event_class, payload = {})
174
+ evt = __current_command.follow_with_attributes(
175
+ event_class,
176
+ attrs: { seq: __next_sequence },
177
+ metadata: { producer: self.class.consumer_info.group_id },
178
+ payload:
179
+ )
180
+ uncommitted_events << evt
181
+ evolve(state, [evt])
182
+ end
183
+
184
+ def commit(&)
185
+ output_events = uncommitted_events.slice(0..-1)
186
+ yield output_events
187
+ @uncommitted_events = []
188
+ output_events
189
+ end
190
+
191
+ # Register a first sync block to append new events to backend
192
+ sync do |_state, command, events|
193
+ messages = [command, *events]
194
+ backend.append_to_stream(id, messages) if messages.any?
195
+ end
196
+
197
+ sync do |_state, command, events|
198
+ Sourced::Router.handle_events(events)
199
+ end
200
+
201
+ def handle_command(command)
202
+ # TODO: this might raise an exception from a worker
203
+ # Think what to do with invalid commands here
204
+ raise "invalid command #{command.inspect} #{command.errors.inspect}" unless command.valid?
205
+ logger.info "#{self.inspect} Handling #{command.type}"
206
+ decide(command)
207
+ save
208
+ end
209
+
210
+ # TODO: idempotent event and command handling
211
+ # Reactor interface
212
+ # Handle events, return new commands
213
+ # Workers will handle route these commands
214
+ # to their target Deciders
215
+ def handle_events(events)
216
+ react(events)
217
+ end
218
+
219
+ private
220
+
221
+ attr_reader :backend, :logger, :__current_command
222
+
223
+ def save
224
+ events = commit do |messages|
225
+ backend.transaction do
226
+ run_sync_blocks(state, messages[0], messages[1..-1])
227
+ end
228
+ end
229
+ [ self, events ]
230
+ end
231
+
232
+ def __set_current_command(command)
233
+ command.with(seq: __next_sequence).tap do |cmd|
234
+ uncommitted_events << cmd
235
+ @__current_command = cmd
236
+ end
237
+ end
238
+
239
+ def __next_sequence
240
+ @seq += 1
241
+ end
242
+
243
+ def issue_command(klass, payload = {})
244
+ cmd = klass.new(stream_id: id, payload:)
245
+ return cmd unless cmd.valid?
246
+
247
+ handle_command(cmd)
248
+ cmd
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sourced
4
+ module Evolve
5
+ PREFIX = 'evolution'
6
+ NOOP_HANDLER = ->(*_) { nil }
7
+
8
+ def self.included(base)
9
+ super
10
+ base.extend ClassMethods
11
+ end
12
+
13
+ def evolve(*args)
14
+ state = self
15
+
16
+ case args
17
+ in [events]
18
+ events.each do |event|
19
+ method_name = Sourced.message_method_name(Evolve::PREFIX, event.class.to_s)
20
+ if respond_to?(method_name)
21
+ before_evolve(event)
22
+ send(method_name, event)
23
+ end
24
+ end
25
+ in [obj, events]
26
+ state = obj
27
+ events.each do |event|
28
+ method_name = Sourced.message_method_name(Evolve::PREFIX, event.class.to_s)
29
+ if respond_to?(method_name)
30
+ before_evolve(state, event)
31
+ send(method_name, state, event)
32
+ end
33
+ end
34
+ end
35
+
36
+ state
37
+ end
38
+
39
+ private def before_evolve(*_)
40
+ nil
41
+ end
42
+
43
+ module ClassMethods
44
+ def inherited(subclass)
45
+ super
46
+ handled_events_for_evolve.each do |evt_type|
47
+ subclass.handled_events_for_evolve << evt_type
48
+ end
49
+ end
50
+
51
+ # These two are the Reactor interface
52
+ # expected by Worker
53
+ def handle_events(_events)
54
+ raise NoMethodError, "implement .handle_events(Array<Event>) in #{self}"
55
+ end
56
+
57
+ def handled_events_for_evolve
58
+ @handled_events_for_evolve ||= []
59
+ end
60
+
61
+ # Example:
62
+ # evolve :event_type do |event|
63
+ # @updated_at = event.created_at
64
+ # end
65
+ #
66
+ # @param event_type [Sourced::Message]
67
+ def evolve(event_type, &block)
68
+ handled_events_for_evolve << event_type unless event_type.is_a?(Symbol)
69
+ block = NOOP_HANDLER unless block_given?
70
+ define_method(Sourced.message_method_name(Evolve::PREFIX, event_type.to_s), &block)
71
+ end
72
+
73
+ # Run this block before any of the registered event handlers
74
+ # Example:
75
+ # before_evolve do |event|
76
+ # @updated_at = event.created_at
77
+ # end
78
+ def before_evolve(&block)
79
+ define_method(:before_evolve, &block)
80
+ end
81
+
82
+ # Example:
83
+ # # With an Array of event types
84
+ # evolve_all [:event_type1, :event_type2] do |event|
85
+ # @updated_at = event.created_at
86
+ # end
87
+ #
88
+ # # From another Evolver that responds to #handled_events_for_evolve
89
+ # evolve_all CartAggregate do |event|
90
+ # @updated_at = event.created_at
91
+ # end
92
+ #
93
+ # @param event_list [Array<Sourced::Message>, #handled_events_for_evolve() {Array<Sourced::Message>}]
94
+ def evolve_all(event_list, &block)
95
+ event_list = event_list.handled_events_for_evolve if event_list.respond_to?(:handled_events_for_evolve)
96
+ event_list.each do |event_type|
97
+ evolve(event_type, &block)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sourced/types'
4
+
5
+ # A superclass and registry to define event types
6
+ # for example for an event-driven or event-sourced system.
7
+ # All events have an "envelope" set of attributes,
8
+ # including unique ID, stream_id, type, timestamp, causation ID,
9
+ # event subclasses have a type string (ex. 'users.name.updated') and an optional payload
10
+ # This class provides a `.define` method to create new event types with a type and optional payload struct,
11
+ # a `.from` method to instantiate the correct subclass from a hash, ex. when deserializing from JSON or a web request.
12
+ # and a `#follow` method to produce new events based on a previous event's envelope, where the #causation_id and #correlation_id
13
+ # are set to the parent event
14
+ # @example
15
+ #
16
+ # # Define event struct with type and payload
17
+ # UserCreated = Message.define('users.created') do
18
+ # attribute :name, Types::String
19
+ # attribute :email, Types::Email
20
+ # end
21
+ #
22
+ # # Instantiate a full event with .new
23
+ # user_created = UserCreated.new(stream_id: 'user-1', payload: { name: 'Joe', email: '...' })
24
+ #
25
+ # # Use the `.from(Hash) => Message` factory to lookup event class by `type` and produce the right instance
26
+ # user_created = Message.from(type: 'users.created', stream_id: 'user-1', payload: { name: 'Joe', email: '...' })
27
+ #
28
+ # # Use #follow(payload Hash) => Message to produce events following a command or parent event
29
+ # create_user = CreateUser.new(...)
30
+ # user_created = create_user.follow(UserCreated, name: 'Joe', email: '...')
31
+ # user_created.causation_id == create_user.id
32
+ # user_created.correlation_id == create_user.correlation_id
33
+ # user_created.stream_id == create_user.stream_id
34
+ #
35
+ # ## Message registries
36
+ # Each Message class has its own registry of sub-classes.
37
+ # You can use the top-level Sourced::Message.from(hash) to instantiate all message types.
38
+ # You can also scope the lookup by sub-class.
39
+ #
40
+ # @example
41
+ #
42
+ # class PublicCommand < Sourced::Message; end
43
+ #
44
+ # DoSomething = PublicCommand.define('commands.do_something')
45
+ #
46
+ # # Use .from scoped to PublicCommand subclass
47
+ # # to ensure that only PublicCommand subclasses are accessible.
48
+ # cmd = PublicCommand.from(type: 'commands.do_something', payload: { ... })
49
+ #
50
+ # ## JSON Schemas
51
+ # Plumb data structs support `.to_json_schema`, so you can document all events in the registry with something like
52
+ #
53
+ # Message.subclasses.map(&:to_json_schema)
54
+ #
55
+ module Sourced
56
+ UnknownMessageError = Class.new(ArgumentError)
57
+ PastMessageDateError = Class.new(ArgumentError)
58
+
59
+ class Message < Types::Data
60
+ attribute :id, Types::AutoUUID
61
+ attribute :stream_id, Types::String.present
62
+ attribute :type, Types::String.present
63
+ attribute :created_at, Types::Forms::Time.default { Time.now } #Types::JSON::AutoUTCTime
64
+ attribute? :causation_id, Types::UUID::V4
65
+ attribute? :correlation_id, Types::UUID::V4
66
+ attribute :seq, Types::Integer.default(1)
67
+ attribute :metadata, Types::Hash.default(Plumb::BLANK_HASH)
68
+ attribute :payload, Types::Static[nil]
69
+
70
+ class Registry
71
+ def initialize(message_class)
72
+ @message_class = message_class
73
+ @lookup = {}
74
+ end
75
+
76
+ def []=(key, klass)
77
+ @lookup[key] = klass
78
+ end
79
+
80
+ def [](key)
81
+ klass = lookup[key]
82
+ return klass if klass
83
+
84
+ message_class.subclasses.each do |c|
85
+ klass = c.registry[key]
86
+ return klass if klass
87
+ end
88
+ nil
89
+ end
90
+
91
+ def inspect
92
+ %(<#{self.class}:#{object_id} #{lookup.size} keys, #{message_class.subclasses.size} child registries>)
93
+ end
94
+
95
+ private
96
+
97
+ attr_reader :lookup, :message_class
98
+ end
99
+
100
+ def self.registry
101
+ @registry ||= Registry.new(self)
102
+ end
103
+
104
+ class Payload < Types::Data
105
+ def [](key) = attributes[key]
106
+ def fetch(...) = to_h.fetch(...)
107
+ end
108
+
109
+ def self.define(type_str, payload_schema: nil, &payload_block)
110
+ type_str.freeze unless type_str.frozen?
111
+ if registry[type_str]
112
+ Sourced.config.logger.warn("Message '#{type_str}' already defined")
113
+ end
114
+
115
+ registry[type_str] = Class.new(self) do
116
+ def self.node_name = :data
117
+ define_singleton_method(:type) { type_str }
118
+
119
+ attribute :type, Types::Static[type_str]
120
+ if payload_schema
121
+ attribute :payload, Payload[payload_schema]
122
+ elsif block_given?
123
+ attribute :payload, Payload, &payload_block if block_given?
124
+ end
125
+ end
126
+ end
127
+
128
+ def self.from(attrs)
129
+ klass = registry[attrs[:type]]
130
+ raise UnknownMessageError, "Unknown event type: #{attrs[:type]}" unless klass
131
+
132
+ klass.new(attrs)
133
+ end
134
+
135
+ def initialize(attrs = {})
136
+ unless attrs[:payload]
137
+ attrs = attrs.merge(payload: {})
138
+ end
139
+ super(attrs)
140
+ end
141
+
142
+ def with_metadata(meta = {})
143
+ return self if meta.empty?
144
+
145
+ attrs = metadata.merge(meta)
146
+ with(metadata: attrs)
147
+ end
148
+
149
+ def follow(event_class, payload_attrs = nil)
150
+ follow_with_attributes(
151
+ event_class,
152
+ payload: payload_attrs
153
+ )
154
+ end
155
+
156
+ def follow_with_seq(event_class, seq, payload_attrs = nil)
157
+ follow_with_attributes(
158
+ event_class,
159
+ attrs: { seq: },
160
+ payload: payload_attrs
161
+ )
162
+ end
163
+
164
+ def follow_with_stream_id(event_class, stream_id, payload_attrs = nil)
165
+ follow_with_attributes(
166
+ event_class,
167
+ attrs: { stream_id: },
168
+ payload: payload_attrs
169
+ )
170
+ end
171
+
172
+ def follow_with_attributes(event_class, attrs: {}, payload: nil, metadata: nil)
173
+ meta = self.metadata
174
+ meta = meta.merge(metadata) if metadata
175
+ attrs = { stream_id:, causation_id: id, correlation_id:, metadata: meta }.merge(attrs)
176
+ attrs[:payload] = payload.to_h if payload
177
+ event_class.parse(attrs)
178
+ end
179
+
180
+ def delay(datetime)
181
+ if datetime < created_at
182
+ raise PastMessageDateError, "Message #{type} can't be delayed to a date in the past"
183
+ end
184
+ with(created_at: datetime)
185
+ end
186
+
187
+ def to_json(*)
188
+ to_h.to_json(*)
189
+ end
190
+
191
+ private
192
+
193
+ def prepare_attributes(attrs)
194
+ attrs[:correlation_id] = attrs[:id] unless attrs[:correlation_id]
195
+ attrs[:causation_id] = attrs[:id] unless attrs[:causation_id]
196
+ attrs
197
+ end
198
+ end
199
+
200
+ class Command < Message; end
201
+ class Event < Message; end
202
+ end