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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +600 -0
  5. data/lib/generators/tcb/domain/domain_generator.rb +49 -0
  6. data/lib/generators/tcb/domain/templates/command_handler.rb.tt +11 -0
  7. data/lib/generators/tcb/domain/templates/domain_module.rb.tt +34 -0
  8. data/lib/generators/tcb/event_store/event_store_generator.rb +64 -0
  9. data/lib/generators/tcb/event_store/templates/command_handler.rb.tt +18 -0
  10. data/lib/generators/tcb/event_store/templates/domain_module.rb.tt +44 -0
  11. data/lib/generators/tcb/event_store/templates/migration.rb.tt +14 -0
  12. data/lib/generators/tcb/install/install_generator.rb +16 -0
  13. data/lib/generators/tcb/install/templates/tcb.rb.tt +18 -0
  14. data/lib/generators/tcb/shared/command_argument.rb +39 -0
  15. data/lib/tcb/command_bus.rb +26 -0
  16. data/lib/tcb/configuration.rb +118 -0
  17. data/lib/tcb/domain.rb +8 -0
  18. data/lib/tcb/domain_context.rb +29 -0
  19. data/lib/tcb/event_bus/running_strategy.rb +24 -0
  20. data/lib/tcb/event_bus/shutdown_strategy.rb +88 -0
  21. data/lib/tcb/event_bus/subscriber_registry.rb +46 -0
  22. data/lib/tcb/event_bus/termination_signal_handler.rb +55 -0
  23. data/lib/tcb/event_bus.rb +118 -0
  24. data/lib/tcb/event_bus_shutdown.rb +11 -0
  25. data/lib/tcb/event_query.rb +107 -0
  26. data/lib/tcb/event_store/active_record.rb +93 -0
  27. data/lib/tcb/event_store/event_stream_envelope.rb +13 -0
  28. data/lib/tcb/event_store/in_memory.rb +51 -0
  29. data/lib/tcb/handles_commands.rb +31 -0
  30. data/lib/tcb/handles_events.rb +44 -0
  31. data/lib/tcb/minitest_helpers.rb +37 -0
  32. data/lib/tcb/publish.rb +6 -0
  33. data/lib/tcb/record.rb +55 -0
  34. data/lib/tcb/records_events.rb +23 -0
  35. data/lib/tcb/rspec_helpers.rb +61 -0
  36. data/lib/tcb/stream_id.rb +33 -0
  37. data/lib/tcb/subscriber_invocation_failed.rb +31 -0
  38. data/lib/tcb/subscriber_metadata_extractor.rb +66 -0
  39. data/lib/tcb/test_helpers/shared.rb +29 -0
  40. data/lib/tcb/version.rb +5 -0
  41. data/lib/tcb.rb +57 -0
  42. 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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ class EventStore
5
+ EventStreamEnvelope = Data.define(
6
+ :event,
7
+ :event_id,
8
+ :stream_id,
9
+ :version,
10
+ :occurred_at
11
+ )
12
+ end
13
+ 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
@@ -0,0 +1,6 @@
1
+ module TCB
2
+ def self.publish(*events)
3
+ events.each { |event| config.event_bus.publish(event) }
4
+ events
5
+ end
6
+ end
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