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,11 @@
1
+ module <%= module_class_name %>
2
+ class <%= current_command.handler_class_name %>
3
+ def call(command)
4
+ <% if comments? %>
5
+ # Perform the side effect and publish an event when done.
6
+ # WelcomeMailer.with(email: command.email).deliver_later
7
+ # TCB.publish(WelcomeEmailSent.new(user_id: command.user_id))
8
+ <% end %>
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,34 @@
1
+ module <%= module_class_name %>
2
+ include TCB::Domain
3
+ <% if comments? %>
4
+
5
+ # Events — past tense, immutable facts from other domains or this module
6
+ # UserRegistered = Data.define(:user_id, :email)
7
+ <% end %>
8
+ <% parsed_commands.each do |cmd| %>
9
+
10
+ <%= cmd.command_class_name %> = Data.define(<%= cmd.attrs.map { |a| ":#{a}" }.join(", ") %>) do
11
+ def validate!
12
+ <% cmd.attrs.each do |attr| %>
13
+ raise ArgumentError, "<%= attr %> is required" if <%= attr %>.nil?
14
+ <% end %>
15
+ end
16
+ end
17
+ <% end %>
18
+
19
+ <% parsed_commands.each do |cmd| %>
20
+ handle <%= cmd.command_class_name %>, with(<%= cmd.handler_class_name %>)
21
+ <% end %>
22
+ <% if comments? %>
23
+
24
+ # Reactions — handlers are called asynchronously when events are published
25
+ # on EventClass, react_with(EventHandlerClass)
26
+ <% end %>
27
+
28
+ # Facade
29
+ <% parsed_commands.each do |cmd| %>
30
+ def self.<%= cmd.name %>(<%= cmd.attrs.map { |a| "#{a}:" }.join(", ") %>)
31
+ TCB.publish(<%= cmd.command_class_name %>.new(<%= cmd.attrs.map { |a| "#{a}: #{a}" }.join(", ") %>))
32
+ end
33
+ <% end %>
34
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../shared/command_argument"
4
+
5
+ module TCB
6
+ module Generators
7
+ class EventStoreGenerator < Rails::Generators::Base
8
+ namespace "TCB:event_store"
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ argument :module_name, type: :string
12
+ argument :commands, type: :array, default: [], banner: "command:attr1,attr2"
13
+
14
+ class_option :skip_domain, type: :boolean, default: false, desc: "Skip domain module generation"
15
+ class_option :skip_migration, type: :boolean, default: false, desc: "Skip migration generation"
16
+ class_option :no_comments, type: :boolean, default: false, desc: "Generate without inline comments"
17
+
18
+ def create_domain_module
19
+ return if options[:skip_domain]
20
+ template "domain_module.rb.tt", "app/domain/#{module_name.underscore}.rb"
21
+ end
22
+
23
+ def create_handlers
24
+ return if options[:skip_domain]
25
+ parsed_commands.each do |cmd|
26
+ @current_command = cmd
27
+ template "command_handler.rb.tt", cmd.handler_file_path(module_name.underscore)
28
+ end
29
+ end
30
+
31
+ def create_migration
32
+ return if options[:skip_migration]
33
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
34
+ template "migration.rb.tt", "db/migrate/#{timestamp}_create_#{table_name}.rb"
35
+ end
36
+
37
+ private
38
+
39
+ def parsed_commands
40
+ @parsed_commands ||= CommandArgumentParser.parse(commands)
41
+ end
42
+
43
+ def module_class_name
44
+ module_name.camelize
45
+ end
46
+
47
+ def table_name
48
+ "#{module_name.underscore}_events"
49
+ end
50
+
51
+ def migration_class_name
52
+ "Create#{module_name.camelize}Events"
53
+ end
54
+
55
+ def comments?
56
+ !options[:no_comments]
57
+ end
58
+
59
+ def current_command
60
+ @current_command
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,18 @@
1
+ module <%= module_class_name %>
2
+ class <%= current_command.handler_class_name %>
3
+ def call(command)
4
+ <% if comments? %>
5
+ # 1. Load or build your aggregate
6
+ # order = Order.new(id: command.order_id)
7
+
8
+ # 2. TCB.record collects events and persists them within a transaction
9
+ # events = TCB.record(aggregates: [order], within: ApplicationRecord) do
10
+ # order.place(...)
11
+ # end
12
+
13
+ # 3. TCB.publish dispatches events to subscribers
14
+ # TCB.publish(*events)
15
+ <% end %>
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,44 @@
1
+ module <%= module_class_name %>
2
+ include TCB::Domain
3
+ <% if comments? %>
4
+
5
+ # Events — past tense, immutable facts
6
+ # OrderPlaced = Data.define(:order_id)
7
+ <% end %>
8
+ <% parsed_commands.each do |cmd| %>
9
+
10
+ <%= cmd.command_class_name %> = Data.define(<%= cmd.attrs.map { |a| ":#{a}" }.join(", ") %>) do
11
+ def validate!
12
+ <% cmd.attrs.each do |attr| %>
13
+ raise ArgumentError, "<%= attr %> is required" if <%= attr %>.nil?
14
+ <% end %>
15
+ end
16
+ end
17
+ <% end %>
18
+ <% if comments? %>
19
+
20
+ # Persistence — declare which events to store, and how to derive the stream id.
21
+ # Stream id is derived from an attribute on the event (stream_id_from_event:).
22
+ # Events are persisted by TCB.record in your command handlers (see below).
23
+ # persist events(
24
+ # EventClass,
25
+ # stream_id_from_event: :events_id_attribute
26
+ # )
27
+ <% end %>
28
+
29
+ <% parsed_commands.each do |cmd| %>
30
+ handle <%= cmd.command_class_name %>, with(<%= cmd.handler_class_name %>)
31
+ <% end %>
32
+ <% if comments? %>
33
+
34
+ # Reactions — handlers are called asynchronously when events are published
35
+ # on EventClass, react_with(EventHandlerClass)
36
+ <% end %>
37
+
38
+ # Facade
39
+ <% parsed_commands.each do |cmd| %>
40
+ def self.<%= cmd.name %>(<%= cmd.attrs.map { |a| "#{a}:" }.join(", ") %>)
41
+ TCB.dispatch(<%= cmd.command_class_name %>.new(<%= cmd.attrs.map { |a| "#{a}: #{a}" }.join(", ") %>))
42
+ end
43
+ <% end %>
44
+ end
@@ -0,0 +1,14 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :<%= table_name %> do |t|
4
+ t.string :event_id, null: false
5
+ t.string :stream_id, null: false
6
+ t.integer :version, null: false
7
+ t.string :event_type, null: false
8
+ t.text :payload, null: false
9
+ t.datetime :occurred_at, null: false
10
+ end
11
+
12
+ add_index :<%= table_name %>, [:stream_id, :version], unique: true
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ namespace "TCB:install"
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ desc "Creates a TCB initializer in config/initializers"
10
+
11
+ def create_initializer
12
+ template "tcb.rb.tt", "config/initializers/tcb.rb"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,18 @@
1
+ Rails.application.config.to_prepare do
2
+ TCB.configure do |c|
3
+ c.event_bus = TCB::EventBus.new(
4
+ handle_signals: true,
5
+ shutdown_timeout: 10.0
6
+ )
7
+ c.event_store = Rails.env.test? ? TCB::EventStore::InMemory.new
8
+ : TCB::EventStore::ActiveRecord.new
9
+
10
+ # Add your domain modules here after generating them:
11
+ # rails generate tcb:event_store orders place_order:order_id,customer
12
+ # rails generate tcb:domain notifications send_welcome_email:user_id,email
13
+ c.domain_modules = [
14
+ # Orders,
15
+ # Notifications
16
+ ]
17
+ end
18
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ module Generators
5
+ CommandArgument = Data.define(:name, :attrs) do
6
+ def command_class_name
7
+ camelize(name)
8
+ end
9
+
10
+ def handler_class_name
11
+ "#{camelize(name)}Handler"
12
+ end
13
+
14
+ def handler_file_name
15
+ "#{name}_handler"
16
+ end
17
+
18
+ def handler_file_path(module_name)
19
+ "app/domain/#{module_name}/#{handler_file_name}.rb"
20
+ end
21
+
22
+ private
23
+
24
+ def camelize(str)
25
+ str.split("_").map(&:capitalize).join
26
+ end
27
+ end
28
+
29
+ class CommandArgumentParser
30
+ def self.parse(args)
31
+ args.map do |arg|
32
+ name, attrs_str = arg.split(":", 2)
33
+ attrs = attrs_str ? attrs_str.split(",").map(&:to_sym) : []
34
+ CommandArgument.new(name: name, attrs: attrs)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,26 @@
1
+
2
+ module TCB
3
+ CommandHandlerNotFound = Class.new(StandardError)
4
+
5
+ def self.dispatch(command)
6
+ validate!(command)
7
+ handler = resolve_handler(command)
8
+ handler.new.call(command)
9
+ end
10
+
11
+ def self.validate!(command)
12
+ unless command.respond_to?(:validate!)
13
+ raise NotImplementedError, "#{command.class} must implement validate!"
14
+ end
15
+ command.validate!
16
+ end
17
+ private_class_method :validate!
18
+
19
+ def self.resolve_handler(command)
20
+ handler = config.command_handler(command.class)
21
+ raise CommandHandlerNotFound, "No handler registered for #{command.class.name}" unless handler
22
+
23
+ handler
24
+ end
25
+ private_class_method :resolve_handler
26
+ end
@@ -0,0 +1,118 @@
1
+ module TCB
2
+ ConfigurationError = Class.new(StandardError)
3
+
4
+ class Configuration
5
+ def initialize
6
+ @persist_registrations = []
7
+ end
8
+
9
+ def persist_registrations
10
+ @persist_registrations
11
+ end
12
+
13
+ def event_bus=(bus)
14
+ @event_bus = bus
15
+ end
16
+
17
+ def event_bus
18
+ @event_bus ||
19
+ raise(
20
+ ConfigurationError,
21
+ "TCB event_bus is not configured. Call TCB.configure { |c| c.event_bus = TCB::EventBus.new }"
22
+ )
23
+ end
24
+
25
+ def event_store=(store)
26
+ @event_store = store
27
+ end
28
+
29
+ def event_store
30
+ @event_store
31
+ end
32
+
33
+ def domain_modules=(modules)
34
+ @domain_modules = modules
35
+ flush_domain_modules
36
+ flush_command_handlers
37
+ flush_persist_registrations
38
+ end
39
+
40
+ def domain_modules
41
+ @domain_modules || []
42
+ end
43
+
44
+ def command_handler(command_class)
45
+ @command_handlers ||= {}
46
+ @command_handlers[command_class]
47
+ end
48
+
49
+ def extra_serialization_classes=(classes)
50
+ @extra_serialization_classes = classes
51
+ end
52
+
53
+ def extra_serialization_classes
54
+ @extra_serialization_classes || []
55
+ end
56
+
57
+ def permitted_serialization_classes
58
+ @permitted_serialization_classes ||= [
59
+ Symbol, Time, Date, BigDecimal,
60
+ *persist_registrations.flat_map(&:event_classes),
61
+ *extra_serialization_classes
62
+ ]
63
+ end
64
+
65
+ private
66
+
67
+ def flush_domain_modules
68
+ @domain_modules.each do |domain_module|
69
+ next unless domain_module.respond_to?(:event_handler_registrations)
70
+
71
+ domain_module.event_handler_registrations.each do |registration|
72
+ registration.handlers.each do |handler|
73
+ event_bus.subscribe(registration.event_class) do |event|
74
+ handler.new.call(event)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ def flush_command_handlers
82
+ @command_handlers = {}
83
+ @domain_modules.each do |domain_module|
84
+ next unless domain_module.respond_to?(:command_handler_registrations)
85
+
86
+ domain_module.command_handler_registrations.each do |reg|
87
+ @command_handlers[reg.command_class] = reg.handler
88
+ end
89
+ end
90
+ end
91
+
92
+ def flush_persist_registrations
93
+ @persist_registrations = []
94
+ @domain_modules.each do |domain_module|
95
+ next unless domain_module.respond_to?(:persist_registrations)
96
+
97
+ context = DomainContext.from_module(domain_module).to_s
98
+ domain_module.persist_registrations.each do |registration|
99
+ @persist_registrations << registration.with(context: context)
100
+ end
101
+ define_event_record_for(domain_module) if domain_module.persist_registrations.any?
102
+ end
103
+ end
104
+
105
+ def define_event_record_for(domain_module)
106
+ return if domain_module.const_defined?(:EventRecord, false)
107
+
108
+ klass = Class.new(::ActiveRecord::Base) do
109
+ self.table_name = DomainContext.from_module(domain_module).table_name
110
+ end
111
+ domain_module.const_set(:EventRecord, klass)
112
+ end
113
+ end
114
+
115
+ def self.config
116
+ @config ||= Configuration.new
117
+ end
118
+ end
data/lib/tcb/domain.rb ADDED
@@ -0,0 +1,8 @@
1
+ module TCB
2
+ module Domain
3
+ def self.included(base)
4
+ base.include(HandlesEvents)
5
+ base.include(HandlesCommands)
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ class DomainContext < Data.define(:value)
5
+ NAMESPACE_SEPARATOR = "/"
6
+ TABLE_SEPARATOR = "__"
7
+ TABLE_SUFFIX = "_events"
8
+
9
+ def self.from_module(domain_module)
10
+ value = domain_module.name
11
+ .gsub("::", NAMESPACE_SEPARATOR)
12
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
13
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
14
+ .downcase
15
+
16
+ new(value: value)
17
+ end
18
+
19
+ def to_s
20
+ value
21
+ end
22
+
23
+ def table_name
24
+ value
25
+ .gsub(NAMESPACE_SEPARATOR, TABLE_SEPARATOR)
26
+ .concat(TABLE_SUFFIX)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ class EventBus
5
+ class RunningStrategy
6
+ def initialize(event_bus)
7
+ @event_bus = event_bus
8
+ end
9
+
10
+ def publish(event)
11
+ @event_bus.queue << event
12
+ event
13
+ end
14
+
15
+ def subscribe(event_class, &block)
16
+ @event_bus.registry.add(event_class, block)
17
+ end
18
+
19
+ def shutdown?
20
+ false
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ class EventBus
5
+ class ShutdownStrategy
6
+ def initialize(event_bus:, drain:, timeout:)
7
+ @event_bus = event_bus
8
+ @drain = drain
9
+ @timeout = timeout
10
+ @start_time = Time.now
11
+ end
12
+
13
+ def publish(event)
14
+ raise ShutdownError, "Cannot publish events after shutdown"
15
+ end
16
+
17
+ def subscribe(event_class, &block)
18
+ raise ShutdownError, "Cannot subscribe after shutdown"
19
+ end
20
+
21
+ def shutdown?
22
+ true
23
+ end
24
+
25
+ def execute
26
+ emit_shutdown_event(:initiated)
27
+
28
+ if @drain
29
+ drain_with_timeout
30
+ else
31
+ force_terminate
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def drain_with_timeout
38
+ deadline = @start_time + @timeout
39
+
40
+ # Wait for queue to drain AND all active dispatches to complete
41
+ loop do
42
+ queue_empty = @event_bus.queue.size == 0
43
+ active_work = @event_bus.mutex.synchronize { @event_bus.active_dispatches }
44
+
45
+ if queue_empty && active_work == 0
46
+ # All work complete
47
+ terminate_dispatcher
48
+ emit_shutdown_event(:completed)
49
+ return
50
+ end
51
+
52
+ if Time.now >= deadline
53
+ # Timeout exceeded, force shutdown
54
+ force_terminate
55
+ emit_shutdown_event(:timeout_exceeded)
56
+ return
57
+ end
58
+
59
+ sleep 0.01 # Small poll interval
60
+ end
61
+ end
62
+
63
+ def force_terminate
64
+ @event_bus.queue << :shutdown_sentinel
65
+ @event_bus.dispatcher.kill if @event_bus.dispatcher.alive?
66
+ @event_bus.dispatcher.join(0.1)
67
+ end
68
+
69
+ def terminate_dispatcher
70
+ @event_bus.queue << :shutdown_sentinel
71
+ @event_bus.dispatcher.join(0.5)
72
+ end
73
+
74
+ def emit_shutdown_event(status)
75
+ shutdown_event = EventBusShutdown.new(
76
+ status: status,
77
+ drain_requested: @drain,
78
+ timeout_seconds: @timeout,
79
+ events_drained: @event_bus.events_processed_during_shutdown,
80
+ occurred_at: Time.now
81
+ )
82
+
83
+ # Dispatch directly, bypassing queue
84
+ @event_bus.dispatch(shutdown_event)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ class EventBus
5
+ class SubscriberRegistry
6
+ Subscription = Data.define(:event_class, :handler)
7
+
8
+ def initialize
9
+ @subscribers = Hash.new { |h, k| h[k] = Set.new }
10
+ @metadata = {}
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def add(event_class, handler)
15
+ subscription = Subscription.new(event_class: event_class, handler: handler)
16
+ @mutex.synchronize do
17
+ @subscribers[event_class].add(handler)
18
+ @metadata[handler.object_id] = SubscriberMetadataExtractor.new(handler).extract
19
+ end
20
+ subscription
21
+ end
22
+
23
+ def remove(subscription)
24
+ @mutex.synchronize do
25
+ @subscribers[subscription.event_class].delete(subscription.handler)
26
+ @metadata.delete(subscription.handler.object_id)
27
+ end
28
+ end
29
+
30
+ def handlers_for(event_class)
31
+ @mutex.synchronize { @subscribers[event_class].dup.freeze }
32
+ end
33
+
34
+ def metadata_for(subscription)
35
+ @mutex.synchronize { @metadata[subscription.handler.object_id] }
36
+ end
37
+
38
+ def clear
39
+ @mutex.synchronize do
40
+ @subscribers.clear
41
+ @metadata.clear
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TCB
4
+ class EventBus
5
+ class TerminationSignalHandler
6
+ def initialize(event_bus:, shutdown_timeout:, signals:, on_signal:)
7
+ @event_bus = event_bus
8
+ @shutdown_timeout = shutdown_timeout
9
+ @signals = signals
10
+ @on_signal = on_signal
11
+ @shutdown_thread = nil
12
+ @original_handlers = {}
13
+ end
14
+
15
+ def install
16
+ @signals.each do |sig|
17
+ @original_handlers[sig] = Signal.trap(sig) { handle_signal(sig) }
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def handle_signal(sig)
24
+ if shutdown_in_progress?
25
+ handle_force_shutdown(sig)
26
+ else
27
+ handle_graceful_shutdown(sig)
28
+ end
29
+ end
30
+
31
+ def shutdown_in_progress?
32
+ @shutdown_thread&.alive?
33
+ end
34
+
35
+ def handle_graceful_shutdown(sig)
36
+ @shutdown_thread = Thread.new do
37
+ @on_signal&.call(sig) if @on_signal
38
+ @event_bus.shutdown(drain: true, timeout: @shutdown_timeout)
39
+ restore_and_reraise(sig)
40
+ end
41
+ end
42
+
43
+ def handle_force_shutdown(sig)
44
+ @shutdown_thread.kill
45
+ @event_bus.force_shutdown
46
+ restore_and_reraise(sig)
47
+ end
48
+
49
+ def restore_and_reraise(sig)
50
+ Signal.trap(sig, @original_handlers[sig])
51
+ Process.kill(sig, Process.pid)
52
+ end
53
+ end
54
+ end
55
+ end