ruby_event_store-browser 3.0.1 → 3.1.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/ruby_event_store/browser/app.rb +190 -86
  4. data/lib/ruby_event_store/browser/extension_context.rb +22 -0
  5. data/lib/ruby_event_store/browser/get_events_from_stream.rb +50 -93
  6. data/lib/ruby_event_store/browser/get_events_from_streams.rb +98 -0
  7. data/lib/ruby_event_store/browser/layout.rb +55 -0
  8. data/lib/ruby_event_store/browser/renderer.rb +64 -0
  9. data/lib/ruby_event_store/browser/urls.rb +35 -39
  10. data/lib/ruby_event_store/browser/version.rb +1 -1
  11. data/lib/ruby_event_store/browser/views/_clipboard_icon.html.erb +1 -0
  12. data/lib/ruby_event_store/browser/views/_clock_icon.html.erb +1 -0
  13. data/lib/ruby_event_store/browser/views/_json_tree.html.erb +1 -0
  14. data/lib/ruby_event_store/browser/views/_raw_section.html.erb +9 -0
  15. data/lib/ruby_event_store/browser/views/_timestamp.html.erb +7 -0
  16. data/lib/ruby_event_store/browser/views/events/show.html.erb +85 -0
  17. data/lib/ruby_event_store/browser/views/layout.html.erb +72 -0
  18. data/lib/ruby_event_store/browser/views/not_found.html.erb +3 -0
  19. data/lib/ruby_event_store/browser/views/streams/show.html.erb +74 -0
  20. data/lib/ruby_event_store/browser/views/swimlane/_rows.html.erb +20 -0
  21. data/lib/ruby_event_store/browser/views/swimlane/show.html.erb +55 -0
  22. data/lib/ruby_event_store/browser.rb +17 -5
  23. data/public/ruby_event_store_browser.css +2 -1
  24. data/public/ruby_event_store_browser.js +287 -1
  25. data/public/stimulus-3.2.2.js +11 -0
  26. metadata +21 -11
  27. data/lib/ruby_event_store/browser/gem_source.rb +0 -32
  28. data/lib/ruby_event_store/browser/get_event.rb +0 -37
  29. data/lib/ruby_event_store/browser/get_stream.rb +0 -29
  30. data/lib/ruby_event_store/browser/json_api_event.rb +0 -66
  31. data/lib/ruby_event_store/browser/json_api_stream.rb +0 -34
  32. data/public/bootstrap.js +0 -30
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b419e20de9010f16d9592f1a715216f2c4c74d54c8412be3a99ed9af265ac30
4
- data.tar.gz: 4335f6b699e72f9af66a72a62f3b42c97879611bc149bab757d4655eeb02e6d7
3
+ metadata.gz: 5c34bdfb8bf1ac7bd5ee801add4be05d53e9698f9e62b7dd55ba822ab5210b10
4
+ data.tar.gz: 03ff99bad816c753b0b69554e19233467842ff74d0df7e21e1a36645550b9336
5
5
  SHA512:
6
- metadata.gz: 7282139e311fa44799a4a7f125ecf137d026b65cc255ec6808886a8af19d01c803af3b6d211850c902672a7b49750aa00b76770072d04b1fb271fd66c9cab687
7
- data.tar.gz: 77281b49763b1d6633062c2635a61b63e087007eae1299746b5a74b25d767ad27e2452439ebe4f32cab1e42bd7380f04f6ed8bbfac69ea1b93e4bcf3a6885dc2
6
+ metadata.gz: 851f7a67f73ac05f96b6ac76ce73690783eed6bc71b830b2740a6b529fb4f47396ba2e7490f969648c9fede5c54a975f7e39c94fe6f4231c09f8b803456a7af9
7
+ data.tar.gz: 7a9c17aa335a429247c81561ee301d26af132952b52113a0c439a2b5afbfc5b1c38b77a5154b768b75727f55dfdd8ef1592f3fab840760dde5982e137e31bef0
data/README.md CHANGED
@@ -6,4 +6,4 @@ Find out more at [https://railseventstore.org](https://railseventstore.org/)
6
6
 
7
7
  ## UI Dev mode
8
8
 
9
- `make install clean dev` will auto-compile JS/CSS from ELM in the background and also serve standalone browser on http://localhost:9393
9
+ `make build` compiles the CSS with Tailwind and vendors the pinned Stimulus runtime. `make dev` serves the standalone browser seeded with some events on http://localhost:9393
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "../browser"
4
4
  require "rack"
5
- require "erb"
5
+ require "uri"
6
6
  require "json"
7
7
 
8
8
  module RubyEventStore
@@ -14,37 +14,42 @@ module RubyEventStore
14
14
  path: nil,
15
15
  api_url: nil,
16
16
  environment: nil,
17
- related_streams_query: DEFAULT_RELATED_STREAMS_QUERY
17
+ related_streams_query: DEFAULT_RELATED_STREAMS_QUERY,
18
+ extensions: [],
19
+ views_root: nil
18
20
  )
19
21
  warn(<<~WARN) if environment
20
- Passing :environment to RubyEventStore::Browser::App.for is deprecated.
22
+ Passing :environment to RubyEventStore::Browser::App.for is deprecated.
21
23
 
22
- This option is no-op, has no effect and will be removed in next major release.
23
- WARN
24
+ This option is no-op, has no effect and will be removed in next major release.
25
+ WARN
24
26
  warn(<<~WARN) if host
25
- Passing :host to RubyEventStore::Browser::App.for is deprecated.
27
+ Passing :host to RubyEventStore::Browser::App.for is deprecated.
26
28
 
27
- This option will be removed in next major release.
28
-
29
- Host and mount points are correctly recognized from Rack environment
30
- and this option is redundant.
31
- WARN
29
+ This option will be removed in next major release.
30
+
31
+ Host and mount points are correctly recognized from Rack environment
32
+ and this option is redundant.
33
+ WARN
32
34
  warn(<<~WARN) if path
33
- Passing :path to RubyEventStore::Browser::App.for is deprecated.
35
+ Passing :path to RubyEventStore::Browser::App.for is deprecated.
36
+
37
+ This option will be removed in next major release.
34
38
 
35
- This option will be removed in next major release.
39
+ Host and mount points are correctly recognized from Rack environment
40
+ and this option is redundant.
41
+ WARN
42
+ warn(<<~WARN) if api_url
43
+ Passing :api_url to RubyEventStore::Browser::App.for is deprecated.
36
44
 
37
- Host and mount points are correctly recognized from Rack environment
38
- and this option is redundant.
39
- WARN
45
+ This option is no-op and will be removed in next major release.
46
+ WARN
40
47
 
41
48
  Rack::Builder.new do
42
49
  use Rack::Static,
43
50
  urls:
44
51
  %w[
45
- bootstrap.js
46
- ruby_event_store_browser.css
47
- ruby_event_store_browser.js
52
+ stimulus-3.2.2.js
48
53
  android-chrome-192x192.png
49
54
  android-chrome-512x512.png
50
55
  apple-touch-icon.png
@@ -57,109 +62,208 @@ module RubyEventStore
57
62
  mstile-310x150.png
58
63
  mstile-310x310.png
59
64
  safari-pinned-tab.svg
60
- ].map { |f| ["/#{f}", f] }.to_h,
65
+ ruby_event_store_browser.js
66
+ ruby_event_store_browser.css
67
+ ].map { |f| ["/#{f}", f] }.to_h.merge(
68
+ "/#{BROWSER_JS}" => "ruby_event_store_browser.js",
69
+ "/#{BROWSER_CSS}" => "ruby_event_store_browser.css",
70
+ ),
61
71
  root: "#{__dir__}/../../../public"
72
+ extensions.each_with_index do |extension, index|
73
+ mappings = App.extension_asset_mappings(extension, index)
74
+ use Rack::Static, urls: mappings, root: extension.assets_root unless mappings.empty?
75
+ end
62
76
  run App.new(
63
77
  event_store_locator: event_store_locator,
64
78
  related_streams_query: related_streams_query,
65
79
  host: host,
66
80
  root_path: path,
67
- api_url: api_url,
81
+ extensions: extensions,
82
+ views_root: views_root,
68
83
  )
69
84
  end
70
85
  end
71
86
 
72
- def initialize(event_store_locator:, related_streams_query:, host:, root_path:, api_url:)
87
+ def self.extension_asset_mappings(extension, index)
88
+ return {} unless extension.respond_to?(:assets_root)
89
+
90
+ Dir
91
+ .glob("**/*", base: extension.assets_root)
92
+ .to_h { |relative_path| ["/extension_assets/#{index}/#{relative_path}", relative_path] }
93
+ end
94
+
95
+ def initialize(event_store_locator:, related_streams_query:, host:, root_path:, extensions: [], views_root: nil)
73
96
  @event_store_locator = event_store_locator
74
97
  @related_streams_query = related_streams_query
75
- @routing = Urls.from_configuration(host, root_path, api_url)
98
+ @extensions = extensions
99
+ @views_root = views_root
100
+ @routing = Urls.from_configuration(host, root_path)
76
101
  end
77
102
 
78
103
  def call(env)
104
+ request = Rack::Request.new(env)
79
105
  router = Router.new(routing)
80
- router.add_route("GET", "/api/events/:event_id") do |params|
81
- json GetEvent.new(event_store: event_store, event_id: params.fetch("event_id"))
106
+
107
+ router.add_route("GET", "/") do |_, urls|
108
+ [302, { "location" => urls.stream_url(SERIALIZED_GLOBAL_STREAM_NAME) }, []]
82
109
  end
83
- router.add_route("GET", "/api/streams/:stream_name") do |params, urls|
84
- json GetStream.new(
85
- stream_name: params.fetch("stream_name"),
86
- routing: urls,
87
- related_streams_query: related_streams_query,
88
- )
110
+
111
+ router.add_route("GET", "/streams/:stream_name") do |params, urls|
112
+ stream_name = params.fetch("stream_name")
113
+ reader = GetEventsFromStream.new(event_store: event_store, stream_name: stream_name, page: params["page"])
114
+ layout.render(
115
+ "streams/show",
116
+ urls: urls,
117
+ stream_name: stream_name,
118
+ title: "Stream #{stream_name}",
119
+ events: reader.events,
120
+ pagination:
121
+ reader.pagination.transform_values { |cursor| urls.stream_page_url(stream_name, cursor, reader.count) },
122
+ related_streams: related_streams_query.call(stream_name),
123
+ extension_links: stream_extension_links(stream_name, urls),
124
+ )
89
125
  end
90
- router.add_route("GET", "/api/streams/:stream_name/relationships/events") do |params, urls|
91
- json GetEventsFromStream.new(
92
- event_store: event_store,
93
- routing: urls,
94
- stream_name: params.fetch("stream_name"),
95
- page: params["page"],
96
- )
126
+
127
+ router.add_route("GET", "/events/:event_id") do |params, urls|
128
+ event = event_store.read.event!(params.fetch("event_id"))
129
+ metadata = format_event_metadata(event)
130
+ parent_event =
131
+ event_store.read.event(event.metadata.fetch(:causation_id)) if event.metadata.key?(:causation_id)
132
+
133
+ layout.render(
134
+ "events/show",
135
+ urls: urls,
136
+ event: event,
137
+ title: "Event #{event.event_id}",
138
+ metadata: metadata,
139
+ streams: event_store.streams_of(event.event_id).map(&:name).sort,
140
+ parent_event: parent_event,
141
+ caused_by: event_store.read.stream("$by_causation_id_#{event.event_id}").backward.limit(PAGE_SIZE).to_a,
142
+ extension_links: event_extension_links(event, urls),
143
+ )
97
144
  end
98
145
 
99
- %w[/ /events/:event_id /streams/:stream_name].each do |starting_route|
100
- router.add_route("GET", starting_route) do |_, urls|
101
- erb bootstrap_html,
102
- browser_js_src: urls.browser_js_url,
103
- browser_css_src: urls.browser_css_url,
104
- bootstrap_js_src: urls.bootstrap_js_url,
105
- initial_data: {
106
- rootUrl: urls.app_url,
107
- apiUrl: urls.api_url,
108
- resVersion: res_version,
109
- }
110
- end
146
+ router.add_route("GET", "/swimlane") do |params, urls|
147
+ stream_names, sort = swimlane_params(params)
148
+ reader = GetEventsFromStreams.new(event_store: event_store, stream_names: stream_names, sort: sort)
149
+ missing_stream_names = reader.missing_stream_names
150
+ stream_names -= missing_stream_names
151
+ layout.render(
152
+ "swimlane/show",
153
+ urls: urls,
154
+ stream_names: stream_names,
155
+ missing_stream_names: missing_stream_names,
156
+ title: "Swimlane #{stream_names.join(', ')}",
157
+ events: reader.events,
158
+ sort: sort,
159
+ more_url: (urls.swimlane_more_url(stream_names, reader.next_cursor, sort) if reader.more?),
160
+ )
111
161
  end
112
- router.handle(Rack::Request.new(env))
113
- rescue EventNotFound, Router::NoMatch
114
- not_found
162
+
163
+ router.add_route("GET", "/swimlane/more") do |params, urls|
164
+ stream_names, sort = swimlane_params(params)
165
+ reader =
166
+ GetEventsFromStreams.new(
167
+ event_store: event_store,
168
+ stream_names: stream_names,
169
+ cursor: params["cursor"],
170
+ sort: sort,
171
+ )
172
+ json(
173
+ html:
174
+ layout.render_partial("swimlane/_rows", urls: urls, stream_names: stream_names, events: reader.events, sort: sort),
175
+ more_url: (urls.swimlane_more_url(stream_names, reader.next_cursor, sort) if reader.more?),
176
+ )
177
+ end
178
+
179
+ extensions
180
+ .select { |extension| extension.respond_to?(:register_routes) }
181
+ .each { |extension| extension.register_routes(router, extension_context(extension)) }
182
+
183
+ router.handle(request)
184
+ rescue EventNotFound
185
+ layout.not_found(routing.with_request(request), message: "There's no event with given ID")
186
+ rescue Router::NoMatch
187
+ layout.not_found(routing.with_request(request), message: "Page not found")
115
188
  end
116
189
 
117
190
  private
118
191
 
119
- attr_reader :event_store_locator, :related_streams_query, :routing
192
+ attr_reader :event_store_locator, :related_streams_query, :routing, :extensions, :views_root
120
193
 
121
194
  def event_store
122
195
  event_store_locator.call
123
196
  end
124
197
 
125
- def bootstrap_html
126
- <<~HTML
127
- <!DOCTYPE html>
128
- <html>
129
- <head>
130
- <title>RubyEventStore::Browser</title>
131
- <link type="text/css" rel="stylesheet" href="<%= browser_css_src %>">
132
- <meta name="ruby-event-store-browser-settings" content="<%= Rack::Utils.escape_html(JSON.dump(initial_data)) %>">
133
- <meta name="robots" content="noindex, nofollow">
134
- <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
135
- <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
136
- <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
137
- <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
138
- <meta name="msapplication-TileColor" content="#da532c">
139
- <meta name="theme-color" content="#ffffff">
140
- </head>
141
- <body>
142
- <script type="text/javascript" src="<%= browser_js_src %>"></script>
143
- <script type="text/javascript" src="<%= bootstrap_js_src %>"></script>
144
- </body>
145
- </html>
146
- HTML
147
- end
148
-
149
- def not_found
150
- [404, {}, []]
198
+ def extension_context(extension)
199
+ ExtensionContext.new(event_store, extension_layout(extension))
151
200
  end
152
201
 
153
- def json(body)
154
- [200, { "content-type" => "application/vnd.api+json" }, [JSON.dump(body.to_h)]]
202
+ def extension_layout(extension)
203
+ extension.respond_to?(:views_root) ? layout.with_views_root(extension.views_root) : layout
155
204
  end
156
205
 
157
- def erb(template, **locals)
158
- [200, { "content-type" => "text/html;charset=utf-8" }, [ERB.new(template).result_with_hash(locals)]]
206
+ def layout
207
+ Layout.new(
208
+ method(:extension_stylesheets),
209
+ method(:extension_scripts),
210
+ method(:extension_nav_links),
211
+ views_roots: [views_root].compact,
212
+ )
213
+ end
214
+
215
+ def stream_extension_links(stream_name, urls)
216
+ extensions
217
+ .select { |extension| extension.respond_to?(:stream_links) }
218
+ .flat_map { |extension| extension.stream_links(stream_name, urls) }
219
+ end
220
+
221
+ def event_extension_links(event, urls)
222
+ extensions
223
+ .select { |extension| extension.respond_to?(:event_links) }
224
+ .flat_map { |extension| extension.event_links(event, urls) }
225
+ end
226
+
227
+ def extension_nav_links(urls)
228
+ extensions
229
+ .select { |extension| extension.respond_to?(:nav_links) }
230
+ .flat_map { |extension| extension.nav_links(urls) }
231
+ end
232
+
233
+ def extension_stylesheets(urls)
234
+ extension_asset_urls(urls, ".css")
235
+ end
236
+
237
+ def extension_scripts(urls)
238
+ extension_asset_urls(urls, ".js")
239
+ end
240
+
241
+ def extension_asset_urls(urls, extname)
242
+ extensions.each_with_index.flat_map do |extension, index|
243
+ App
244
+ .extension_asset_mappings(extension, index)
245
+ .keys
246
+ .select { |path| File.extname(path) == extname }
247
+ .map { |path| "#{urls.app_url}#{path}" }
248
+ end
249
+ end
250
+
251
+ def format_event_metadata(event)
252
+ event.metadata.to_h.tap do |metadata|
253
+ %i[timestamp valid_at].each do |key|
254
+ metadata[key] = metadata.fetch(key).iso8601(RubyEventStore::TIMESTAMP_PRECISION) if metadata.key?(key)
255
+ end
256
+ end
257
+ end
258
+
259
+ def json(body)
260
+ [200, { "content-type" => "application/json" }, [JSON.generate(body)]]
159
261
  end
160
262
 
161
- def res_version
162
- RubyEventStore::VERSION
263
+ def swimlane_params(params)
264
+ stream_names = Array(params["streams"]).reject { |name| name.nil? || name.empty? }.uniq
265
+ sort = ("as_of" if params["sort"] == "as_of")
266
+ [stream_names, sort]
163
267
  end
164
268
  end
165
269
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyEventStore
4
+ module Browser
5
+ class ExtensionContext
6
+ attr_reader :event_store
7
+
8
+ def initialize(event_store, layout)
9
+ @event_store = event_store
10
+ @layout = layout
11
+ end
12
+
13
+ def render(template, urls:, title: nil, **locals)
14
+ @layout.render(template, urls: urls, title: title, **locals)
15
+ end
16
+
17
+ def not_found(urls, message: "Page not found")
18
+ @layout.not_found(urls, message: message)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -2,115 +2,72 @@
2
2
 
3
3
  module RubyEventStore
4
4
  module Browser
5
- class GetEventsFromStream
6
- HEAD = Object.new
7
-
8
- def initialize(event_store:, routing:, stream_name:, page:)
9
- @event_store = event_store
10
- @routing = routing
11
- @stream_name = stream_name
12
- @page = page || {}
13
- end
14
-
15
- def to_h
16
- { data: events.map { |e| JsonApiEvent.new(e, nil).to_h }, links: links }
17
- end
18
-
19
- private
20
-
21
- attr_reader :event_store, :routing, :stream_name, :page
5
+ GetEventsFromStream =
6
+ Struct.new(:event_store, :stream_name, :page, keyword_init: true) do
7
+ def initialize(event_store:, stream_name:, page:)
8
+ super(event_store: event_store, stream_name: stream_name, page: page || {})
9
+ end
22
10
 
23
- def events
24
- @events ||=
25
- case direction
26
- when :forward
27
- events_forward(position).reverse
28
- when :backward
29
- events_backward(position)
30
- end
31
- end
11
+ def events
12
+ @events ||=
13
+ case direction
14
+ when :forward
15
+ read(event_store.read, position).reverse
16
+ when :backward
17
+ read(event_store.read.backward, position)
18
+ end
19
+ end
32
20
 
33
- def links
34
- @links ||=
21
+ def pagination
22
+ found = events
35
23
  {}.tap do |h|
36
- if prev_event?
37
- h[:prev] = prev_page_link(events.first.event_id)
38
- h[:first] = first_page_link
24
+ if prev_event?(found)
25
+ h[:prev] = { position: found.first.event_id, direction: :forward }
26
+ h[:first] = { position: :head, direction: :backward }
39
27
  end
40
28
 
41
- if next_event?
42
- h[:next] = next_page_link(events.last.event_id)
43
- h[:last] = last_page_link
29
+ if next_event?(found)
30
+ h[:next] = { position: found.last.event_id, direction: :backward }
31
+ h[:last] = { position: :head, direction: :forward }
44
32
  end
45
33
  end
46
- end
47
-
48
- def events_forward(position)
49
- spec = event_store.read.limit(count)
50
- spec = spec.stream(stream_name) unless stream_name.eql?(SERIALIZED_GLOBAL_STREAM_NAME)
51
- spec = spec.from(position) unless position.equal?(HEAD)
52
- spec.to_a
53
- end
54
-
55
- def events_backward(position)
56
- spec = event_store.read.limit(count).backward
57
- spec = spec.stream(stream_name) unless stream_name.eql?(SERIALIZED_GLOBAL_STREAM_NAME)
58
- spec = spec.from(position) unless position.equal?(HEAD)
59
- spec.to_a
60
- end
61
-
62
- def next_event?
63
- return if events.empty?
64
- events_backward(events.last.event_id).any?
65
- end
66
-
67
- def prev_event?
68
- return if events.empty?
69
- events_forward(events.first.event_id).any?
70
- end
34
+ end
71
35
 
72
- def prev_page_link(event_id)
73
- routing.paginated_events_from_stream_url(id: stream_name, position: event_id, direction: :forward, count: count)
74
- end
36
+ def count
37
+ Integer(page["count"] || PAGE_SIZE)
38
+ end
75
39
 
76
- def next_page_link(event_id)
77
- routing.paginated_events_from_stream_url(
78
- id: stream_name,
79
- position: event_id,
80
- direction: :backward,
81
- count: count,
82
- )
83
- end
40
+ private
84
41
 
85
- def first_page_link
86
- routing.paginated_events_from_stream_url(id: stream_name, position: :head, direction: :backward, count: count)
87
- end
42
+ def read(reader, position)
43
+ spec = reader.limit(count)
44
+ spec = spec.stream(stream_name) unless stream_name.eql?(SERIALIZED_GLOBAL_STREAM_NAME)
45
+ spec = spec.from(position) if position
46
+ spec.to_a
47
+ end
88
48
 
89
- def last_page_link
90
- routing.paginated_events_from_stream_url(id: stream_name, position: :head, direction: :forward, count: count)
91
- end
49
+ def next_event?(found)
50
+ return if found.empty?
51
+ read(event_store.read.backward, found.last.event_id).any?
52
+ end
92
53
 
93
- def count
94
- Integer(page["count"] || PAGE_SIZE)
95
- end
54
+ def prev_event?(found)
55
+ return if found.empty?
56
+ read(event_store.read, found.first.event_id).any?
57
+ end
96
58
 
97
- def direction
98
- case page["direction"]
99
- when "forward"
100
- :forward
101
- else
102
- :backward
59
+ def direction
60
+ case page["direction"]
61
+ when "forward"
62
+ :forward
63
+ else
64
+ :backward
65
+ end
103
66
  end
104
- end
105
67
 
106
- def position
107
- case page["position"]
108
- when "head", nil
109
- HEAD
110
- else
111
- page.fetch("position")
68
+ def position
69
+ page["position"] unless page["position"].nil? || page["position"] == "head"
112
70
  end
113
71
  end
114
- end
115
72
  end
116
73
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyEventStore
4
+ module Browser
5
+ class GetEventsFromStreams
6
+ def initialize(event_store:, stream_names:, cursor: nil, sort: nil, count: PAGE_SIZE)
7
+ @event_store = event_store
8
+ @stream_names = stream_names
9
+ @cursor = cursor
10
+ @sort = sort
11
+ @count = count
12
+ end
13
+
14
+ def events
15
+ @events ||= build_page
16
+ end
17
+
18
+ def more?
19
+ events
20
+ @more
21
+ end
22
+
23
+ def missing_stream_names
24
+ (stream_names - [SERIALIZED_GLOBAL_STREAM_NAME]).select { |name| chunks.fetch(name).empty? }
25
+ end
26
+
27
+ def next_cursor
28
+ events.last && time_of(events.last.last).iso8601(TIMESTAMP_PRECISION)
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :event_store, :stream_names, :cursor, :sort, :count
34
+
35
+ def build_page
36
+ rows = stream_names.flat_map { |name| chunks.fetch(name).map { |event| [name, event] } }
37
+ @more = full_chunks.any?
38
+ return group(rows) unless @more
39
+
40
+ boundary = full_chunks.map { |name| time_of(chunks.fetch(name).last) }.max
41
+ page_rows = rows.select { |_, event| time_of(event) >= boundary }
42
+ group(page_rows + drained_rows(boundary))
43
+ end
44
+
45
+ def chunks
46
+ @chunks ||= stream_names.to_h { |name| [name, read_chunk(name)] }
47
+ end
48
+
49
+ def read_chunk(name)
50
+ scope = time_sorted(stream_scope(name)).backward.limit(count)
51
+ scope = scope.older_than(cursor_time) if cursor_time
52
+ scope.to_a
53
+ end
54
+
55
+ def full_chunks
56
+ stream_names.select { |name| chunks.fetch(name).size == count }
57
+ end
58
+
59
+ def drained_rows(boundary)
60
+ full_chunks
61
+ .select { |name| time_of(chunks.fetch(name).last) == boundary }
62
+ .flat_map { |name| drain(name, boundary).map { |event| [name, event] } }
63
+ end
64
+
65
+ def drain(name, boundary)
66
+ time_sorted(stream_scope(name)).between(boundary..boundary)
67
+ end
68
+
69
+ def stream_scope(name)
70
+ name == SERIALIZED_GLOBAL_STREAM_NAME ? event_store.read : event_store.read.stream(name)
71
+ end
72
+
73
+ def group(rows)
74
+ rows
75
+ .group_by(&:last)
76
+ .map { |event, list| [list.map(&:first).uniq, event] }
77
+ .sort_by { |_, event| [time_of(event), event.event_id] }
78
+ .reverse
79
+ end
80
+
81
+ def time_sorted(scope)
82
+ as_of? ? scope.as_of : scope.as_at
83
+ end
84
+
85
+ def time_of(event)
86
+ event.metadata.fetch(as_of? ? :valid_at : :timestamp)
87
+ end
88
+
89
+ def as_of?
90
+ sort == "as_of"
91
+ end
92
+
93
+ def cursor_time
94
+ @cursor_time ||= cursor && Time.iso8601(cursor)
95
+ end
96
+ end
97
+ end
98
+ end