easyop 0.1.2 → 0.1.4

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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ module Bus
6
+ # Bus adapter backed by ActiveSupport::Notifications.
7
+ #
8
+ # Requires activesupport (not auto-required). Raises LoadError if unavailable.
9
+ #
10
+ # @example
11
+ # Easyop::Events::Registry.bus = :active_support
12
+ #
13
+ # # or manually:
14
+ # bus = Easyop::Events::Bus::ActiveSupportNotifications.new
15
+ # Easyop::Events::Registry.bus = bus
16
+ class ActiveSupportNotifications < Base
17
+ # Publish +event+ via ActiveSupport::Notifications.instrument.
18
+ # The full event hash (name, payload, metadata, timestamp, source) is passed
19
+ # as the AS notification payload.
20
+ #
21
+ # @param event [Easyop::Events::Event]
22
+ def publish(event)
23
+ _ensure_as!
24
+ ::ActiveSupport::Notifications.instrument(event.name, event.to_h)
25
+ end
26
+
27
+ # Subscribe to events matching +pattern+ via ActiveSupport::Notifications.subscribe.
28
+ # Glob patterns are converted to Regexp before passing to AS.
29
+ #
30
+ # @param pattern [String, Regexp]
31
+ # @return [Object] AS subscription handle
32
+ def subscribe(pattern, &block)
33
+ _ensure_as!
34
+
35
+ as_pattern = _as_pattern(pattern)
36
+
37
+ ::ActiveSupport::Notifications.subscribe(as_pattern) do |*args|
38
+ as_event = ::ActiveSupport::Notifications::Event.new(*args)
39
+ p = as_event.payload
40
+
41
+ # Reconstruct an Easyop::Events::Event from the AS notification payload.
42
+ easyop_event = Event.new(
43
+ name: (p[:name] || as_event.name).to_s,
44
+ payload: p[:payload] || {},
45
+ metadata: p[:metadata] || {},
46
+ timestamp: p[:timestamp],
47
+ source: p[:source]
48
+ )
49
+ block.call(easyop_event)
50
+ end
51
+ end
52
+
53
+ # Unsubscribe using the handle returned by #subscribe.
54
+ # @param handle [Object] AS subscription object
55
+ def unsubscribe(handle)
56
+ _ensure_as!
57
+ ::ActiveSupport::Notifications.unsubscribe(handle)
58
+ end
59
+
60
+ private
61
+
62
+ # Convert a pattern for use with AS::Notifications.subscribe.
63
+ # AS accepts exact strings or Regexp (not globs), so convert globs first.
64
+ def _as_pattern(pattern)
65
+ case pattern
66
+ when Regexp then pattern
67
+ when String
68
+ pattern.include?("*") ? _glob_to_regex(pattern) : pattern
69
+ end
70
+ end
71
+
72
+ def _ensure_as!
73
+ return if defined?(::ActiveSupport::Notifications)
74
+
75
+ raise LoadError,
76
+ "ActiveSupport::Notifications is required for this bus adapter. " \
77
+ "Add 'activesupport' to your Gemfile."
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ module Bus
6
+ # Inheritable base class for custom bus implementations.
7
+ #
8
+ # Subclass this (not +Bus::Base+) when building a transport adapter for
9
+ # an external message broker, pub/sub system, or any custom delivery
10
+ # mechanism. +Bus::Base+ defines the required interface and glob helpers.
11
+ # +Bus::Adapter+ adds two production-grade utilities on top:
12
+ #
13
+ # _safe_invoke(handler, event) — call a handler, swallow StandardError
14
+ # _compile_pattern(pattern) — glob/string → cached Regexp
15
+ #
16
+ # Both are protected so they are accessible in subclasses but not part
17
+ # of the external bus interface.
18
+ #
19
+ # == Minimum contract
20
+ #
21
+ # Override +#publish+ and +#subscribe+. Override +#unsubscribe+ if your
22
+ # transport supports subscription cancellation.
23
+ #
24
+ # == Example — minimal adapter
25
+ #
26
+ # class PrintBus < Easyop::Events::Bus::Adapter
27
+ # def initialize
28
+ # super
29
+ # @subs = []
30
+ # @mutex = Mutex.new
31
+ # end
32
+ #
33
+ # def publish(event)
34
+ # snap = @mutex.synchronize { @subs.dup }
35
+ # snap.each do |sub|
36
+ # _safe_invoke(sub[:handler], event) if _pattern_matches?(sub[:pattern], event.name)
37
+ # end
38
+ # end
39
+ #
40
+ # def subscribe(pattern, &block)
41
+ # handle = { pattern: _compile_pattern(pattern), handler: block }
42
+ # @mutex.synchronize { @subs << handle }
43
+ # handle
44
+ # end
45
+ #
46
+ # def unsubscribe(handle)
47
+ # @mutex.synchronize { @subs.delete(handle) }
48
+ # end
49
+ # end
50
+ #
51
+ # == Example — decorator (wraps another bus)
52
+ #
53
+ # class LoggingBus < Easyop::Events::Bus::Adapter
54
+ # def initialize(inner)
55
+ # super()
56
+ # @inner = inner
57
+ # end
58
+ #
59
+ # def publish(event)
60
+ # Rails.logger.info "[bus:publish] #{event.name} payload=#{event.payload}"
61
+ # @inner.publish(event)
62
+ # end
63
+ #
64
+ # def subscribe(pattern, &block)
65
+ # @inner.subscribe(pattern, &block)
66
+ # end
67
+ #
68
+ # def unsubscribe(handle)
69
+ # @inner.unsubscribe(handle)
70
+ # end
71
+ # end
72
+ #
73
+ # Easyop::Events::Registry.bus = LoggingBus.new(Easyop::Events::Bus::Memory.new)
74
+ class Adapter < Base
75
+ protected
76
+
77
+ # Call +handler+ with +event+, rescuing any StandardError.
78
+ #
79
+ # Use this inside your +#publish+ implementation so one broken handler
80
+ # never prevents other handlers from running and never surfaces an
81
+ # exception to the event producer.
82
+ #
83
+ # @param handler [#call]
84
+ # @param event [Easyop::Events::Event]
85
+ # @return [void]
86
+ def _safe_invoke(handler, event)
87
+ handler.call(event)
88
+ rescue StandardError
89
+ # Intentionally swallowed — individual handler failures must not
90
+ # propagate to the caller or block remaining handlers.
91
+ end
92
+
93
+ # Compile +pattern+ into a +Regexp+, memoized per unique pattern value.
94
+ #
95
+ # Results are cached in a per-instance hash so glob→Regexp conversion
96
+ # happens only once regardless of how many events are published.
97
+ #
98
+ # String patterns without wildcards are anchored literally.
99
+ # Glob patterns follow the same rules as +Bus::Base#_glob_to_regex+:
100
+ # "*" — any single dot-separated segment
101
+ # "**" — any sequence of characters including dots
102
+ #
103
+ # @param pattern [String, Regexp]
104
+ # @return [Regexp]
105
+ def _compile_pattern(pattern)
106
+ return pattern if pattern.is_a?(Regexp)
107
+
108
+ @_pattern_cache ||= {}
109
+ @_pattern_cache[pattern] ||=
110
+ if pattern.include?("*")
111
+ _glob_to_regex(pattern)
112
+ else
113
+ Regexp.new("\\A#{Regexp.escape(pattern)}\\z")
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ module Bus
6
+ # Wraps any user-supplied bus adapter.
7
+ #
8
+ # The adapter must respond to:
9
+ # #publish(event)
10
+ # #subscribe(pattern, &block)
11
+ #
12
+ # Optionally:
13
+ # #unsubscribe(handle)
14
+ #
15
+ # @example Wrapping a RabbitMQ adapter
16
+ # class MyRabbitBus
17
+ # def publish(event) = rabbit.publish(event.to_h, routing_key: event.name)
18
+ # def subscribe(pattern, &block) = rabbit.subscribe(pattern) { |msg| block.call(reconstruct(msg)) }
19
+ # end
20
+ #
21
+ # Easyop::Events::Registry.bus = MyRabbitBus.new
22
+ #
23
+ # @example Passing via Custom wrapper explicitly
24
+ # Easyop::Events::Registry.bus = Easyop::Events::Bus::Custom.new(MyRabbitBus.new)
25
+ class Custom < Base
26
+ # @param adapter [Object] must respond to #publish and #subscribe
27
+ # @raise [ArgumentError] if adapter does not meet the interface
28
+ def initialize(adapter)
29
+ unless adapter.respond_to?(:publish) && adapter.respond_to?(:subscribe)
30
+ raise ArgumentError,
31
+ "Custom bus adapter must respond to #publish(event) and " \
32
+ "#subscribe(pattern, &block). Got: #{adapter.inspect}"
33
+ end
34
+ @adapter = adapter
35
+ end
36
+
37
+ def publish(event)
38
+ @adapter.publish(event)
39
+ end
40
+
41
+ def subscribe(pattern, &block)
42
+ @adapter.subscribe(pattern, &block)
43
+ end
44
+
45
+ def unsubscribe(handle)
46
+ @adapter.unsubscribe(handle) if @adapter.respond_to?(:unsubscribe)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ module Bus
6
+ # In-process, synchronous bus. Default adapter — requires no external gems.
7
+ #
8
+ # Thread-safe: subscriptions are protected by a Mutex, and publish snapshots
9
+ # the subscriber list before calling handlers so the lock is not held
10
+ # during handler execution (prevents deadlocks).
11
+ #
12
+ # @example
13
+ # bus = Easyop::Events::Bus::Memory.new
14
+ # bus.subscribe("order.placed") { |e| puts e.name }
15
+ # bus.publish(Easyop::Events::Event.new(name: "order.placed"))
16
+ class Memory < Base
17
+ def initialize
18
+ @subscribers = []
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # Deliver +event+ to all matching subscribers.
23
+ # Handlers are called outside the lock; failures in individual handlers
24
+ # are swallowed so other handlers still run.
25
+ #
26
+ # @param event [Easyop::Events::Event]
27
+ def publish(event)
28
+ subs = @mutex.synchronize { @subscribers.dup }
29
+ subs.each do |sub|
30
+ sub[:handler].call(event) if _pattern_matches?(sub[:pattern], event.name)
31
+ rescue StandardError
32
+ # Individual handler failures must not prevent other handlers from running.
33
+ end
34
+ end
35
+
36
+ # Register a handler block for events matching +pattern+.
37
+ #
38
+ # @param pattern [String, Regexp] exact name, glob, or Regexp
39
+ # @return [Hash] subscription handle (pass to #unsubscribe to remove)
40
+ def subscribe(pattern, &block)
41
+ entry = { pattern: pattern, handler: block }
42
+ @mutex.synchronize { @subscribers << entry }
43
+ entry
44
+ end
45
+
46
+ # Remove a previously registered subscription.
47
+ # @param handle [Hash] the value returned by #subscribe
48
+ def unsubscribe(handle)
49
+ @mutex.synchronize { @subscribers.delete(handle) }
50
+ end
51
+
52
+ # Remove all subscriptions. Useful in tests.
53
+ def clear!
54
+ @mutex.synchronize { @subscribers.clear }
55
+ end
56
+
57
+ # @return [Integer] number of active subscriptions
58
+ def subscriber_count
59
+ @mutex.synchronize { @subscribers.size }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ # Namespace for bus adapters.
6
+ # All adapters inherit from Easyop::Events::Bus::Base.
7
+ module Bus
8
+ # Abstract base for bus adapters.
9
+ #
10
+ # Subclasses must implement:
11
+ # #publish(event) — deliver event to matching subscribers
12
+ # #subscribe(pattern, &block) — register a handler block
13
+ #
14
+ # Optionally override:
15
+ # #unsubscribe(handle) — remove a subscription (handle is return value of #subscribe)
16
+ class Base
17
+ # @param event [Easyop::Events::Event]
18
+ def publish(event)
19
+ raise NotImplementedError, "#{self.class}#publish must be implemented"
20
+ end
21
+
22
+ # @param pattern [String, Regexp] event name, glob ("order.*"), or Regexp
23
+ # @yield [event] called when a matching event is published
24
+ # @return [Object] subscription handle (adapter-specific)
25
+ def subscribe(pattern, &block)
26
+ raise NotImplementedError, "#{self.class}#subscribe must be implemented"
27
+ end
28
+
29
+ # Remove a subscription created by #subscribe.
30
+ # @param handle [Object] the value returned by #subscribe
31
+ def unsubscribe(handle)
32
+ # default no-op — adapters may override
33
+ end
34
+
35
+ private
36
+
37
+ # Returns true when +pattern+ matches +event_name+.
38
+ # Supports exact strings, glob patterns ("order.*", "order.**"), and Regexp.
39
+ #
40
+ # Glob rules:
41
+ # "*" — matches any segment that doesn't cross a dot
42
+ # "**" — matches any string including dots (greedy)
43
+ #
44
+ # @param pattern [String, Regexp]
45
+ # @param event_name [String]
46
+ def _pattern_matches?(pattern, event_name)
47
+ case pattern
48
+ when Regexp
49
+ pattern.match?(event_name)
50
+ when String
51
+ pattern.include?("*") ? _glob_to_regex(pattern).match?(event_name) : pattern == event_name
52
+ end
53
+ end
54
+
55
+ # @param glob [String] e.g. "order.*" or "warehouse.**"
56
+ # @return [Regexp]
57
+ def _glob_to_regex(glob)
58
+ escaped = Regexp.escape(glob)
59
+ .gsub("\\*\\*", ".+")
60
+ .gsub("\\*", "[^.]+")
61
+ Regexp.new("\\A#{escaped}\\z")
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ # An immutable domain event value object.
6
+ #
7
+ # @example
8
+ # event = Easyop::Events::Event.new(
9
+ # name: "order.placed",
10
+ # payload: { order_id: 42, total: 9900 },
11
+ # source: "PlaceOrder"
12
+ # )
13
+ # event.name # => "order.placed"
14
+ # event.payload # => { order_id: 42, total: 9900 }
15
+ # event.frozen? # => true
16
+ class Event
17
+ attr_reader :name, :payload, :metadata, :timestamp, :source
18
+
19
+ # @param name [String] event name, e.g. "order.placed"
20
+ # @param payload [Hash] domain data extracted from ctx
21
+ # @param metadata [Hash] extra envelope data (correlation_id, etc.)
22
+ # @param timestamp [Time] defaults to Time.now
23
+ # @param source [String] class name of the emitting operation
24
+ def initialize(name:, payload: {}, metadata: {}, timestamp: nil, source: nil)
25
+ @name = name.to_s.freeze
26
+ @payload = payload.freeze
27
+ @metadata = metadata.freeze
28
+ @timestamp = (timestamp || Time.now).freeze
29
+ @source = source&.freeze
30
+ freeze
31
+ end
32
+
33
+ # @return [Hash] serializable hash representation
34
+ def to_h
35
+ { name: @name, payload: @payload, metadata: @metadata,
36
+ timestamp: @timestamp, source: @source }
37
+ end
38
+
39
+ def inspect
40
+ "#<Easyop::Events::Event name=#{@name.inspect} source=#{@source.inspect}>"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ # Thread-safe global registry for the event bus and handler subscriptions.
6
+ #
7
+ # The Registry is the coordination point between the Events plugin (producer)
8
+ # and the EventHandlers plugin (subscriber). Neither plugin references the other
9
+ # directly — they communicate only through the bus managed here.
10
+ #
11
+ # Configure the bus once at boot (e.g. in a Rails initializer):
12
+ #
13
+ # Easyop::Events::Registry.bus = :memory # default, in-process
14
+ # Easyop::Events::Registry.bus = :active_support # ActiveSupport::Notifications
15
+ # Easyop::Events::Registry.bus = MyRabbitBus.new # custom adapter
16
+ #
17
+ # Or via the global config:
18
+ #
19
+ # Easyop.configure { |c| c.event_bus = :active_support }
20
+ #
21
+ # IMPORTANT: Configure the bus BEFORE handler classes are loaded (before
22
+ # autoloading runs in Rails). Subscriptions are registered at class load time
23
+ # and are bound to whatever bus is active at that moment.
24
+ class Registry
25
+ @mutex = Mutex.new
26
+
27
+ class << self
28
+ # Set the global bus adapter.
29
+ #
30
+ # @param bus_or_symbol [:memory, :active_support, Bus::Base, Object]
31
+ def bus=(bus_or_symbol)
32
+ @mutex.synchronize { @bus = _resolve_bus(bus_or_symbol) }
33
+ end
34
+
35
+ # Returns the active bus adapter. Defaults to a Memory bus.
36
+ #
37
+ # Falls back to Easyop.config.event_bus if set, then :memory.
38
+ #
39
+ # @return [Bus::Base]
40
+ def bus
41
+ @mutex.synchronize do
42
+ @bus ||= _resolve_bus(
43
+ defined?(Easyop.config) && Easyop.config.respond_to?(:event_bus) && Easyop.config.event_bus ||
44
+ :memory
45
+ )
46
+ end
47
+ end
48
+
49
+ # Register a handler operation as a subscriber for +pattern+.
50
+ #
51
+ # Called automatically by the EventHandlers plugin when `on` is declared.
52
+ #
53
+ # @param pattern [String, Regexp] event name or glob
54
+ # @param handler_class [Class] operation class to invoke
55
+ # @param async [Boolean] use call_async (requires Async plugin)
56
+ # @param options [Hash] e.g. queue: "low"
57
+ def register_handler(pattern:, handler_class:, async: false, **options)
58
+ entry = { pattern: pattern, handler_class: handler_class,
59
+ async: async, options: options }
60
+
61
+ @mutex.synchronize { _subscriptions << entry }
62
+
63
+ bus.subscribe(pattern) { |event| _dispatch(event, entry) }
64
+ end
65
+
66
+ # Returns a copy of all registered handler entries (for introspection).
67
+ #
68
+ # @return [Array<Hash>]
69
+ def subscriptions
70
+ @mutex.synchronize { _subscriptions.dup }
71
+ end
72
+
73
+ # Reset the registry: drop all subscriptions and replace the bus with a
74
+ # fresh Memory instance. Intended for use in tests (called in before/after hooks).
75
+ def reset!
76
+ @mutex.synchronize do
77
+ @bus = nil
78
+ @subscriptions = nil
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def _subscriptions
85
+ @subscriptions ||= []
86
+ end
87
+
88
+ # @api private — exposed for specs
89
+ def _dispatch(event, entry)
90
+ handler_class = entry[:handler_class]
91
+
92
+ if entry[:async] && handler_class.respond_to?(:call_async)
93
+ # Serialize Event to a plain hash for ActiveJob compatibility.
94
+ attrs = event.payload.merge(event_data: event.to_h)
95
+ queue = entry[:options][:queue]
96
+ handler_class.call_async(attrs, **(queue ? { queue: queue } : {}))
97
+ else
98
+ # Sync: pass Event object directly — ctx.event is the live Event instance.
99
+ handler_class.call(event: event, **event.payload)
100
+ end
101
+ rescue StandardError
102
+ # Handler failures must not propagate back to the publisher.
103
+ end
104
+
105
+ def _resolve_bus(bus_or_symbol)
106
+ case bus_or_symbol
107
+ when :memory
108
+ Bus::Memory.new
109
+ when :active_support
110
+ Bus::ActiveSupportNotifications.new
111
+ when Bus::Base
112
+ bus_or_symbol
113
+ when nil
114
+ Bus::Memory.new
115
+ else
116
+ if bus_or_symbol.respond_to?(:publish) && bus_or_symbol.respond_to?(:subscribe)
117
+ Bus::Custom.new(bus_or_symbol)
118
+ else
119
+ raise ArgumentError,
120
+ "Unknown bus: #{bus_or_symbol.inspect}. " \
121
+ "Use :memory, :active_support, or a bus adapter instance."
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
data/lib/easyop/flow.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require 'securerandom'
2
+
1
3
  module Easyop
2
4
  # Compose a sequence of operations that share a single ctx.
3
5
  #
@@ -11,6 +13,27 @@ module Easyop
11
13
  # flow ValidateCart, ChargeCard, CreateOrder, NotifyUser
12
14
  # end
13
15
  #
16
+ # ## Recording plugin integration (flow tracing)
17
+ #
18
+ # When steps have the Recording plugin installed, `CallBehavior#call` forwards
19
+ # the parent ctx keys so every step log entry shows the flow as its parent:
20
+ #
21
+ # # Bare flow — flow itself is not recorded but steps see it as parent:
22
+ # class ProcessOrder
23
+ # include Easyop::Flow
24
+ # flow ValidateCart, ChargeCard
25
+ # end
26
+ #
27
+ # For full tree reconstruction (flow appears in operation_logs as the root
28
+ # entry) inherit from your recorded base class and opt out of the transaction
29
+ # so step-level transactions are not shadowed by an outer one:
30
+ #
31
+ # class ProcessOrder < ApplicationOperation
32
+ # include Easyop::Flow
33
+ # transactional false # EasyOp handles rollback; steps own their transactions
34
+ # flow ValidateCart, ChargeCard
35
+ # end
36
+ #
14
37
  # result = ProcessOrder.call(user: user, cart: cart)
15
38
  # result.on_success { |ctx| redirect_to order_path(ctx.order) }
16
39
  # result.on_failure { |ctx| flash[:alert] = ctx.error }
@@ -31,6 +54,25 @@ module Easyop
31
54
  # otherwise place Operation earlier in the ancestor chain than Flow itself).
32
55
  module CallBehavior
33
56
  def call
57
+ # ── Flow-tracing forwarding for the Recording plugin ──────────────────
58
+ # When Recording is NOT installed on this flow class (i.e. the flow does
59
+ # not inherit from a base operation that has Recording), set the
60
+ # __recording_parent_* ctx keys manually so every step operation knows
61
+ # this flow is its parent. When Recording IS installed on the flow (its
62
+ # RunWrapper runs before `call` is reached), it has already set up the
63
+ # parent context correctly — we detect that via _recording_enabled? and
64
+ # skip to avoid a conflict. If Recording is not used at all, these ctx
65
+ # keys are unused and ignored.
66
+ _flow_tracing = self.class.name &&
67
+ !self.class.respond_to?(:_recording_enabled?)
68
+ if _flow_tracing
69
+ ctx[:__recording_root_reference_id] ||= SecureRandom.uuid
70
+ _prev_parent_name = ctx[:__recording_parent_operation_name]
71
+ _prev_parent_id = ctx[:__recording_parent_reference_id]
72
+ ctx[:__recording_parent_operation_name] = self.class.name
73
+ ctx[:__recording_parent_reference_id] = SecureRandom.uuid
74
+ end
75
+
34
76
  pending_guard = nil
35
77
 
36
78
  self.class._flow_steps.each do |step|
@@ -56,6 +98,12 @@ module Easyop
56
98
  rescue Ctx::Failure
57
99
  ctx.rollback!
58
100
  raise
101
+ ensure
102
+ # Restore parent context so any caller above this flow sees the right parent.
103
+ if _flow_tracing
104
+ ctx[:__recording_parent_operation_name] = _prev_parent_name
105
+ ctx[:__recording_parent_reference_id] = _prev_parent_id
106
+ end
59
107
  end
60
108
  end
61
109