From 5793f2d9902cea06077c5da8a55c55a6054f03e4 Mon Sep 17 00:00:00 2001 From: pjurewicz Date: Fri, 17 Jul 2026 14:50:16 +0200 Subject: [PATCH] Add ruby_event_store-browser-swimlane extension gem A browser extension comparing several streams side by side: their events merged into one newest-first timeline table, one column per stream, with infinite scroll and a created-at/valid-at sort toggle. An event linked into several compared streams renders once, as a link in each matching column. Each page is composed from existing single-stream reads - one time ordered read per compared stream, k-way merged by event time. Count is the read depth per stream, not the page size: a page emits everything above the completeness horizon (the highest chunk end among the streams whose read was cut), so nothing provably complete is thrown away. The shared cursor is that horizon timestamp and the next page asks for strictly older events - nothing is dropped or repeated. Built entirely on the browser extension API: routes and json responses via register_routes, an entry link via stream_links, its own stimulus controllers via scripts. --- ..._event_store-browser-swimlane_coverage.yml | 54 +++++ ...by_event_store-browser-swimlane_mutate.yml | 53 +++++ ...ruby_event_store-browser-swimlane_test.yml | 55 +++++ .../.mutant.yml | 17 ++ .../ruby_event_store-browser-swimlane/Gemfile | 8 + .../Gemfile.lock | 148 ++++++++++++ .../Makefile | 8 + .../README.md | 14 ++ .../lib/ruby_event_store/browser/swimlane.rb | 95 ++++++++ .../swimlane/get_events_from_streams.rb | 108 +++++++++ .../browser/swimlane/public/swimlane.js | 112 +++++++++ .../browser/swimlane/version.rb | 9 + .../browser/swimlane/views/_rows.html.erb | 20 ++ .../browser/swimlane/views/swimlane.html.erb | 50 ++++ .../ruby_event_store-browser-swimlane.gemspec | 27 +++ .../spec/get_events_from_streams_spec.rb | 218 ++++++++++++++++++ .../spec/spec_helper.rb | 9 + .../spec/swimlane_spec.rb | 213 +++++++++++++++++ support/ci/generate | 3 + 19 files changed, 1221 insertions(+) create mode 100644 .github/workflows/ruby_event_store-browser-swimlane_coverage.yml create mode 100644 .github/workflows/ruby_event_store-browser-swimlane_mutate.yml create mode 100644 .github/workflows/ruby_event_store-browser-swimlane_test.yml create mode 100644 contrib/ruby_event_store-browser-swimlane/.mutant.yml create mode 100644 contrib/ruby_event_store-browser-swimlane/Gemfile create mode 100644 contrib/ruby_event_store-browser-swimlane/Gemfile.lock create mode 100644 contrib/ruby_event_store-browser-swimlane/Makefile create mode 100644 contrib/ruby_event_store-browser-swimlane/README.md create mode 100644 contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane.rb create mode 100644 contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/get_events_from_streams.rb create mode 100644 contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/public/swimlane.js create mode 100644 contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/version.rb create mode 100644 contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/_rows.html.erb create mode 100644 contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/swimlane.html.erb create mode 100644 contrib/ruby_event_store-browser-swimlane/ruby_event_store-browser-swimlane.gemspec create mode 100644 contrib/ruby_event_store-browser-swimlane/spec/get_events_from_streams_spec.rb create mode 100644 contrib/ruby_event_store-browser-swimlane/spec/spec_helper.rb create mode 100644 contrib/ruby_event_store-browser-swimlane/spec/swimlane_spec.rb diff --git a/.github/workflows/ruby_event_store-browser-swimlane_coverage.yml b/.github/workflows/ruby_event_store-browser-swimlane_coverage.yml new file mode 100644 index 0000000000..6f2b56f54b --- /dev/null +++ b/.github/workflows/ruby_event_store-browser-swimlane_coverage.yml @@ -0,0 +1,54 @@ +name: ruby_event_store-browser-swimlane_coverage +on: + workflow_dispatch: + repository_dispatch: + types: + - script + push: + branches: + - master + paths: + - contrib/ruby_event_store-browser-swimlane/Gemfile.lock + - ".github/workflows/ruby_event_store-browser-swimlane_coverage.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" + pull_request: + paths: + - contrib/ruby_event_store-browser-swimlane/Gemfile.lock + - ".github/workflows/ruby_event_store-browser-swimlane_coverage.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" + schedule: + - cron: 0 17 * * * +jobs: + coverage: + runs-on: macos-14 + timeout-minutes: 120 + env: + WORKING_DIRECTORY: contrib/ruby_event_store-browser-swimlane + RUBY_VERSION: "${{ matrix.ruby_version }}" + BUNDLE_GEMFILE: "${{ matrix.bundle_gemfile }}" + BUNDLE_WITHOUT: database + strategy: + fail-fast: false + matrix: + include: + - ruby_version: ruby-4.0 + bundle_gemfile: Gemfile + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - run: test -e ${{ env.BUNDLE_GEMFILE }}.lock + working-directory: "${{ env.WORKING_DIRECTORY }}" + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "${{ env.RUBY_VERSION }}" + bundler-cache: true + working-directory: "${{ env.WORKING_DIRECTORY }}" + - run: make mutate + working-directory: "${{ env.WORKING_DIRECTORY }}" + env: + RUBYOPT: "--enable-frozen-string-literal" \ No newline at end of file diff --git a/.github/workflows/ruby_event_store-browser-swimlane_mutate.yml b/.github/workflows/ruby_event_store-browser-swimlane_mutate.yml new file mode 100644 index 0000000000..2931f82c99 --- /dev/null +++ b/.github/workflows/ruby_event_store-browser-swimlane_mutate.yml @@ -0,0 +1,53 @@ +name: ruby_event_store-browser-swimlane_mutate +on: + workflow_dispatch: + repository_dispatch: + types: + - script + push: + branches: + - master + paths: + - contrib/ruby_event_store-browser-swimlane/** + - ".github/workflows/ruby_event_store-browser-swimlane_mutate.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" + pull_request: + paths: + - contrib/ruby_event_store-browser-swimlane/** + - ".github/workflows/ruby_event_store-browser-swimlane_mutate.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" +jobs: + mutate: + runs-on: macos-14 + timeout-minutes: 120 + env: + WORKING_DIRECTORY: contrib/ruby_event_store-browser-swimlane + RUBY_VERSION: "${{ matrix.ruby_version }}" + BUNDLE_GEMFILE: "${{ matrix.bundle_gemfile }}" + BUNDLE_WITHOUT: database + SINCE_SHA: "${{ github.event.pull_request.base.sha || 'HEAD~1' }}" + strategy: + fail-fast: false + matrix: + include: + - ruby_version: ruby-4.0 + bundle_gemfile: Gemfile + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: test -e ${{ env.BUNDLE_GEMFILE }}.lock + working-directory: "${{ env.WORKING_DIRECTORY }}" + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "${{ env.RUBY_VERSION }}" + bundler-cache: true + working-directory: "${{ env.WORKING_DIRECTORY }}" + - run: make mutate-changes + working-directory: "${{ env.WORKING_DIRECTORY }}" + env: + RUBYOPT: "--enable-frozen-string-literal" \ No newline at end of file diff --git a/.github/workflows/ruby_event_store-browser-swimlane_test.yml b/.github/workflows/ruby_event_store-browser-swimlane_test.yml new file mode 100644 index 0000000000..6a1a75eb99 --- /dev/null +++ b/.github/workflows/ruby_event_store-browser-swimlane_test.yml @@ -0,0 +1,55 @@ +name: ruby_event_store-browser-swimlane_test +on: + workflow_dispatch: + repository_dispatch: + types: + - script + push: + branches: + - master + paths: + - contrib/ruby_event_store-browser-swimlane/** + - ".github/workflows/ruby_event_store-browser-swimlane_test.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" + pull_request: + paths: + - contrib/ruby_event_store-browser-swimlane/** + - ".github/workflows/ruby_event_store-browser-swimlane_test.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + WORKING_DIRECTORY: contrib/ruby_event_store-browser-swimlane + RUBY_VERSION: "${{ matrix.ruby_version }}" + BUNDLE_GEMFILE: "${{ matrix.bundle_gemfile }}" + strategy: + fail-fast: false + matrix: + include: + - ruby_version: ruby-4.0 + bundle_gemfile: Gemfile + - ruby_version: ruby-3.4 + bundle_gemfile: Gemfile + - ruby_version: ruby-3.3 + bundle_gemfile: Gemfile + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - run: test -e ${{ env.BUNDLE_GEMFILE }}.lock + working-directory: "${{ env.WORKING_DIRECTORY }}" + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "${{ env.RUBY_VERSION }}" + bundler-cache: true + working-directory: "${{ env.WORKING_DIRECTORY }}" + - run: make test + working-directory: "${{ env.WORKING_DIRECTORY }}" + env: + RUBYOPT: "--enable-frozen-string-literal" \ No newline at end of file diff --git a/contrib/ruby_event_store-browser-swimlane/.mutant.yml b/contrib/ruby_event_store-browser-swimlane/.mutant.yml new file mode 100644 index 0000000000..856e62a86c --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/.mutant.yml @@ -0,0 +1,17 @@ +# https://github.com/mbj/mutant/blob/master/docs/configuration.md + +usage: opensource +requires: + - ruby_event_store/browser/app + - ruby_event_store/browser/swimlane +includes: + - lib +integration: + name: rspec +mutation: + operators: light +coverage_criteria: + process_abort: true +matcher: + subjects: + - RubyEventStore::Browser::Swimlane* diff --git a/contrib/ruby_event_store-browser-swimlane/Gemfile b/contrib/ruby_event_store-browser-swimlane/Gemfile new file mode 100644 index 0000000000..3f019394a9 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/Gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +source "https://rubygems.org" +gemspec + +eval_gemfile "../../support/bundler/Gemfile.shared" +gem "ruby_event_store", path: "../.." +gem "ruby_event_store-browser", path: "../.." diff --git a/contrib/ruby_event_store-browser-swimlane/Gemfile.lock b/contrib/ruby_event_store-browser-swimlane/Gemfile.lock new file mode 100644 index 0000000000..cf5d659edf --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/Gemfile.lock @@ -0,0 +1,148 @@ +PATH + remote: ../.. + specs: + ruby_event_store (3.0.0) + concurrent-ruby (~> 1.0, >= 1.1.6) + ruby_event_store-browser (3.0.0) + rack + ruby_event_store (= 3.0.0) + +PATH + remote: . + specs: + ruby_event_store-browser-swimlane (0.1.0) + ruby_event_store (>= 2.0.0) + ruby_event_store-browser (>= 3.0.0) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + concurrent-ruby (1.3.7) + diff-lcs (1.6.2) + drb (2.2.3) + erb (6.0.5) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + logger (1.7.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + mutant (0.16.3) + diff-lcs (>= 1.6, < 3) + irb (~> 1.15) + parser (~> 3.3.10) + regexp_parser (~> 2.10) + securerandom (>= 0.3) + sorbet-runtime (~> 0.6.0) + unparser (>= 0.8.2, < 0.10) + mutant-minitest (0.16.3) + minitest (>= 5.11, < 7) + mutant (= 0.16.3) + mutex_m (~> 0.2) + mutant-rspec (0.16.3) + mutant (= 0.16.3) + rspec-core (>= 3.8.0, < 5.0.0) + mutex_m (0.3.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + racc (1.8.1) + rack (3.2.6) + rake (13.4.2) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + securerandom (0.4.1) + sorbet-runtime (0.6.13342) + tsort (0.2.0) + unparser (0.9.0) + diff-lcs (>= 1.6, < 3) + parser (>= 3.3.0) + prism (>= 1.5.1) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + irb + mutant + mutant-minitest + mutant-rspec + rake (>= 10.0) + rspec + ruby_event_store! + ruby_event_store-browser! + ruby_event_store-browser-swimlane! + +CHECKSUMS + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.5) sha256=858e63488cb796c9daba8b6e9ff4b3879c395022049be9a66a8e00980e612eac + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + mutant (0.16.3) sha256=7e10fb09b80a78d82f8e38283fc1e4d562cbb1392451e2a21611ea2bc44b5023 + mutant-minitest (0.16.3) sha256=504d110c1bd503528a7e57a4f062960e571bba4e3939e4a66ddc0f75d2071d90 + mutant-rspec (0.16.3) sha256=006ee9640800f205278604bb2160ca4b1d0013464fb216a48b7c09571a1fc547 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.0.3) sha256=5a7bf70e2628549d9a1f44eae447b2cfe55968a9c60cfff52693a4bdcc020e14 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + ruby_event_store (3.0.0) + ruby_event_store-browser (3.0.0) + ruby_event_store-browser-swimlane (0.1.0) + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + sorbet-runtime (0.6.13342) sha256=dbeb9892597f593ed2b29e38018912787f6c4e0c4b736d8af0d6bd3c63bde3c3 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + unparser (0.9.0) sha256=4331f174a73a23b69250b13d47da3794ed1449711ee0f9ed8947dc020ba76067 + +BUNDLED WITH + 4.0.10 diff --git a/contrib/ruby_event_store-browser-swimlane/Makefile b/contrib/ruby_event_store-browser-swimlane/Makefile new file mode 100644 index 0000000000..b6f8d785c6 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/Makefile @@ -0,0 +1,8 @@ +GEM_VERSION = $(shell cat lib/ruby_event_store/browser/swimlane/version.rb | grep VERSION | egrep -o '[0-9]+\.[0-9]+\.[0-9]+') +GEM_NAME = ruby_event_store-browser-swimlane + +include ../../support/make/install.mk +include ../../support/make/test.mk +include ../../support/make/mutant.mk +include ../../support/make/gem.mk +include ../../support/make/help.mk diff --git a/contrib/ruby_event_store-browser-swimlane/README.md b/contrib/ruby_event_store-browser-swimlane/README.md new file mode 100644 index 0000000000..89babb4973 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/README.md @@ -0,0 +1,14 @@ +# RubyEventStore::Browser::Swimlane + +A [RubyEventStore browser](https://railseventstore.org) extension comparing several streams side by side: their events merged into one newest-first timeline table, one column per stream, with infinite scroll. An event linked into several compared streams renders once, as a link in each matching column. The timeline can be sorted along either axis of the bi-temporal model — created-at (default) or valid-at. + +## Usage + +```ruby +mount RubyEventStore::Browser::App.for( + event_store_locator: -> { Rails.configuration.event_store }, + extensions: [RubyEventStore::Browser::Swimlane.new], +) => "/res" +``` + +A `Compare` link shows up on every stream page; the comparison itself lives under `/swimlane?streams[]=first&streams[]=second`. diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane.rb b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane.rb new file mode 100644 index 0000000000..82060f8a70 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "uri" +require_relative "swimlane/version" +require_relative "swimlane/get_events_from_streams" + +module RubyEventStore + module Browser + # Browser extension comparing several streams side by side: their events + # merged into one newest-first timeline table, one column per stream, + # with infinite scroll. + class Swimlane + VIEWS_ROOT = File.expand_path("swimlane/views", __dir__) + PUBLIC_ROOT = File.expand_path("swimlane/public", __dir__) + + def register_routes(router, context) + router.add_route("GET", "/swimlane") do |params, urls| + stream_names, sort = read_params(params) + reader = GetEventsFromStreams.new(event_store: context.event_store, stream_names: stream_names, sort: sort) + + context.render( + "swimlane", + views_root: VIEWS_ROOT, + urls: urls, + swimlane: self, + stream_names: stream_names, + events: reader.events, + sort: sort, + more_url: (more_url(urls, stream_names, reader.next_cursor, sort) if reader.more?), + ) + end + + router.add_route("GET", "/swimlane/more") do |params, urls| + stream_names, sort = read_params(params) + reader = + GetEventsFromStreams.new( + event_store: context.event_store, + stream_names: stream_names, + cursor: params["cursor"], + sort: sort, + ) + + context.json( + html: + context.render_partial( + "_rows", + views_root: VIEWS_ROOT, + urls: urls, + stream_names: stream_names, + events: reader.events, + sort: sort, + ), + more_url: (more_url(urls, stream_names, reader.next_cursor, sort) if reader.more?), + ) + end + + router.add_route("GET", "/swimlane/swimlane.js") do |_, _| + [200, { "content-type" => "text/javascript" }, [File.read(File.join(PUBLIC_ROOT, "swimlane.js"))]] + end + end + + def scripts(urls) + ["#{urls.app_url}/swimlane/swimlane.js"] + end + + def stream_links(stream_name, urls) + [{ label: "Streamline", url: swimlane_url(urls, [stream_name]) }] + end + + def swimlane_url(urls, stream_names, sort = nil) + "#{urls.app_url}/swimlane?#{query(stream_names, sort)}" + end + + def more_url(urls, stream_names, cursor, sort) + query = query(stream_names, sort, [["cursor", cursor]]) + "#{urls.app_url}/swimlane/more?#{query}" + end + + private + + def read_params(params) + stream_names = Array(params["streams"]).reject { |name| name.nil? || name.empty? }.uniq + sort = ("as_of" if params["sort"] == "as_of") + [stream_names, sort] + end + + def query(stream_names, sort, extra = []) + pairs = stream_names.map { |name| ["streams[]", name] } + pairs.concat(extra) + pairs << ["sort", sort] if sort + URI.encode_www_form(pairs) + end + end + end +end diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/get_events_from_streams.rb b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/get_events_from_streams.rb new file mode 100644 index 0000000000..c5a651ffe5 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/get_events_from_streams.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + class Swimlane + # Reads the next page of events across several streams merged into one + # newest-first timeline, using only the single-stream read API. Each + # stream is read in time order — as_at by default, as_of when sorting + # by validity — so the per-stream pages are sorted by the same key the + # merge uses and can be zipped together correctly. + # + # Count is the read depth per stream, not the page size: a page emits + # everything above the completeness horizon — the highest chunk end + # among the streams whose read was cut by count. Above that line every + # stream is fully known, so the page carries between count and + # count times the number of streams events, and the next page can ask + # for strictly older ones with nothing dropped or repeated. The shared + # cursor is that horizon timestamp. + GetEventsFromStreams = + Struct.new(:event_store, :stream_names, :cursor, :sort, :count, keyword_init: true) do + def initialize(event_store:, stream_names:, cursor: nil, sort: nil, count: PAGE_SIZE) + super(event_store: event_store, stream_names: stream_names, cursor: cursor, sort: sort, count: count) + end + + def events + @events ||= build_page + end + + def more? + events + @more + end + + def next_cursor + events.last && time_of(events.last.last).iso8601(TIMESTAMP_PRECISION) + end + + private + + def build_page + rows = stream_names.flat_map { |name| chunks[name].map { |event| [name, event] } } + @more = full_chunks.any? + return group(rows) unless @more + + boundary = full_chunks.map { |name| time_of(chunks[name].last) }.max + page_rows = rows.select { |_, event| time_of(event) >= boundary } + group(page_rows + drained_rows(boundary)) + end + + def chunks + @chunks ||= stream_names.to_h { |name| [name, read_chunk(name)] } + end + + def read_chunk(name) + scope = time_sorted(stream_scope(name)).backward.limit(count) + scope = scope.older_than(cursor_time) if cursor_time + scope.to_a + end + + def full_chunks + stream_names.select { |name| chunks[name].size == count } + end + + # A full chunk cut exactly at the boundary timestamp may have left + # some of that timestamp's events behind — fetch the whole group. + def drained_rows(boundary) + full_chunks + .select { |name| time_of(chunks[name].last) == boundary } + .flat_map { |name| drain(name, boundary).map { |event| [name, event] } } + end + + def drain(name, boundary) + time_sorted(stream_scope(name)).between(boundary..boundary).to_a + end + + # The browser addresses the global stream by its serialized alias, + # which is not a stream name a repository would recognize. + def stream_scope(name) + name == SERIALIZED_GLOBAL_STREAM_NAME ? event_store.read : event_store.read.stream(name) + end + + def group(rows) + rows + .group_by { |_, event| event.event_id } + .map { |_, list| [list.map(&:first).uniq, list.first.last] } + .sort_by { |_, event| [time_of(event), event.event_id] } + .reverse + end + + def time_sorted(scope) + as_of? ? scope.as_of : scope.as_at + end + + def time_of(event) + event.metadata.fetch(as_of? ? :valid_at : :timestamp) + end + + def as_of? + sort == "as_of" + end + + def cursor_time + @cursor_time ||= cursor && Time.iso8601(cursor) + end + end + end + end +end diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/public/swimlane.js b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/public/swimlane.js new file mode 100644 index 0000000000..a73761ac74 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/public/swimlane.js @@ -0,0 +1,112 @@ +import { Application, Controller } from "../stimulus-3.2.2.js" + +const application = Application.start() + +application.register( + "swimlane", + class extends Controller { + static targets = ["tbody", "time"] + static values = { moreUrl: String } + + get storageKey() { + return "ruby_event_store_browser.timezone" + } + + connect() { + this.onScroll = () => this.catchUp() + window.addEventListener("scroll", this.onScroll, { passive: true }) + this.onZoneChange = (event) => { + if (event.target.matches('[data-timezone-target="select"]')) this.renderTimes(event.target.value) + } + document.addEventListener("change", this.onZoneChange) + this.renderTimes(this.zone()) + this.catchUp() + } + + disconnect() { + window.removeEventListener("scroll", this.onScroll) + document.removeEventListener("change", this.onZoneChange) + } + + // Rows are just a plain table — the browser aligns columns and draws row + // borders on its own, no layout math needed here. One shared cursor for + // every stream at once means there's nothing to fall behind — just check + // whether we're near the bottom of the page after every scroll and after + // every load. + catchUp() { + if (!this.moreUrlValue) return + if (document.body.scrollHeight - (window.scrollY + window.innerHeight) > 200) return + this.loadMore() + } + + loadMore() { + const url = this.moreUrlValue + if (!url) return + this.moreUrlValue = "" + + fetch(url, { headers: { Accept: "application/json" } }) + .then((response) => response.json()) + .then(({ html, more_url }) => { + this.tbodyTarget.insertAdjacentHTML("beforeend", html) + this.moreUrlValue = more_url || "" + this.renderTimes(this.zone()) + this.catchUp() + }) + } + + zone() { + const stored = localStorage.getItem(this.storageKey) + const detected = Intl.DateTimeFormat().resolvedOptions().timeZone + try { + Intl.DateTimeFormat("en-US", { timeZone: stored }) + return stored || detected + } catch (_) { + return detected + } + } + + renderTimes(tz) { + this.timeTargets.forEach((el) => { + el.textContent = this.format(el.dataset.iso, tz) + el.setAttribute("title", tz) + }) + } + + format(iso, tz) { + const parts = Object.fromEntries( + new Intl.DateTimeFormat("en-US", { + timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", + fractionalSecondDigits: 3, hour12: false, + }).formatToParts(new Date(iso)).map((p) => [p.type, p.value]) + ) + return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond}` + } + }, +) + +application.register( + "swimlane-add", + class extends Controller { + static targets = ["dialog", "input"] + + open(event) { + event?.preventDefault() + this.dialogTarget.showModal() + this.inputTarget.focus() + } + + close() { + this.dialogTarget.close() + } + + go(event) { + event.preventDefault() + const name = this.inputTarget.value + if (!name) return + const url = new URL(window.location.href) + url.searchParams.append("streams[]", name) + window.location = url.toString() + } + }, +) diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/version.rb b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/version.rb new file mode 100644 index 0000000000..f0cae4a572 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/version.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + class Swimlane + VERSION = "0.1.0" + end + end +end diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/_rows.html.erb b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/_rows.html.erb new file mode 100644 index 0000000000..791c0aaaeb --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/_rows.html.erb @@ -0,0 +1,20 @@ +<% time_field = sort == "as_of" ? :valid_at : :timestamp -%> +<% events.each_with_index do |(names, event), index| -%> + <% next_event = events[index + 1]&.last -%> + <% same_group = next_event && next_event.metadata[time_field].utc.iso8601(3) == event.metadata[time_field].utc.iso8601(3) -%> + <% cell = "align-middle px-2 py-1#{" border-b border-gray-300" unless same_group}" -%> + + + <% unless same_group -%> + <%= event.metadata[time_field].utc.strftime("%Y-%m-%dT%H:%M:%S.%3N") %> + <% end -%> + + <% stream_names.each_with_index do |stream_name, column| -%> + <%= " hover:bg-gray-100" if names.include?(stream_name) %>"> + <% if names.include?(stream_name) -%> + <%= h(event.event_type) %> + <% end -%> + + <% end -%> + +<% end -%> diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/swimlane.html.erb b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/swimlane.html.erb new file mode 100644 index 0000000000..b667e4020f --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/swimlane.html.erb @@ -0,0 +1,50 @@ +
+
+

Comparing <%= h(stream_names.join(", ")) %>

+ +
+
+ Sort by + <% if sort == "as_of" -%> + created at + valid at + <% else -%> + created at + ">valid at + <% end -%> +
+ +
+ + + + + <% stream_names.each_with_index do |stream_name, column| -%> + + <% end -%> + + + + <%= render("_rows", urls: urls, stream_names: stream_names, events: events, sort: sort) %> + +
Time"> +
+ <%= h(stream_name) %> + <% if stream_names.size > 1 -%> + × + <% end -%> +
+
+
+ + + +
+
+
+ +
+
+
+
+
diff --git a/contrib/ruby_event_store-browser-swimlane/ruby_event_store-browser-swimlane.gemspec b/contrib/ruby_event_store-browser-swimlane/ruby_event_store-browser-swimlane.gemspec new file mode 100644 index 0000000000..4d26ae2d3f --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/ruby_event_store-browser-swimlane.gemspec @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "lib/ruby_event_store/browser/swimlane/version" + +Gem::Specification.new do |spec| + spec.name = "ruby_event_store-browser-swimlane" + spec.version = RubyEventStore::Browser::Swimlane::VERSION + spec.license = "MIT" + spec.author = "Arkency" + spec.email = "dev@arkency.com" + spec.summary = "Stream comparison view for the RubyEventStore browser" + spec.homepage = "https://railseventstore.org" + spec.files = Dir["lib/**/*"] + spec.require_paths = ["lib"] + spec.extra_rdoc_files = %w[README.md] + spec.metadata = { + "homepage_uri" => spec.homepage, + "source_code_uri" => "https://github.com/RailsEventStore/rails_event_store", + "bug_tracker_uri" => "https://github.com/RailsEventStore/rails_event_store/issues", + "rubygems_mfa_required" => "true" + } + + spec.required_ruby_version = ">= 3.2" + + spec.add_dependency "ruby_event_store", ">= 2.0.0" + spec.add_dependency "ruby_event_store-browser", ">= 3.0.0" +end diff --git a/contrib/ruby_event_store-browser-swimlane/spec/get_events_from_streams_spec.rb b/contrib/ruby_event_store-browser-swimlane/spec/get_events_from_streams_spec.rb new file mode 100644 index 0000000000..d40f87bbfa --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/spec/get_events_from_streams_spec.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + module Browser + ::RSpec.describe Swimlane::GetEventsFromStreams do + let(:event_store) { RubyEventStore::Client.new } + let(:base_time) { Time.utc(2024, 1, 1, 12, 0, 0) } + + def reader(stream_names:, cursor: nil, sort: nil, count: 20) + Swimlane::GetEventsFromStreams.new( + event_store: event_store, + stream_names: stream_names, + cursor: cursor, + sort: sort, + count: count, + ) + end + + def next_page(previous, stream_names:, count:, sort: nil) + reader(stream_names: stream_names, cursor: previous.next_cursor, sort: sort, count: count) + end + + def publish(stream:, at: nil, valid_at: nil) + event = DummyEvent.new + if at + event.metadata[:timestamp] = at + event.metadata[:valid_at] = valid_at || at + end + event_store.publish(event, stream_name: stream) + event + end + + specify "merges events from multiple streams, newest first, tagged with their owning stream" do + a1 = publish(stream: "a", at: base_time + 1) + b1 = publish(stream: "b", at: base_time + 2) + a2 = publish(stream: "a", at: base_time + 3) + + result = reader(stream_names: %w[a b]).events.map { |names, event| [names, event.event_id] } + expect(result).to eq([[["a"], a2.event_id], [["b"], b1.event_id], [["a"], a1.event_id]]) + end + + specify "orders by event time, not by position in the stream" do + newer = publish(stream: "a", at: base_time + 2) + older = publish(stream: "a", at: base_time + 1) + + result = reader(stream_names: %w[a]).events.map { |_, event| event.event_id } + expect(result).to eq([newer.event_id, older.event_id]) + end + + specify "a single stream page is capped at count" do + 6.times { |i| publish(stream: "a", at: base_time + i) } + expect(reader(stream_names: %w[a], count: 3).events.size).to eq(3) + end + + specify "a page emits everything above the completeness horizon, not just count events" do + a1 = publish(stream: "a", at: base_time + 1) + b2 = publish(stream: "b", at: base_time + 2) + a3 = publish(stream: "a", at: base_time + 3) + b4 = publish(stream: "b", at: base_time + 4) + + page = reader(stream_names: %w[a b], count: 2) + expect(page.events.map { |_, event| event.event_id }).to eq([b4, a3, b2].map(&:event_id)) + expect(page.more?).to eq(true) + + rest = next_page(page, stream_names: %w[a b], count: 2) + expect(rest.events.map { |_, event| event.event_id }).to eq([a1.event_id]) + expect(rest.more?).to eq(false) + end + + specify "more? is true when the page is full" do + 3.times { |i| publish(stream: "a", at: base_time + i) } + expect(reader(stream_names: %w[a], count: 3).more?).to eq(true) + end + + specify "streams read to exhaustion merge into a single page" do + 2.times { |i| publish(stream: "a", at: base_time + i) } + 2.times { |i| publish(stream: "b", at: base_time + 10 + i) } + + page = reader(stream_names: %w[a b], count: 3) + expect(page.events.size).to eq(4) + expect(page.more?).to eq(false) + end + + specify "more? is false once every stream is exhausted" do + 3.times { |i| publish(stream: "a", at: base_time + i) } + expect(reader(stream_names: %w[a], count: 20).more?).to eq(false) + end + + specify "next_cursor is the last returned event's timestamp" do + publish(stream: "a", at: base_time + 1) + a2 = publish(stream: "a", at: base_time + 2) + a3 = publish(stream: "a", at: base_time + 3) + + page = reader(stream_names: %w[a], count: 2) + expect(page.events.map { |_, event| event.event_id }).to eq([a3.event_id, a2.event_id]) + expect(page.next_cursor).to eq((base_time + 2).iso8601(TIMESTAMP_PRECISION)) + end + + specify "continues below the cursor timestamp" do + a1 = publish(stream: "a", at: base_time + 1) + a2 = publish(stream: "a", at: base_time + 2) + a3 = publish(stream: "a", at: base_time + 3) + + page = reader(stream_names: %w[a], count: 2) + rest = next_page(page, stream_names: %w[a], count: 2) + expect(page.events.map { |_, event| event.event_id }).to eq([a3.event_id, a2.event_id]) + expect(rest.events.map { |_, event| event.event_id }).to eq([a1.event_id]) + expect(rest.more?).to eq(false) + end + + specify "does not skip an event from another stream sharing the boundary timestamp" do + old = publish(stream: "a", at: base_time + 1) + tied_a = publish(stream: "a", at: base_time + 2) + tied_b = publish(stream: "b", at: base_time + 2) + newest = publish(stream: "b", at: base_time + 3) + + page = reader(stream_names: %w[a b], count: 2) + rest = next_page(page, stream_names: %w[a b], count: 2) + + expect(page.events.map { |_, event| event.event_id }).to match_array( + [newest, tied_a, tied_b].map(&:event_id), + ) + expect(rest.events.map { |_, event| event.event_id }).to eq([old.event_id]) + end + + specify "a page runs to the end of its last timestamp instead of splitting the group" do + published = 7.times.map { publish(stream: "a", at: base_time) } + + page = reader(stream_names: %w[a], count: 3) + expect(page.events.map { |_, event| event.event_id }).to match_array(published.map(&:event_id)) + + rest = next_page(page, stream_names: %w[a], count: 3) + expect(rest.events).to eq([]) + expect(rest.more?).to eq(false) + end + + specify "pages through mixed unique and tied timestamps without drops or duplicates" do + published = 3.times.map { |i| publish(stream: "a", at: base_time + i) } + published += 4.times.map { publish(stream: "a", at: base_time + 10) } + published += 2.times.map { |i| publish(stream: "b", at: base_time + 20 + i) } + + pages = [reader(stream_names: %w[a b], count: 3)] + pages << next_page(pages.last, stream_names: %w[a b], count: 3) while pages.last.more? + + emitted = pages.flat_map { |page| page.events.map { |_, event| event.event_id } } + expect(emitted).to match_array(published.map(&:event_id)) + end + + specify "an event linked into more than one compared stream is one row shown in both columns, not duplicated" do + a1 = publish(stream: "a", at: base_time) + event_store.link([a1.event_id], stream_name: "b") + + result = reader(stream_names: %w[a b]).events + expect(result.map { |_, event| event.event_id }).to eq([a1.event_id]) + expect(result.first.first).to match_array(%w[a b]) + end + + specify "does not repeat a stream in the columns of an event fetched twice" do + 3.times { publish(stream: "a", at: base_time) } + + result = reader(stream_names: %w[a], count: 3).events + expect(result.map(&:first)).to eq([["a"], ["a"], ["a"]]) + end + + specify "sorts by validity time when sort is as_of" do + e1 = publish(stream: "a", at: base_time + 3, valid_at: base_time + 11) + e2 = publish(stream: "b", at: base_time + 2, valid_at: base_time + 12) + e3 = publish(stream: "a", at: base_time + 1, valid_at: base_time + 13) + + by_append = reader(stream_names: %w[a b]).events.map { |_, event| event.event_id } + by_validity = reader(stream_names: %w[a b], sort: "as_of").events.map { |_, event| event.event_id } + expect(by_append).to eq([e1.event_id, e2.event_id, e3.event_id]) + expect(by_validity).to eq([e3.event_id, e2.event_id, e1.event_id]) + end + + specify "as_of cursor pages along the validity axis" do + e1 = publish(stream: "a", at: base_time + 3, valid_at: base_time + 11) + e2 = publish(stream: "a", at: base_time + 2, valid_at: base_time + 12) + e3 = publish(stream: "a", at: base_time + 1, valid_at: base_time + 13) + + page = reader(stream_names: %w[a], sort: "as_of", count: 2) + expect(page.events.map { |_, event| event.event_id }).to eq([e3.event_id, e2.event_id]) + expect(page.next_cursor).to eq((base_time + 12).iso8601(TIMESTAMP_PRECISION)) + + rest = next_page(page, stream_names: %w[a], sort: "as_of", count: 2) + expect(rest.events.map { |_, event| event.event_id }).to eq([e1.event_id]) + expect(rest.more?).to eq(false) + end + + specify "as_of pages run to the end of their last validity time instead of splitting the group" do + published = 4.times.map { |i| publish(stream: "a", at: base_time + i, valid_at: base_time + 10) } + + page = reader(stream_names: %w[a], sort: "as_of", count: 3) + expect(page.events.map { |_, event| event.event_id }).to match_array(published.map(&:event_id)) + end + + specify "the global stream alias reads all events, not a stream named all" do + a1 = publish(stream: "orders", at: base_time + 1) + a2 = publish(stream: "shipments", at: base_time + 2) + + result = reader(stream_names: %w[all orders]).events.map { |names, event| [names, event.event_id] } + expect(result).to eq([[%w[all], a2.event_id], [%w[all orders], a1.event_id]]) + end + + specify "reads each stream once per page" do + publish(stream: "a", at: base_time) + publish(stream: "b", at: base_time + 1) + + allow(event_store).to receive(:read).and_call_original + reader(stream_names: %w[a b c]).events + + expect(event_store).to have_received(:read).exactly(3).times + end + end + end +end diff --git a/contrib/ruby_event_store-browser-swimlane/spec/spec_helper.rb b/contrib/ruby_event_store-browser-swimlane/spec/spec_helper.rb new file mode 100644 index 0000000000..f486473938 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/spec/spec_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "../../../support/helpers/rspec_defaults" +require "ruby_event_store" +require "ruby_event_store/browser/app" +require "ruby_event_store/browser/swimlane" +require "rack" + +DummyEvent = Class.new(::RubyEventStore::Event) diff --git a/contrib/ruby_event_store-browser-swimlane/spec/swimlane_spec.rb b/contrib/ruby_event_store-browser-swimlane/spec/swimlane_spec.rb new file mode 100644 index 0000000000..676c079feb --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/spec/swimlane_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "spec_helper" +require "json" +require "uri" + +module RubyEventStore + module Browser + ::RSpec.describe Swimlane do + let(:event_store) { RubyEventStore::Client.new } + let(:app) { App.for(event_store_locator: -> { event_store }, extensions: [Swimlane.new]) } + let(:client) { Rack::MockRequest.new(Rack::Lint.new(app)) } + let(:base_time) { Time.utc(2024, 1, 1, 12, 0, 0) } + + def event_at(time, valid_at: nil) + DummyEvent.new.tap do |event| + event.metadata[:timestamp] = time + event.metadata[:valid_at] = valid_at if valid_at + end + end + + def more_url(stream_names, cursor_time, sort = nil) + pairs = stream_names.map { |name| ["streams[]", name] } + pairs << ["cursor", cursor_time.iso8601(TIMESTAMP_PRECISION)] + pairs << ["sort", sort] if sort + "http://example.org/swimlane/more?#{URI.encode_www_form(pairs)}" + end + + specify "compare view lists events from all compared streams" do + event_store.append(e1 = event_at(base_time + 1), stream_name: "fizz") + event_store.append(e2 = event_at(base_time + 2), stream_name: "buzz") + + response = client.get("/swimlane?streams%5B%5D=fizz&streams%5B%5D=buzz") + expect(response.status).to eq(200) + expect(response.headers["content-type"]).to eq("text/html;charset=utf-8") + expect(response.body).to include("Comparing fizz, buzz") + expect(response.body).to include(e1.event_id) + expect(response.body).to include(e2.event_id) + expect(response.body.scan("") + end + + specify "compare view exposes the url for fetching older events" do + event_store.append(event_at(base_time), stream_name: "buzz") + events = Array.new(Browser::PAGE_SIZE + 1) { |i| event_at(base_time + i + 1) } + event_store.append(events, stream_name: "fizz") + + body = client.get("/swimlane?streams%5B%5D=fizz&streams%5B%5D=buzz").body + expect(body).to include( + "data-swimlane-more-url-value=\"#{more_url(%w[fizz buzz], base_time + 2)}\"", + ) + end + + specify "compare view has no more-url when everything fits one page" do + event_store.append(event_at(base_time), stream_name: "fizz") + + expect(client.get("/swimlane?streams%5B%5D=fizz").body).to include(%q[data-swimlane-more-url-value=""]) + end + + specify "compare more endpoint returns next rows and cursor as JSON" do + event_store.append(event_at(base_time), stream_name: "buzz") + events = Array.new(Browser::PAGE_SIZE + 1) { |i| event_at(base_time + i + 1) } + event_store.append(events, stream_name: "fizz") + + response = client.get("/swimlane/more?streams%5B%5D=fizz&streams%5B%5D=buzz") + expect(response.status).to eq(200) + expect(response.headers["content-type"]).to eq("application/json") + payload = JSON.parse(response.body) + expect(payload.keys).to eq(%w[html more_url]) + expect(payload["html"]).to include(events.last.event_id) + expect(payload["html"]).not_to include(" "", "more_url" => nil }) + end + + specify "compare more endpoint pages from the cursor" do + e1, e2, e3 = Array.new(3) { |i| event_at(base_time + i + 1) } + event_store.append([e1, e2, e3], stream_name: "fizz") + + query = URI.encode_www_form([["streams[]", "fizz"], ["cursor", (base_time + 2).iso8601(TIMESTAMP_PRECISION)]]) + payload = JSON.parse(client.get("/swimlane/more?#{query}").body) + expect(payload["html"]).to include(e1.event_id) + expect(payload["html"]).not_to include(e2.event_id) + expect(payload["html"]).not_to include(e3.event_id) + expect(payload["more_url"]).to be_nil + end + + specify "compare view sorts by validity time when requested" do + event_store.append(e1 = event_at(base_time + 2, valid_at: base_time + 11), stream_name: "fizz") + event_store.append(e2 = event_at(base_time + 1, valid_at: base_time + 12), stream_name: "buzz") + + by_append = client.get("/swimlane?streams%5B%5D=fizz&streams%5B%5D=buzz").body + expect(by_append.index(e1.event_id)).to be < by_append.index(e2.event_id) + expect(by_append).to include("/swimlane?streams%5B%5D=fizz&streams%5B%5D=buzz&sort=as_of").or include( + "/swimlane?streams%5B%5D=fizz&streams%5B%5D=buzz&sort=as_of", + ) + + by_validity = client.get("/swimlane?streams%5B%5D=fizz&streams%5B%5D=buzz&sort=as_of").body + expect(by_validity.index(e2.event_id)).to be < by_validity.index(e1.event_id) + expect(by_validity).to include((base_time + 12).utc.iso8601(6)) + end + + specify "compare more endpoint honors as_of sort and carries it in more_url" do + events = Array.new(Browser::PAGE_SIZE + 1) { |i| event_at(base_time + i, valid_at: base_time + 100 - i) } + event_store.append(events, stream_name: "fizz") + + query = URI.encode_www_form([["streams[]", "fizz"], ["sort", "as_of"]]) + payload = JSON.parse(client.get("/swimlane/more?#{query}").body) + expect(payload["html"]).to include(events[0].event_id) + expect(payload["html"]).to include((base_time + 100).utc.iso8601(6)) + expect(payload["html"]).not_to include(events[20].event_id) + expect(payload["more_url"]).to eq(more_url(%w[fizz], base_time + 81, "as_of")) + end + + specify "unknown sort values fall back to created-at order" do + event_store.append(e1 = event_at(base_time + 2, valid_at: base_time + 11), stream_name: "fizz") + event_store.append(e2 = event_at(base_time + 1, valid_at: base_time + 12), stream_name: "buzz") + + body = client.get("/swimlane?streams%5B%5D=fizz&streams%5B%5D=buzz&sort=wrong").body + expect(body.index(e1.event_id)).to be < body.index(e2.event_id) + + query = URI.encode_www_form([["streams[]", "fizz"], ["streams[]", "buzz"], ["sort", "wrong"]]) + html = JSON.parse(client.get("/swimlane/more?#{query}").body).fetch("html") + expect(html.index(e1.event_id)).to be < html.index(e2.event_id) + end + + specify "compare endpoints tolerate valueless stream params" do + event_store.append(event_at(base_time), stream_name: "fizz") + + expect(client.get("/swimlane?streams%5B%5D").body).to include("Comparing ") + expect(JSON.parse(client.get("/swimlane/more?streams%5B%5D").body)).to eq({ "html" => "", "more_url" => nil }) + end + + specify "compare view carries as_of sort in the url for fetching older events" do + events = Array.new(Browser::PAGE_SIZE + 1) { |i| event_at(base_time + i, valid_at: base_time + 100 - i) } + event_store.append(events, stream_name: "fizz") + + body = client.get("/swimlane?streams%5B%5D=fizz&sort=as_of").body + expect(body).to include("data-swimlane-more-url-value=\"#{more_url(%w[fizz], base_time + 81, "as_of")}\"") + end + + specify "exposes its script url for the layout" do + urls = Urls.from_configuration("http://example.org", nil) + expect(Swimlane.new.scripts(urls)).to eq(["http://example.org/swimlane/swimlane.js"]) + end + + specify "exposes a compare link for the stream page" do + urls = Urls.from_configuration("http://example.org", nil) + expect(Swimlane.new.stream_links("fizz", urls)).to eq( + [{ label: "Streamline", url: "http://example.org/swimlane?streams%5B%5D=fizz" }], + ) + end + + specify "comparing the all stream shows the global timeline" do + event_store.append(event = event_at(base_time), stream_name: "orders") + + body = client.get("/swimlane?streams%5B%5D=all").body + expect(body).to include(event.event_id) + end + + specify "contributes a compare link on the stream page" do + event_store.append(event_at(base_time), stream_name: "fizz") + + body = client.get("/streams/fizz").body + expect(body).to include("Streamline") + expect(body).to include("http://example.org/swimlane?streams%5B%5D=fizz") + end + + specify "contributes its script to the layout" do + response = client.get("/streams/all") + expect(response.body).to include( + '', + ) + end + + specify "serves its script" do + response = client.get("/swimlane/swimlane.js") + expect(response.status).to eq(200) + expect(response.headers["content-type"]).to eq("text/javascript") + expect(response.body).to include('application.register(\n "swimlane",'.gsub('\n', "\n")) + end + + specify "removing a stream from comparison keeps the sort" do + event_store.append(event_at(base_time), stream_name: "fizz") + event_store.append(event_at(base_time + 1), stream_name: "buzz") + + body = client.get("/swimlane?streams%5B%5D=fizz&streams%5B%5D=buzz&sort=as_of").body + expect(body).to include("/swimlane?streams%5B%5D=buzz&sort=as_of").or include( + "/swimlane?streams%5B%5D=buzz&sort=as_of", + ) + end + end + end +end diff --git a/support/ci/generate b/support/ci/generate index 20be098ee1..a9a0525773 100755 --- a/support/ci/generate +++ b/support/ci/generate @@ -102,6 +102,9 @@ class CI ), release_mutate("ruby_event_store-active_record"), release_coverage("ruby_event_store-active_record"), + contrib_test("ruby_event_store-browser-swimlane"), + contrib_mutate("ruby_event_store-browser-swimlane"), + contrib_coverage("ruby_event_store-browser-swimlane"), contrib_test( "ruby_event_store-flipper", matrix: