event_store_client 0.2.3 → 0.2.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 650872ba0eecbebfc09a26b58d3ff593bfd3d00db36a835e153233b665673f71
4
- data.tar.gz: e202dc48ce4a69d345e6fe4cfc6fe734d362e2d7e9b7c6d1d865511e84d9a255
3
+ metadata.gz: cc9b9f123804c920a08c07937465ae04afcd971d784912099f06822725742901
4
+ data.tar.gz: 0e5e5e7d863180a90fa20a6c6d1d2b8e76b479cb50489910af2327a33e0d2c7f
5
5
  SHA512:
6
- metadata.gz: e21ef0625b5e6b3faba04c19eda00efc736e408c787b6d60184eb666bc9ff88ff24a53f46a53efccafc0bd6c840c6beb42bd31229811115ba9ecbb9842c5c95c
7
- data.tar.gz: 68c5e7b7150857ca2091505ea6180f219ba38c2156751d8706f719acf47755da7f85ed22c420fce0583f8226f8c7603302f29038c0c6b39117bde0e78f230b1e
6
+ metadata.gz: 04e46ab7a710fc53c7be0d22bbadd59e6a075495174b88447cb82f054de5dcfa02607a1bf7cc03392952bdabb6dffc68ec264575264bd1fb3a9d5ac32447caea
7
+ data.tar.gz: 188cff27182fe3e6f83250c70a3a4a5cab9814b7ef1ce61a624af2bcd235d4cf950723e7551d8ec2612f64923b117592341ea4ada03fe44660c6f2f7713f05ce
@@ -1,27 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EventStoreClient
4
- def self.configure(&block)
5
- config = Configuration.instance
6
- config.configure(&block)
7
- end
8
4
  end
9
5
 
10
- require 'event_store_client/configuration'
11
6
  require 'event_store_client/types'
12
7
  require 'event_store_client/event'
13
8
  require 'event_store_client/deserialized_event'
14
-
15
9
  require 'event_store_client/serializer/json'
16
10
 
17
11
  require 'event_store_client/mapper'
18
12
 
19
- require 'event_store_client/endpoint'
13
+ require 'event_store_client/configuration'
20
14
 
21
15
  require 'event_store_client/store_adapter'
22
16
 
23
- require 'event_store_client/connection'
24
-
25
17
  require 'event_store_client/subscription'
26
18
  require 'event_store_client/subscriptions'
27
19
  require 'event_store_client/broker'
@@ -4,9 +4,10 @@ module EventStoreClient
4
4
  class Broker
5
5
  def call(subscriptions)
6
6
  subscriptions.each do |subscription|
7
- new_events = connection.consume_feed(subscription.stream, subscription.name) || []
8
- next if new_events.none?
9
- new_events.each { |event| subscription.subscriber.call(event) }
7
+ res = connection.consume_feed(subscription.stream, subscription.name) || []
8
+ next if res[:events].none?
9
+ res[:events].each { |event| subscription.subscriber.call(event) }
10
+ connection.ack(res[:ack_uri])
10
11
  end
11
12
  end
12
13
 
@@ -8,13 +8,21 @@ module EventStoreClient
8
8
  WrongExpectedEventVersion = Class.new(StandardError)
9
9
 
10
10
  def publish(stream:, events:, expected_version: nil)
11
- connection.publish(stream: stream, events: events, expected_version: expected_version)
11
+ connection.append_to_stream(stream, events, expected_version: expected_version)
12
12
  rescue StoreAdapter::Api::Client::WrongExpectedEventVersion => e
13
13
  raise WrongExpectedEventVersion.new(e.message)
14
14
  end
15
15
 
16
- def read(stream, direction: 'forward', start: 0, all: false)
17
- connection.read(stream, direction: direction, start: start, all: all)
16
+ def read(stream, direction: 'forward', start: 0, all: false, resolve_links: true)
17
+ if all
18
+ connection.read_all_from_stream(
19
+ stream, start: start, direction: direction, resolve_links: resolve_links
20
+ )
21
+ else
22
+ connection.read(
23
+ stream, start: start, direction: direction, resolve_links: resolve_links
24
+ )
25
+ end
18
26
  end
19
27
 
20
28
  def subscribe(subscriber, to: [], polling: true)
@@ -81,12 +89,12 @@ module EventStoreClient
81
89
  attr_reader :subscriptions, :broker, :error_handler
82
90
 
83
91
  def config
84
- EventStoreClient::Configuration.instance
92
+ EventStoreClient.config
85
93
  end
86
94
 
87
95
  def initialize
88
96
  @threads = []
89
- @connection ||= Connection.new
97
+ @connection ||= EventStoreClient.adapter
90
98
  @error_handler ||= config.error_handler
91
99
  @service_name ||= 'default'
92
100
  @broker ||= Broker.new(connection: connection)
@@ -1,28 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry-struct'
4
- require 'singleton'
3
+ require 'dry-configurable'
5
4
 
6
5
  module EventStoreClient
7
- class Configuration
8
- include Singleton
6
+ extend Dry::Configurable
9
7
 
10
- attr_accessor :per_page, :service_name, :mapper, :error_handler, :pid_path, :adapter
8
+ # Supported adapters: %i[api in_memory]
9
+ #
10
+ setting :adapter, :api
11
11
 
12
- def configure
13
- yield(self) if block_given?
14
- end
12
+ setting :error_handler
13
+ setting :eventstore_url, 'http://localhost:2113' do |value|
14
+ value.is_a?(URI) ? value : URI(value)
15
+ end
16
+
17
+ setting :eventstore_user, 'admin'
18
+ setting :eventstore_password, 'changeit'
19
+
20
+ setting :db_port, 2113
21
+
22
+ setting :per_page, 20
23
+ setting :pid_path, 'tmp/poll.pid'
15
24
 
16
- private
25
+ setting :service_name, 'default'
17
26
 
18
- def initialize
19
- @per_page = 20
20
- @pid_path = 'tmp/poll.pid'
21
- @mapper = Mapper::Default.new
22
- @service_name = 'default'
23
- @error_handler = nil
24
- @adapter = EventStoreClient::StoreAdapter::Api::Client.new(
25
- host: 'http://localhost', port: 2113, per_page: per_page
27
+ setting :mapper, Mapper::Default.new
28
+
29
+ def self.configure
30
+ yield(config) if block_given?
31
+ end
32
+
33
+ def self.adapter
34
+ case config.adapter
35
+ when :api
36
+ StoreAdapter::Api::Client.new(
37
+ config.eventstore_url,
38
+ per_page: config.per_page,
39
+ mapper: config.mapper,
40
+ connection_options: {}
41
+ )
42
+ else
43
+ StoreAdapter::InMemory.new(
44
+ mapper: config.mapper, per_page: config.per_page
26
45
  )
27
46
  end
28
47
  end
@@ -5,7 +5,8 @@ module EventStoreClient
5
5
  class Default
6
6
  def serialize(event)
7
7
  Event.new(
8
- type: event.class.to_s,
8
+ id: event.respond_to?(:id) ? event.id : nil,
9
+ type: (event.respond_to?(:type) ? event.type : nil) || event.class.to_s,
9
10
  data: serializer.serialize(event.data),
10
11
  metadata: serializer.serialize(event.metadata)
11
12
  )
@@ -7,6 +7,8 @@ module Serializer
7
7
  end
8
8
 
9
9
  def self.serialize(data)
10
+ return data if data.is_a?(String)
11
+
10
12
  JSON.generate(data)
11
13
  end
12
14
  end
@@ -7,14 +7,16 @@ module EventStoreClient
7
7
  WrongExpectedEventVersion = Class.new(StandardError)
8
8
 
9
9
  def append_to_stream(stream_name, events, expected_version: nil)
10
+ serialized_events = events.map { |event| mapper.serialize(event) }
10
11
  headers = {
11
12
  'ES-ExpectedVersion' => expected_version&.to_s
12
13
  }.reject { |_key, val| val.nil? || val.empty? }
13
14
 
14
- data = build_events_data(events)
15
+ data = build_events_data(serialized_events)
15
16
  response = make_request(:post, "/streams/#{stream_name}", body: data, headers: headers)
16
17
  validate_response(response, expected_version)
17
18
  response
19
+ serialized_events
18
20
  end
19
21
 
20
22
  def delete_stream(stream_name, hard_delete: false)
@@ -31,11 +33,39 @@ module EventStoreClient
31
33
  'Accept' => 'application/vnd.eventstore.atom+json'
32
34
  }
33
35
 
34
- make_request(
36
+ response = make_request(
35
37
  :get,
36
38
  "/streams/#{stream_name}/#{start}/#{direction}/#{count}",
37
39
  headers: headers
38
40
  )
41
+ return [] if response.body.nil? || response.body.empty?
42
+ JSON.parse(response.body)['entries'].map do |entry|
43
+ deserialize_event(entry)
44
+ end.reverse
45
+ end
46
+
47
+ def read_all_from_stream(stream, start: 0, resolve_links: true)
48
+ count = per_page
49
+ events = []
50
+ failed_requests_count = 0
51
+
52
+ while failed_requests_count < 3
53
+ begin
54
+ response =
55
+ read(stream, start: start, direction: 'forward', resolve_links: resolve_links)
56
+ failed_requests_count += 1 && next unless response.success? || response.status == 404
57
+ rescue Faraday::ConnectionFailed
58
+ failed_requests_count += 1
59
+ next
60
+ end
61
+ failed_requests_count = 0
62
+ break if response.body.nil? || response.body.empty?
63
+ entries = JSON.parse(response.body)['entries']
64
+ break if entries.empty?
65
+ events += entries.map { |entry| deserialize_event(entry) }.reverse
66
+ start += count
67
+ end
68
+ events
39
69
  end
40
70
 
41
71
  def join_streams(name, streams)
@@ -79,18 +109,31 @@ module EventStoreClient
79
109
  subscription_name,
80
110
  count: 1,
81
111
  long_poll: 0,
82
- resolve_links: true
112
+ resolve_links: true,
113
+ per_page: 20
83
114
  )
84
115
  headers = long_poll.positive? ? { 'ES-LongPoll' => long_poll.to_s } : {}
85
116
  headers['Content-Type'] = 'application/vnd.eventstore.competingatom+json'
86
117
  headers['Accept'] = 'application/vnd.eventstore.competingatom+json'
87
118
  headers['ES-ResolveLinktos'] = resolve_links.to_s
88
119
 
89
- make_request(
120
+ response = make_request(
90
121
  :get,
91
122
  "/subscriptions/#{stream_name}/#{subscription_name}/#{count}",
92
123
  headers: headers
93
124
  )
125
+
126
+ return [] if response.body || response.body.empty?
127
+
128
+ body = JSON.parse(response.body)
129
+
130
+ ack_info = body['links'].find { |link| link['relation'] == 'ackAll' }
131
+ return unless ack_info
132
+ ack_uri = ack_info['uri']
133
+ events = body['entries'].map do |entry|
134
+ deserialize_event(entry)
135
+ end
136
+ { ack_uri: ack_uri, events: events }
94
137
  end
95
138
 
96
139
  def link_to(stream_name, events, expected_version: nil)
@@ -106,7 +149,7 @@ module EventStoreClient
106
149
  headers: headers
107
150
  )
108
151
  validate_response(response, expected_version)
109
- response
152
+ true
110
153
  end
111
154
 
112
155
  def ack(url)
@@ -115,11 +158,12 @@ module EventStoreClient
115
158
 
116
159
  private
117
160
 
118
- attr_reader :endpoint, :per_page, :connection_options
161
+ attr_reader :uri, :per_page, :connection_options, :mapper
119
162
 
120
- def initialize(host:, port:, per_page: 20, connection_options: {})
121
- @endpoint = Endpoint.new(host: host, port: port)
163
+ def initialize(uri, per_page: 20, mapper:, connection_options: {})
164
+ @uri = uri
122
165
  @per_page = per_page
166
+ @mapper = mapper
123
167
  @connection_options = connection_options
124
168
  end
125
169
 
@@ -154,7 +198,7 @@ module EventStoreClient
154
198
  end
155
199
 
156
200
  def connection
157
- @connection ||= Api::Connection.new(endpoint, connection_options).call
201
+ @connection ||= Api::Connection.new(uri, connection_options).call
158
202
  end
159
203
 
160
204
  def validate_response(resp, expected_version)
@@ -164,6 +208,18 @@ module EventStoreClient
164
208
  "expected: #{expected_version}"
165
209
  )
166
210
  end
211
+
212
+ def deserialize_event(entry)
213
+ event = EventStoreClient::Event.new(
214
+ id: entry['eventId'],
215
+ title: entry['title'],
216
+ type: entry['eventType'],
217
+ data: entry['data'] || '{}',
218
+ metadata: entry['isMetaData'] ? entry['metaData'] : '{}'
219
+ )
220
+
221
+ mapper.deserialize(event)
222
+ end
167
223
  end
168
224
  end
169
225
  end
@@ -9,23 +9,27 @@ module EventStoreClient
9
9
  def call
10
10
  Faraday.new(
11
11
  {
12
- url: endpoint.url,
12
+ url: uri.to_s,
13
13
  headers: DEFAULT_HEADERS
14
14
  }.merge(options)
15
15
  ) do |conn|
16
- conn.basic_auth(ENV['EVENT_STORE_USER'], ENV['EVENT_STORE_PASSWORD'])
16
+ conn.basic_auth(config.eventstore_user, config.eventstore_password)
17
17
  conn.adapter Faraday.default_adapter
18
18
  end
19
19
  end
20
20
 
21
21
  private
22
22
 
23
- def initialize(endpoint, options = {})
24
- @endpoint = endpoint
23
+ def config
24
+ EventStoreClient.config
25
+ end
26
+
27
+ def initialize(uri, options = {})
28
+ @uri = uri
25
29
  @options = options
26
30
  end
27
31
 
28
- attr_reader :endpoint, :options
32
+ attr_reader :uri, :options
29
33
 
30
34
  DEFAULT_HEADERS = {
31
35
  'Content-Type' => 'application/vnd.eventstore.events+json'
@@ -33,7 +33,27 @@ module EventStoreClient
33
33
  read_stream_backward(stream_name, start: start)
34
34
  end
35
35
 
36
- Response.new(response.to_json, 200)
36
+ res = Response.new(response.to_json, 200)
37
+
38
+ return [] if res.body.nil? || res.body.empty?
39
+ JSON.parse(res.body)['entries'].map do |entry|
40
+ deserialize_event(entry)
41
+ end.reverse
42
+ end
43
+
44
+ def read_all_from_stream(stream_name, direction: 'forward', start: 0, resolve_links: true)
45
+ response =
46
+ if direction == 'forward'
47
+ read_stream_forward(stream_name, start: start)
48
+ else
49
+ read_stream_backward(stream_name, start: start)
50
+ end
51
+ res = Response.new(response.to_json, 200)
52
+
53
+ return [] if res.body.nil? || res.body.empty?
54
+ JSON.parse(res.body)['entries'].map do |entry|
55
+ deserialize_event(entry)
56
+ end.reverse
37
57
  end
38
58
 
39
59
  def subscribe_to_stream(stream_name, subscription_name, **)
@@ -54,6 +74,7 @@ module EventStoreClient
54
74
 
55
75
  def link_to(stream_name, events, **)
56
76
  append_to_stream(stream_name, events)
77
+ events
57
78
  end
58
79
 
59
80
  def ack(url)
@@ -64,11 +85,11 @@ module EventStoreClient
64
85
 
65
86
  private
66
87
 
67
- attr_reader :endpoint, :per_page
88
+ attr_reader :per_page, :mapper
68
89
 
69
- def initialize(host:, port:, per_page: 20)
70
- @endpoint = Endpoint.new(host: host, port: port)
90
+ def initialize(mapper:, per_page: 20)
71
91
  @per_page = per_page
92
+ @mapper = mapper
72
93
  @event_store = {}
73
94
  end
74
95
 
@@ -115,11 +136,25 @@ module EventStoreClient
115
136
  else
116
137
  [{
117
138
  'uri' =>
118
- "http://#{endpoint.url}/streams/#{stream_name}/#{batch_size}/#{direction}/#{count}",
139
+ "/streams/#{stream_name}/#{batch_size}/#{direction}/#{count}",
119
140
  'relation' => direction
120
141
  }]
121
142
  end
122
143
  end
144
+
145
+ private
146
+
147
+ def deserialize_event(entry)
148
+ event = EventStoreClient::Event.new(
149
+ id: entry['eventId'],
150
+ title: entry['title'],
151
+ type: entry['eventType'],
152
+ data: entry['data'] || '{}',
153
+ metadata: entry['isMetaData'] ? entry['metaData'] : '{}'
154
+ )
155
+
156
+ mapper.deserialize(event)
157
+ end
123
158
  end
124
159
  end
125
160
  end
@@ -25,8 +25,9 @@ module EventStoreClient
25
25
  private
26
26
 
27
27
  def create_subscription(subscription)
28
+ # store position somewhere.
28
29
  connection.join_streams(subscription.name, subscription.observed_streams)
29
- connection.subscribe(subscription.stream, name: subscription.name)
30
+ connection.subscribe_to_stream(subscription.stream, name: subscription.name)
30
31
  end
31
32
 
32
33
  attr_reader :connection, :subscriptions, :service
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EventStoreClient
4
- VERSION = '0.2.3'
4
+ VERSION = '0.2.4'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: event_store_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Wilgosz
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-10-21 00:00:00.000000000 Z
11
+ date: 2020-12-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-schema
@@ -42,16 +42,16 @@ dependencies:
42
42
  name: faraday
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - ">="
45
+ - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: 0.17.0
47
+ version: '1.0'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - ">="
52
+ - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: 0.17.0
54
+ version: '1.0'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: rss
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - ">="
67
67
  - !ruby/object:Gem::Version
68
68
  version: 0.2.8
69
+ - !ruby/object:Gem::Dependency
70
+ name: dry-configurable
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0.11'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0.11'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: rspec
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -108,12 +122,10 @@ files:
108
122
  - lib/event_store_client/broker.rb
109
123
  - lib/event_store_client/client.rb
110
124
  - lib/event_store_client/configuration.rb
111
- - lib/event_store_client/connection.rb
112
125
  - lib/event_store_client/data_decryptor.rb
113
126
  - lib/event_store_client/data_encryptor.rb
114
127
  - lib/event_store_client/deserialized_event.rb
115
128
  - lib/event_store_client/encryption_metadata.rb
116
- - lib/event_store_client/endpoint.rb
117
129
  - lib/event_store_client/event.rb
118
130
  - lib/event_store_client/mapper.rb
119
131
  - lib/event_store_client/mapper/default.rb
@@ -148,7 +160,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
160
  - !ruby/object:Gem::Version
149
161
  version: '0'
150
162
  requirements: []
151
- rubygems_version: 3.0.8
163
+ rubygems_version: 3.1.4
152
164
  signing_key:
153
165
  specification_version: 4
154
166
  summary: Ruby integration for https://eventstore.org
@@ -1,112 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module EventStoreClient
4
- class Connection
5
- def publish(stream:, events:, expected_version: nil)
6
- serialized_events = events.map { |event| mapper.serialize(event) }
7
- client.append_to_stream(
8
- stream, serialized_events, expected_version: expected_version
9
- )
10
- serialized_events
11
- end
12
-
13
- def read(stream, direction:, start:, all:, resolve_links: true)
14
- return read_all_from_stream(stream, start: start, resolve_links: resolve_links) if all
15
- read_from_stream(
16
- stream, direction: direction, start: start, resolve_links: resolve_links
17
- )
18
- end
19
-
20
- def delete_stream(stream); end
21
-
22
- def join_streams(name, streams)
23
- client.join_streams(name, streams)
24
- end
25
-
26
- def subscribe(stream, name:)
27
- client.subscribe_to_stream(stream, name)
28
- end
29
-
30
- def consume_feed(stream, subscription)
31
- response = client.consume_feed(stream, subscription)
32
- return [] unless response.body
33
- body = JSON.parse(response.body)
34
-
35
- ack = body['links'].find { |link| link['relation'] == 'ackAll' }
36
- return unless ack
37
- ack_uri = ack['uri']
38
- events = body['entries'].map do |entry|
39
- deserialize_event(entry)
40
- end
41
- client.ack(ack_uri)
42
- events
43
- end
44
-
45
- def link_to(stream, events, expected_version: nil)
46
- client.link_to(stream, events, expected_version: expected_version)
47
-
48
- true
49
- end
50
-
51
- private
52
-
53
- attr_reader :mapper, :per_page, :client
54
-
55
- def config
56
- EventStoreClient::Configuration.instance
57
- end
58
-
59
- def initialize
60
- @per_page = config.per_page
61
- @mapper = config.mapper
62
- @client = config.adapter
63
- end
64
-
65
- def read_from_stream(stream, direction:, start:, resolve_links:)
66
- response =
67
- client.read(
68
- stream, start: start, direction: direction, resolve_links: resolve_links
69
- )
70
- return [] if response.body.nil? || response.body.empty?
71
- JSON.parse(response.body)['entries'].map do |entry|
72
- deserialize_event(entry)
73
- end.reverse
74
- end
75
-
76
- def read_all_from_stream(stream, start:, resolve_links:)
77
- count = per_page
78
- events = []
79
- failed_requests_count = 0
80
-
81
- while failed_requests_count < 3
82
- begin
83
- response =
84
- client.read(stream, start: start, direction: 'forward', resolve_links: resolve_links)
85
- failed_requests_count += 1 && next unless response.success? || response.status == 404
86
- rescue Faraday::ConnectionFailed
87
- failed_requests_count += 1
88
- next
89
- end
90
- failed_requests_count = 0
91
- break if response.body.nil? || response.body.empty?
92
- entries = JSON.parse(response.body)['entries']
93
- break if entries.empty?
94
- events += entries.map { |entry| deserialize_event(entry) }.reverse
95
- start += count
96
- end
97
- events
98
- end
99
-
100
- def deserialize_event(entry)
101
- event = EventStoreClient::Event.new(
102
- id: entry['eventId'],
103
- title: entry['title'],
104
- type: entry['eventType'],
105
- data: entry['data'] || '{}',
106
- metadata: entry['isMetaData'] ? entry['metaData'] : '{}'
107
- )
108
-
109
- mapper.deserialize(event)
110
- end
111
- end
112
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'dry-struct'
4
-
5
- module EventStoreClient
6
- class Endpoint < Dry::Struct
7
- attribute :host, Types::String
8
- attribute :port, Types::Coercible::Integer
9
-
10
- def url
11
- "#{host}:#{port}"
12
- end
13
- end
14
- end