tcb 0.5.0
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/CHANGELOG.md +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +600 -0
- data/lib/generators/tcb/domain/domain_generator.rb +49 -0
- data/lib/generators/tcb/domain/templates/command_handler.rb.tt +11 -0
- data/lib/generators/tcb/domain/templates/domain_module.rb.tt +34 -0
- data/lib/generators/tcb/event_store/event_store_generator.rb +64 -0
- data/lib/generators/tcb/event_store/templates/command_handler.rb.tt +18 -0
- data/lib/generators/tcb/event_store/templates/domain_module.rb.tt +44 -0
- data/lib/generators/tcb/event_store/templates/migration.rb.tt +14 -0
- data/lib/generators/tcb/install/install_generator.rb +16 -0
- data/lib/generators/tcb/install/templates/tcb.rb.tt +18 -0
- data/lib/generators/tcb/shared/command_argument.rb +39 -0
- data/lib/tcb/command_bus.rb +26 -0
- data/lib/tcb/configuration.rb +118 -0
- data/lib/tcb/domain.rb +8 -0
- data/lib/tcb/domain_context.rb +29 -0
- data/lib/tcb/event_bus/running_strategy.rb +24 -0
- data/lib/tcb/event_bus/shutdown_strategy.rb +88 -0
- data/lib/tcb/event_bus/subscriber_registry.rb +46 -0
- data/lib/tcb/event_bus/termination_signal_handler.rb +55 -0
- data/lib/tcb/event_bus.rb +118 -0
- data/lib/tcb/event_bus_shutdown.rb +11 -0
- data/lib/tcb/event_query.rb +107 -0
- data/lib/tcb/event_store/active_record.rb +93 -0
- data/lib/tcb/event_store/event_stream_envelope.rb +13 -0
- data/lib/tcb/event_store/in_memory.rb +51 -0
- data/lib/tcb/handles_commands.rb +31 -0
- data/lib/tcb/handles_events.rb +44 -0
- data/lib/tcb/minitest_helpers.rb +37 -0
- data/lib/tcb/publish.rb +6 -0
- data/lib/tcb/record.rb +55 -0
- data/lib/tcb/records_events.rb +23 -0
- data/lib/tcb/rspec_helpers.rb +61 -0
- data/lib/tcb/stream_id.rb +33 -0
- data/lib/tcb/subscriber_invocation_failed.rb +31 -0
- data/lib/tcb/subscriber_metadata_extractor.rb +66 -0
- data/lib/tcb/test_helpers/shared.rb +29 -0
- data/lib/tcb/version.rb +5 -0
- data/lib/tcb.rb +57 -0
- metadata +195 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require_relative 'event_bus/running_strategy'
|
|
5
|
+
require_relative 'event_bus/shutdown_strategy'
|
|
6
|
+
require_relative 'event_bus/termination_signal_handler'
|
|
7
|
+
require_relative 'event_bus/subscriber_registry'
|
|
8
|
+
|
|
9
|
+
module TCB
|
|
10
|
+
class EventBus
|
|
11
|
+
class ShutdownError < StandardError; end
|
|
12
|
+
|
|
13
|
+
attr_reader :queue, :registry, :mutex,
|
|
14
|
+
:active_dispatches, :dispatcher, :events_processed_during_shutdown
|
|
15
|
+
|
|
16
|
+
def initialize(
|
|
17
|
+
handle_signals: false,
|
|
18
|
+
shutdown_timeout: 30.0,
|
|
19
|
+
shutdown_signals: [:TERM, :INT],
|
|
20
|
+
on_signal: nil
|
|
21
|
+
)
|
|
22
|
+
@queue = Queue.new
|
|
23
|
+
@registry = SubscriberRegistry.new
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
@active_dispatches = 0
|
|
26
|
+
@events_processed_during_shutdown = 0
|
|
27
|
+
@execution_strategy = RunningStrategy.new(self)
|
|
28
|
+
|
|
29
|
+
@dispatcher = Thread.new do
|
|
30
|
+
loop do
|
|
31
|
+
event = @queue.pop
|
|
32
|
+
break if event == :shutdown_sentinel
|
|
33
|
+
|
|
34
|
+
dispatch(event)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if handle_signals
|
|
39
|
+
@termination_signal_handler = TerminationSignalHandler.new(
|
|
40
|
+
event_bus: self,
|
|
41
|
+
shutdown_timeout: shutdown_timeout,
|
|
42
|
+
signals: shutdown_signals,
|
|
43
|
+
on_signal: on_signal
|
|
44
|
+
)
|
|
45
|
+
@termination_signal_handler.install
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Subscribe to a specific event class
|
|
50
|
+
def subscribe(event_class, &block)
|
|
51
|
+
@execution_strategy.subscribe(event_class, &block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Unsubscribe using a subscription token
|
|
55
|
+
def unsubscribe(subscription)
|
|
56
|
+
@registry.remove(subscription)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Publish an event instance
|
|
60
|
+
def publish(event)
|
|
61
|
+
@execution_strategy.publish(event)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Graceful shutdown - drains queue with timeout
|
|
65
|
+
def shutdown(drain: true, timeout: 5.0)
|
|
66
|
+
@execution_strategy = ShutdownStrategy.new(
|
|
67
|
+
event_bus: self,
|
|
68
|
+
drain: drain,
|
|
69
|
+
timeout: timeout
|
|
70
|
+
)
|
|
71
|
+
@execution_strategy.execute
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Force shutdown - immediate, no draining
|
|
75
|
+
def force_shutdown
|
|
76
|
+
shutdown(drain: false, timeout: 0)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Check if bus is shut down
|
|
80
|
+
def shutdown?
|
|
81
|
+
@execution_strategy.shutdown?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Public for strategy access
|
|
85
|
+
def dispatch(event)
|
|
86
|
+
@mutex.synchronize { @active_dispatches += 1 }
|
|
87
|
+
|
|
88
|
+
@events_processed_during_shutdown += 1 if shutdown?
|
|
89
|
+
handlers = @registry.handlers_for(event.class)
|
|
90
|
+
|
|
91
|
+
handlers.each do |handler|
|
|
92
|
+
execute_handler(handler, event)
|
|
93
|
+
end
|
|
94
|
+
ensure
|
|
95
|
+
@mutex.synchronize { @active_dispatches -= 1 }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def execute_handler(handler, event)
|
|
101
|
+
handler.call(event)
|
|
102
|
+
rescue => e
|
|
103
|
+
return if event.is_a?(SubscriberInvocationFailed)
|
|
104
|
+
|
|
105
|
+
failure_event = SubscriberInvocationFailed.build(
|
|
106
|
+
handler: handler,
|
|
107
|
+
original_event: event,
|
|
108
|
+
error: e
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if shutdown?
|
|
112
|
+
dispatch(failure_event)
|
|
113
|
+
else
|
|
114
|
+
publish(failure_event)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
EventBusShutdown = Data.define(
|
|
5
|
+
:status, # :initiated, :completed, :timeout_exceeded
|
|
6
|
+
:drain_requested, # true/false - was drain requested?
|
|
7
|
+
:timeout_seconds, # timeout value used
|
|
8
|
+
:events_drained, # number of events processed during shutdown
|
|
9
|
+
:occurred_at # timestamp
|
|
10
|
+
)
|
|
11
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
class EventQuery
|
|
5
|
+
def initialize(store:, context:, stream_id: nil, from_version: nil, to_version: nil, occurred_after: nil)
|
|
6
|
+
@store = store
|
|
7
|
+
@context = context
|
|
8
|
+
@stream_id = stream_id
|
|
9
|
+
@from_version = from_version
|
|
10
|
+
@to_version = to_version
|
|
11
|
+
@occurred_after = occurred_after
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def stream(aggregate_id)
|
|
15
|
+
self.class.new(
|
|
16
|
+
store: @store,
|
|
17
|
+
context: @context,
|
|
18
|
+
stream_id: StreamId.build(@context, aggregate_id).to_s,
|
|
19
|
+
from_version: @from_version,
|
|
20
|
+
to_version: @to_version,
|
|
21
|
+
occurred_after: @occurred_after
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def from_version(version)
|
|
26
|
+
self.class.new(
|
|
27
|
+
store: @store,
|
|
28
|
+
context: @context,
|
|
29
|
+
stream_id: @stream_id,
|
|
30
|
+
from_version: version,
|
|
31
|
+
to_version: @to_version,
|
|
32
|
+
occurred_after: @occurred_after
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_version(version)
|
|
37
|
+
self.class.new(
|
|
38
|
+
store: @store,
|
|
39
|
+
context: @context,
|
|
40
|
+
stream_id: @stream_id,
|
|
41
|
+
from_version: @from_version,
|
|
42
|
+
to_version: version,
|
|
43
|
+
occurred_after: @occurred_after
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def between_versions(from, to)
|
|
48
|
+
from_version(from).to_version(to)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def occurred_after(time)
|
|
52
|
+
self.class.new(
|
|
53
|
+
store: @store,
|
|
54
|
+
context: @context,
|
|
55
|
+
stream_id: @stream_id,
|
|
56
|
+
from_version: @from_version,
|
|
57
|
+
to_version: @to_version,
|
|
58
|
+
occurred_after: time
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def last(count)
|
|
63
|
+
return [] unless @stream_id
|
|
64
|
+
|
|
65
|
+
result = @store.read(
|
|
66
|
+
@stream_id,
|
|
67
|
+
from_version: @from_version,
|
|
68
|
+
to_version: @to_version,
|
|
69
|
+
occurred_after: @occurred_after,
|
|
70
|
+
limit: count,
|
|
71
|
+
order: :desc
|
|
72
|
+
)
|
|
73
|
+
result.reverse
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def in_batches(of: 1000, from_version: nil, to_version: nil)
|
|
77
|
+
return enum_for(:in_batches, of: of, from_version: from_version, to_version: to_version) unless block_given?
|
|
78
|
+
|
|
79
|
+
cursor = from_version || @from_version
|
|
80
|
+
ceiling = to_version || @to_version
|
|
81
|
+
|
|
82
|
+
loop do
|
|
83
|
+
batch = @store.read(
|
|
84
|
+
@stream_id,
|
|
85
|
+
from_version: cursor,
|
|
86
|
+
to_version: ceiling,
|
|
87
|
+
occurred_after: @occurred_after,
|
|
88
|
+
limit: of
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
break if batch.empty?
|
|
92
|
+
|
|
93
|
+
yield batch
|
|
94
|
+
|
|
95
|
+
break if batch.size < of
|
|
96
|
+
|
|
97
|
+
cursor = batch.last.version + 1
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def to_a
|
|
102
|
+
result = []
|
|
103
|
+
in_batches { |batch| result.push(*batch) }
|
|
104
|
+
result
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module TCB
|
|
6
|
+
class EventStore
|
|
7
|
+
class ActiveRecord
|
|
8
|
+
def initialize
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def append(stream_id:, events:, occurred_at: Time.now)
|
|
13
|
+
@mutex.synchronize do
|
|
14
|
+
next_ver = next_version(stream_id)
|
|
15
|
+
|
|
16
|
+
envelopes = events.map.with_index(next_ver) do |event, version|
|
|
17
|
+
event_id = SecureRandom.uuid
|
|
18
|
+
|
|
19
|
+
event_record_for(stream_id).create!(
|
|
20
|
+
event_id: event_id,
|
|
21
|
+
stream_id: stream_id,
|
|
22
|
+
version: version,
|
|
23
|
+
event_type: event.class.name,
|
|
24
|
+
payload: serialize(event),
|
|
25
|
+
occurred_at: occurred_at
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
EventStreamEnvelope.new(
|
|
29
|
+
event: event,
|
|
30
|
+
event_id: event_id,
|
|
31
|
+
stream_id: stream_id,
|
|
32
|
+
version: version,
|
|
33
|
+
occurred_at: occurred_at
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
envelopes
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def read(stream_id, from_version: nil, to_version: nil, occurred_after: nil, limit: nil, order: :asc)
|
|
42
|
+
scope = event_record_for(stream_id)
|
|
43
|
+
.where(stream_id: stream_id)
|
|
44
|
+
.order(version: order)
|
|
45
|
+
|
|
46
|
+
scope = scope.where("version >= ?", from_version) if from_version
|
|
47
|
+
scope = scope.where("version <= ?", to_version) if to_version
|
|
48
|
+
scope = scope.where("occurred_at > ?", occurred_after) if occurred_after
|
|
49
|
+
scope = scope.limit(limit) if limit
|
|
50
|
+
|
|
51
|
+
scope.map do |record|
|
|
52
|
+
EventStreamEnvelope.new(
|
|
53
|
+
event: deserialize(record.payload),
|
|
54
|
+
event_id: record.event_id,
|
|
55
|
+
stream_id: record.stream_id,
|
|
56
|
+
version: record.version,
|
|
57
|
+
occurred_at: record.occurred_at
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def next_version(stream_id)
|
|
65
|
+
event_record_for(stream_id)
|
|
66
|
+
.where(stream_id: stream_id)
|
|
67
|
+
.maximum(:version)
|
|
68
|
+
.to_i + 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def event_record_for(stream_id)
|
|
72
|
+
context = stream_id.split("|").first
|
|
73
|
+
module_name = context
|
|
74
|
+
.split("/")
|
|
75
|
+
.map { |part| part.split("_").map(&:capitalize).join }
|
|
76
|
+
.join("::")
|
|
77
|
+
Object.const_get("#{module_name}::EventRecord")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def serialize(event)
|
|
81
|
+
YAML.dump(event)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def deserialize(payload)
|
|
85
|
+
YAML.safe_load(payload, permitted_classes: permitted_classes, aliases: true)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def permitted_classes
|
|
89
|
+
TCB.config.permitted_serialization_classes
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module TCB
|
|
6
|
+
class EventStore
|
|
7
|
+
class InMemory
|
|
8
|
+
def initialize
|
|
9
|
+
@streams = Hash.new { |h, k| h[k] = [] }
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def append(stream_id:, events:, occurred_at: Time.now)
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
envelopes = events.map.with_index(next_version(stream_id)) do |event, version|
|
|
16
|
+
EventStreamEnvelope.new(
|
|
17
|
+
event: event,
|
|
18
|
+
event_id: SecureRandom.uuid,
|
|
19
|
+
stream_id: stream_id,
|
|
20
|
+
version: version,
|
|
21
|
+
occurred_at: occurred_at
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
@streams[stream_id].concat(envelopes)
|
|
25
|
+
envelopes
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def read(stream_id, from_version: nil, to_version: nil, occurred_after: nil, limit: nil, order: :asc)
|
|
30
|
+
@mutex.synchronize { @streams[stream_id].dup }
|
|
31
|
+
.then { |e| from_version ? e.select { |env| env.version >= from_version } : e }
|
|
32
|
+
.then { |e| to_version ? e.select { |env| env.version <= to_version } : e }
|
|
33
|
+
.then { |e| occurred_after ? e.select { |env| env.occurred_at > occurred_after } : e }
|
|
34
|
+
.then { |e| order == :desc ? e.reverse : e }
|
|
35
|
+
.then { |e| limit ? e.first(limit) : e }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reset!
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
@streams = Hash.new { |h, k| h[k] = [] }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def next_version(stream_id)
|
|
47
|
+
@streams[stream_id].size + 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
module HandlesCommands
|
|
5
|
+
CommandHandlerRegistration = Data.define(:command_class, :handler)
|
|
6
|
+
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend(ClassMethods)
|
|
9
|
+
base.instance_variable_set(:@command_handler_registrations, [])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def handle(command_class, handler)
|
|
14
|
+
@command_handler_registrations << CommandHandlerRegistration.new(
|
|
15
|
+
command_class: command_class,
|
|
16
|
+
handler: handler
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with(*handlers)
|
|
21
|
+
raise ArgumentError, "command accepts exactly one handler" unless handlers.compact.size == 1
|
|
22
|
+
|
|
23
|
+
handlers.first
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def command_handler_registrations
|
|
27
|
+
@command_handler_registrations
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
module HandlesEvents
|
|
5
|
+
EventHandlerRegistration = Data.define(:event_class, :handlers)
|
|
6
|
+
PersistRegistration = Data.define(:event_classes, :stream_id_from_event, :context)
|
|
7
|
+
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
base.instance_variable_set(:@event_handler_registrations, [])
|
|
11
|
+
base.instance_variable_set(:@persist_registrations, [])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module ClassMethods
|
|
15
|
+
def react_with(*handlers)
|
|
16
|
+
EventHandlerRegistration.new(event_class: :undefined, handlers: handlers)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def on(event_class, registration)
|
|
20
|
+
@event_handler_registrations << registration.with(event_class: event_class)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def persist(registration)
|
|
24
|
+
@persist_registrations << registration
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def events(*event_classes, stream_id_from_event:)
|
|
28
|
+
PersistRegistration.new(
|
|
29
|
+
event_classes: event_classes,
|
|
30
|
+
stream_id_from_event: stream_id_from_event,
|
|
31
|
+
context: nil
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def event_handler_registrations
|
|
36
|
+
@event_handler_registrations
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def persist_registrations
|
|
40
|
+
@persist_registrations
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helpers/shared"
|
|
4
|
+
|
|
5
|
+
module TCB
|
|
6
|
+
module MinitestHelpers
|
|
7
|
+
include TestHelpers::Shared
|
|
8
|
+
|
|
9
|
+
def assert_published(*expected, within: 1.0, &block)
|
|
10
|
+
event_classes = expected.map { |arg| arg.is_a?(Class) ? arg : arg.class }.uniq
|
|
11
|
+
|
|
12
|
+
with_subscriptions(*event_classes) do |captured|
|
|
13
|
+
block.call
|
|
14
|
+
|
|
15
|
+
expected.each do |arg|
|
|
16
|
+
if arg.is_a?(Class)
|
|
17
|
+
met = poll_until(within: within) { captured[arg].any? }
|
|
18
|
+
raise Minitest::Assertion, "Expected #{arg} to be published, but it was not" unless met
|
|
19
|
+
else
|
|
20
|
+
event_class = arg.class
|
|
21
|
+
met = poll_until(within: within) { captured[event_class].any? { |e| e == arg } }
|
|
22
|
+
raise Minitest::Assertion, "Expected #{arg.inspect} to be published, but it was not" unless met
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def poll_assert(message = nil, within: 1.0, interval: 0.001, &block)
|
|
29
|
+
met = poll_until(within: within, interval: interval, &block)
|
|
30
|
+
return if met
|
|
31
|
+
|
|
32
|
+
failure_message = "Condition not met within #{within}s"
|
|
33
|
+
failure_message += ": \"#{message}\"" if message
|
|
34
|
+
raise Minitest::Assertion, failure_message
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/tcb/publish.rb
ADDED
data/lib/tcb/record.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
class Record
|
|
5
|
+
def self.call(events_from:, events:, within:, store:, registrations:, &block)
|
|
6
|
+
raise ArgumentError, "events_from: or events: must be provided" if events_from.empty? && events.empty?
|
|
7
|
+
new(events_from: events_from, events: events, store: store, registrations: registrations)
|
|
8
|
+
.call(within: within, &block)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(events_from:, events:, store:, registrations:)
|
|
12
|
+
@events_from = events_from
|
|
13
|
+
@events = events
|
|
14
|
+
@store = store
|
|
15
|
+
@registrations = registrations
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(within:, &block)
|
|
19
|
+
if within
|
|
20
|
+
within.transaction { execute(&block) }
|
|
21
|
+
else
|
|
22
|
+
execute(&block)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def execute(&block)
|
|
29
|
+
block.call if block
|
|
30
|
+
events = @events_from.flat_map(&:pull_recorded_events)
|
|
31
|
+
events += @events
|
|
32
|
+
persist(events)
|
|
33
|
+
events
|
|
34
|
+
rescue
|
|
35
|
+
@events_from.each(&:pull_recorded_events)
|
|
36
|
+
raise
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def persist(events)
|
|
40
|
+
return unless @store
|
|
41
|
+
|
|
42
|
+
grouped = Hash.new { |h, k| h[k] = [] }
|
|
43
|
+
|
|
44
|
+
events.each do |event|
|
|
45
|
+
registration = @registrations.find { |r| r.event_classes.include?(event.class) }
|
|
46
|
+
next unless registration
|
|
47
|
+
|
|
48
|
+
stream_id = StreamId.build(registration.context, event.public_send(registration.stream_id_from_event))
|
|
49
|
+
grouped[stream_id.to_s] << event
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
grouped.each { |stream_id, grouped_events| @store.append(stream_id: stream_id, events: grouped_events) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module TCB
|
|
2
|
+
module RecordsEvents
|
|
3
|
+
def record(event)
|
|
4
|
+
recorded_events_array << event
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def recorded_events
|
|
8
|
+
recorded_events_array.dup
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def pull_recorded_events
|
|
12
|
+
events = recorded_events_array.dup
|
|
13
|
+
recorded_events_array.clear
|
|
14
|
+
events
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def recorded_events_array
|
|
20
|
+
@recorded_events ||= []
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helpers/shared"
|
|
4
|
+
|
|
5
|
+
module TCB
|
|
6
|
+
module RSpecHelpers
|
|
7
|
+
include TestHelpers::Shared
|
|
8
|
+
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.extend(Matchers)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module Matchers
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
RSpec::Matchers.define :have_published do |*expected, within: 1.0|
|
|
17
|
+
match do |block|
|
|
18
|
+
event_classes = expected.map { |arg| arg.is_a?(Class) ? arg : arg.class }.uniq
|
|
19
|
+
@missed = []
|
|
20
|
+
|
|
21
|
+
helper = Object.new.extend(TCB::TestHelpers::Shared)
|
|
22
|
+
|
|
23
|
+
helper.with_subscriptions(*event_classes) do |captured|
|
|
24
|
+
block.call
|
|
25
|
+
|
|
26
|
+
expected.each do |arg|
|
|
27
|
+
if arg.is_a?(Class)
|
|
28
|
+
met = helper.poll_until(within: within) { captured[arg].any? }
|
|
29
|
+
@missed << arg unless met
|
|
30
|
+
else
|
|
31
|
+
event_class = arg.class
|
|
32
|
+
met = helper.poll_until(within: within) { captured[event_class].any? { |e| e == arg } }
|
|
33
|
+
@missed << arg unless met
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@missed.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
failure_message do
|
|
42
|
+
@missed.map { |arg| "Expected #{arg.inspect} to be published, but it was not" }.join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
supports_block_expectations
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
RSpec::Matchers.define :poll_match do |within: 1.0, interval: 0.001|
|
|
49
|
+
match do |block|
|
|
50
|
+
helper = Object.new.extend(TCB::TestHelpers::Shared)
|
|
51
|
+
helper.poll_until(within: within, interval: interval) { block.call }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
failure_message do
|
|
55
|
+
"Condition not met within #{within}s"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
supports_block_expectations
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TCB
|
|
4
|
+
class StreamId < Data.define(:context, :id)
|
|
5
|
+
SEPARATOR = "|"
|
|
6
|
+
NAMESPACE_SEPARATOR = "/"
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def build(context, id)
|
|
10
|
+
new(context: context.to_s.downcase, id: id.to_s)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def context_from_module(mod)
|
|
14
|
+
DomainContext.from_module(mod).to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse(string)
|
|
18
|
+
string = string.to_s
|
|
19
|
+
parts = string.split(SEPARATOR, 2)
|
|
20
|
+
|
|
21
|
+
if parts.size != 2 || parts.any?(&:empty?)
|
|
22
|
+
raise ArgumentError, "Invalid StreamId format: #{string.inspect}. Expected \"context#{SEPARATOR}id\""
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
new(context: parts[0], id: parts[1])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_s
|
|
30
|
+
"#{context}#{SEPARATOR}#{id}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|