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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +5 -0
- data/README.md +34 -0
- data/Rakefile +8 -0
- data/config.ru +0 -0
- data/examples/cart.rb +229 -0
- data/examples/workers.rb +5 -0
- data/lib/sourced/backends/active_record_backend.rb +184 -0
- data/lib/sourced/backends/sequel_backend.rb +387 -0
- data/lib/sourced/backends/test_backend.rb +273 -0
- data/lib/sourced/command_context.rb +46 -0
- data/lib/sourced/configuration.rb +39 -0
- data/lib/sourced/consumer.rb +42 -0
- data/lib/sourced/decide.rb +50 -0
- data/lib/sourced/decider.rb +251 -0
- data/lib/sourced/evolve.rb +102 -0
- data/lib/sourced/message.rb +202 -0
- data/lib/sourced/projector.rb +131 -0
- data/lib/sourced/rails/install_generator.rb +57 -0
- data/lib/sourced/rails/railtie.rb +16 -0
- data/lib/sourced/rails/templates/bin_sors +8 -0
- data/lib/sourced/rails/templates/create_sors_tables.rb.erb +55 -0
- data/lib/sourced/react.rb +57 -0
- data/lib/sourced/router.rb +148 -0
- data/lib/sourced/supervisor.rb +49 -0
- data/lib/sourced/sync.rb +80 -0
- data/lib/sourced/types.rb +24 -0
- data/lib/sourced/version.rb +5 -0
- data/lib/sourced/worker.rb +93 -0
- data/lib/sourced.rb +42 -0
- data/sig/sors.rbs +4 -0
- metadata +103 -0
@@ -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
|