From 21ebfc78bcf8b8eb5632752f6ed1f0d2d2ab5d1d Mon Sep 17 00:00:00 2001 From: pjurewicz Date: Fri, 17 Jul 2026 14:50:16 +0200 Subject: [PATCH 1/3] 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 d0963546a9..da5071ed44 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: From 06f21158dc2b35ac17a0ea76ecf305b5a9ae9873 Mon Sep 17 00:00:00 2001 From: pjurewicz Date: Wed, 22 Jul 2026 18:18:30 +0200 Subject: [PATCH 2/3] Build the swimlane stream comparison into the browser The feature moves from the ruby_event_store-browser-swimlane contrib gem into ruby_event_store-browser itself, available out of the box with no configuration. The /swimlane and /swimlane/more routes are served by App, the views live under views/swimlane, the url builders on Urls, the stimulus controllers ship in the main js bundle and the stream page always offers a Streamline entry link, followed by any extension-contributed links. The extension API stays intact - only its swimlane consumer is gone, together with the contrib gem and its CI workflows. Specs follow into the browser suite; new Urls and App subjects are mutation-covered. --- ..._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 -- .../ruby_event_store-browser-swimlane.gemspec | 27 ---- .../spec/spec_helper.rb | 9 -- .../lib/ruby_event_store/browser.rb | 1 + .../lib/ruby_event_store/browser/app.rb | 45 ++++++ .../browser/get_events_from_streams.rb | 89 +++++++++++ .../lib/ruby_event_store/browser/urls.rb | 17 ++ .../browser/views/streams/show.html.erb | 12 +- .../browser/views/swimlane}/_rows.html.erb | 0 .../browser/views/swimlane/show.html.erb | 8 +- .../public/ruby_event_store_browser.js | 104 ++++++++++++ .../spec/get_events_from_streams_spec.rb | 4 +- .../spec/swimlane_spec.rb | 32 +--- ruby_event_store-browser/spec/urls_spec.rb | 28 ++++ support/ci/generate | 3 - 26 files changed, 298 insertions(+), 762 deletions(-) delete mode 100644 .github/workflows/ruby_event_store-browser-swimlane_coverage.yml delete mode 100644 .github/workflows/ruby_event_store-browser-swimlane_mutate.yml delete mode 100644 .github/workflows/ruby_event_store-browser-swimlane_test.yml delete mode 100644 contrib/ruby_event_store-browser-swimlane/.mutant.yml delete mode 100644 contrib/ruby_event_store-browser-swimlane/Gemfile delete mode 100644 contrib/ruby_event_store-browser-swimlane/Gemfile.lock delete mode 100644 contrib/ruby_event_store-browser-swimlane/Makefile delete mode 100644 contrib/ruby_event_store-browser-swimlane/README.md delete mode 100644 contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane.rb delete mode 100644 contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/get_events_from_streams.rb delete mode 100644 contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/public/swimlane.js delete mode 100644 contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/version.rb delete mode 100644 contrib/ruby_event_store-browser-swimlane/ruby_event_store-browser-swimlane.gemspec delete mode 100644 contrib/ruby_event_store-browser-swimlane/spec/spec_helper.rb create mode 100644 ruby_event_store-browser/lib/ruby_event_store/browser/get_events_from_streams.rb rename {contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views => ruby_event_store-browser/lib/ruby_event_store/browser/views/swimlane}/_rows.html.erb (100%) rename contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/swimlane.html.erb => ruby_event_store-browser/lib/ruby_event_store/browser/views/swimlane/show.html.erb (85%) rename {contrib/ruby_event_store-browser-swimlane => ruby_event_store-browser}/spec/get_events_from_streams_spec.rb (98%) rename {contrib/ruby_event_store-browser-swimlane => ruby_event_store-browser}/spec/swimlane_spec.rb (87%) diff --git a/.github/workflows/ruby_event_store-browser-swimlane_coverage.yml b/.github/workflows/ruby_event_store-browser-swimlane_coverage.yml deleted file mode 100644 index 6f2b56f54b..0000000000 --- a/.github/workflows/ruby_event_store-browser-swimlane_coverage.yml +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 2931f82c99..0000000000 --- a/.github/workflows/ruby_event_store-browser-swimlane_mutate.yml +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 6a1a75eb99..0000000000 --- a/.github/workflows/ruby_event_store-browser-swimlane_test.yml +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 856e62a86c..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/.mutant.yml +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index 3f019394a9..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/Gemfile +++ /dev/null @@ -1,8 +0,0 @@ -# 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 deleted file mode 100644 index cf5d659edf..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/Gemfile.lock +++ /dev/null @@ -1,148 +0,0 @@ -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 deleted file mode 100644 index b6f8d785c6..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/Makefile +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 89babb4973..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 82060f8a70..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane.rb +++ /dev/null @@ -1,95 +0,0 @@ -# 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 deleted file mode 100644 index c5a651ffe5..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/get_events_from_streams.rb +++ /dev/null @@ -1,108 +0,0 @@ -# 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 deleted file mode 100644 index a73761ac74..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/public/swimlane.js +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index f0cae4a572..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/version.rb +++ /dev/null @@ -1,9 +0,0 @@ -# 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/ruby_event_store-browser-swimlane.gemspec b/contrib/ruby_event_store-browser-swimlane/ruby_event_store-browser-swimlane.gemspec deleted file mode 100644 index 4d26ae2d3f..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/ruby_event_store-browser-swimlane.gemspec +++ /dev/null @@ -1,27 +0,0 @@ -# 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/spec_helper.rb b/contrib/ruby_event_store-browser-swimlane/spec/spec_helper.rb deleted file mode 100644 index f486473938..0000000000 --- a/contrib/ruby_event_store-browser-swimlane/spec/spec_helper.rb +++ /dev/null @@ -1,9 +0,0 @@ -# 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/ruby_event_store-browser/lib/ruby_event_store/browser.rb b/ruby_event_store-browser/lib/ruby_event_store/browser.rb index c608df9439..763ad47e8e 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser.rb @@ -22,6 +22,7 @@ def self.fingerprint(name) end require_relative "browser/get_events_from_stream" +require_relative "browser/get_events_from_streams" require_relative "browser/urls" require_relative "browser/router" require_relative "browser/renderer" diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/app.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/app.rb index 33930177df..31e5257d30 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser/app.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/app.rb @@ -162,6 +162,41 @@ def call(env) ) end + router.add_route("GET", "/swimlane") do |params, urls| + stream_names, sort = swimlane_params(params) + reader = GetEventsFromStreams.new(event_store: event_store, stream_names: stream_names, sort: sort) + html render( + "swimlane/show", + urls: urls, + stream_names: stream_names, + events: reader.events, + sort: sort, + more_url: (urls.swimlane_more_url(stream_names, reader.next_cursor, sort) if reader.more?), + ) + end + + router.add_route("GET", "/swimlane/more") do |params, urls| + stream_names, sort = swimlane_params(params) + reader = + GetEventsFromStreams.new( + event_store: event_store, + stream_names: stream_names, + cursor: params["cursor"], + sort: sort, + ) + json( + html: + Renderer.new.render( + "swimlane/_rows", + urls: urls, + stream_names: stream_names, + events: reader.events, + sort: sort, + ), + more_url: (urls.swimlane_more_url(stream_names, reader.next_cursor, sort) if reader.more?), + ) + end + extensions.each do |extension| extension.register_routes(router, ExtensionContext.new(event_store, method(:extension_stylesheets), method(:extension_scripts))) end @@ -223,6 +258,16 @@ def html(body) [200, { "content-type" => "text/html;charset=utf-8" }, [body]] end + def json(body) + [200, { "content-type" => "application/json" }, [JSON.generate(body)]] + end + + def swimlane_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 not_found(urls) renderer = Renderer.new content = renderer.render("not_found") diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/get_events_from_streams.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/get_events_from_streams.rb new file mode 100644 index 0000000000..309fe21f0f --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/get_events_from_streams.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + 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 + + 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 + + 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 diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/urls.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/urls.rb index f0b778e884..eff3eb92df 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser/urls.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/urls.rb @@ -39,6 +39,14 @@ def stream_page_url(stream_name, cursor, count) "#{stream_url(stream_name)}?#{query}" end + def swimlane_url(stream_names, sort = nil) + "#{app_url}/swimlane?#{swimlane_query(stream_names, sort)}" + end + + def swimlane_more_url(stream_names, cursor, sort) + "#{app_url}/swimlane/more?#{swimlane_query(stream_names, sort, [["cursor", cursor]])}" + end + def browser_js_url "#{app_url}/#{BROWSER_JS}" end @@ -50,6 +58,15 @@ def browser_css_url def ==(other) self.class.eql?(other.class) && app_url.eql?(other.app_url) end + + private + + def swimlane_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/ruby_event_store-browser/lib/ruby_event_store/browser/views/streams/show.html.erb b/ruby_event_store-browser/lib/ruby_event_store/browser/views/streams/show.html.erb index ea9776f2e1..8c1182ccde 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser/views/streams/show.html.erb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/views/streams/show.html.erb @@ -15,13 +15,11 @@ - <% if extension_links.any? -%> -
- <% extension_links.each do |link| -%> - <%= h(link[:label]) %> - <% end -%> -
- <% end -%> +
+ <% [{ label: "Streamline", url: urls.swimlane_url([stream_name]) }].concat(extension_links).each do |link| -%> + <%= h(link[:label]) %> + <% end -%> +
diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/_rows.html.erb b/ruby_event_store-browser/lib/ruby_event_store/browser/views/swimlane/_rows.html.erb similarity index 100% rename from contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/_rows.html.erb rename to ruby_event_store-browser/lib/ruby_event_store/browser/views/swimlane/_rows.html.erb diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/swimlane.html.erb b/ruby_event_store-browser/lib/ruby_event_store/browser/views/swimlane/show.html.erb similarity index 85% rename from contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/swimlane.html.erb rename to ruby_event_store-browser/lib/ruby_event_store/browser/views/swimlane/show.html.erb index b667e4020f..d4b472f90e 100644 --- a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/swimlane.html.erb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/views/swimlane/show.html.erb @@ -6,11 +6,11 @@
Sort by <% if sort == "as_of" -%> - created at + created at valid at <% else -%> created at - ">valid at + ">valid at <% end -%>
@@ -24,7 +24,7 @@
<%= h(stream_name) %> <% if stream_names.size > 1 -%> - × + × <% end -%>
@@ -32,7 +32,7 @@ - <%= render("_rows", urls: urls, stream_names: stream_names, events: events, sort: sort) %> + <%= render("swimlane/_rows", urls: urls, stream_names: stream_names, events: events, sort: sort) %>
diff --git a/ruby_event_store-browser/public/ruby_event_store_browser.js b/ruby_event_store-browser/public/ruby_event_store_browser.js index e68d377595..5899b5eb1a 100644 --- a/ruby_event_store-browser/public/ruby_event_store_browser.js +++ b/ruby_event_store-browser/public/ruby_event_store_browser.js @@ -92,3 +92,107 @@ application.register( } }, ) + +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) + } + + 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/spec/get_events_from_streams_spec.rb b/ruby_event_store-browser/spec/get_events_from_streams_spec.rb similarity index 98% rename from contrib/ruby_event_store-browser-swimlane/spec/get_events_from_streams_spec.rb rename to ruby_event_store-browser/spec/get_events_from_streams_spec.rb index d40f87bbfa..45d911f070 100644 --- a/contrib/ruby_event_store-browser-swimlane/spec/get_events_from_streams_spec.rb +++ b/ruby_event_store-browser/spec/get_events_from_streams_spec.rb @@ -4,12 +4,12 @@ module RubyEventStore module Browser - ::RSpec.describe Swimlane::GetEventsFromStreams do + ::RSpec.describe 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( + GetEventsFromStreams.new( event_store: event_store, stream_names: stream_names, cursor: cursor, diff --git a/contrib/ruby_event_store-browser-swimlane/spec/swimlane_spec.rb b/ruby_event_store-browser/spec/swimlane_spec.rb similarity index 87% rename from contrib/ruby_event_store-browser-swimlane/spec/swimlane_spec.rb rename to ruby_event_store-browser/spec/swimlane_spec.rb index 676c079feb..7480bf7115 100644 --- a/contrib/ruby_event_store-browser-swimlane/spec/swimlane_spec.rb +++ b/ruby_event_store-browser/spec/swimlane_spec.rb @@ -6,9 +6,9 @@ module RubyEventStore module Browser - ::RSpec.describe Swimlane do + ::RSpec.describe Browser do let(:event_store) { RubyEventStore::Client.new } - let(:app) { App.for(event_store_locator: -> { event_store }, extensions: [Swimlane.new]) } + let(:app) { App.for(event_store_locator: -> { event_store }) } let(:client) { Rack::MockRequest.new(Rack::Lint.new(app)) } let(:base_time) { Time.utc(2024, 1, 1, 12, 0, 0) } @@ -158,18 +158,6 @@ def more_url(stream_names, cursor_time, sort = nil) 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") @@ -177,7 +165,7 @@ def more_url(stream_names, cursor_time, sort = nil) expect(body).to include(event.event_id) end - specify "contributes a compare link on the stream page" do + specify "stream page links to comparing that stream" do event_store.append(event_at(base_time), stream_name: "fizz") body = client.get("/streams/fizz").body @@ -185,20 +173,6 @@ def more_url(stream_names, cursor_time, sort = nil) 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") diff --git a/ruby_event_store-browser/spec/urls_spec.rb b/ruby_event_store-browser/spec/urls_spec.rb index fe96eb675b..0d15e7a9e2 100644 --- a/ruby_event_store-browser/spec/urls_spec.rb +++ b/ruby_event_store-browser/spec/urls_spec.rb @@ -90,6 +90,34 @@ module Browser ) end + specify "swimlane_url" do + urls = Urls.from_configuration("http://example.com:9393", "/res") + expect(urls.swimlane_url(%w[fizz buzz])).to eq( + "http://example.com:9393/res/swimlane?streams%5B%5D=fizz&streams%5B%5D=buzz", + ) + end + + specify "swimlane_url with sort" do + urls = Urls.from_configuration("http://example.com:9393", nil) + expect(urls.swimlane_url(%w[fizz], "as_of")).to eq( + "http://example.com:9393/swimlane?streams%5B%5D=fizz&sort=as_of", + ) + end + + specify "swimlane_more_url" do + urls = Urls.from_configuration("http://example.com:9393", nil) + expect(urls.swimlane_more_url(%w[fizz buzz], "2024-01-01T12:00:00.000000Z", nil)).to eq( + "http://example.com:9393/swimlane/more?streams%5B%5D=fizz&streams%5B%5D=buzz&cursor=2024-01-01T12%3A00%3A00.000000Z", + ) + end + + specify "swimlane_more_url with sort" do + urls = Urls.from_configuration("http://example.com:9393", nil) + expect(urls.swimlane_more_url(%w[fizz], "2024-01-01T12:00:00.000000Z", "as_of")).to eq( + "http://example.com:9393/swimlane/more?streams%5B%5D=fizz&cursor=2024-01-01T12%3A00%3A00.000000Z&sort=as_of", + ) + end + specify "browser_css_url" do urls = Urls.from_configuration("http://example.com:9393", "/res") expect(urls.browser_css_url).to eq("http://example.com:9393/res/#{BROWSER_CSS}") diff --git a/support/ci/generate b/support/ci/generate index da5071ed44..d0963546a9 100755 --- a/support/ci/generate +++ b/support/ci/generate @@ -102,9 +102,6 @@ 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: From ce6b542487806f2c1aa32e27a55f3844eea0aad6 Mon Sep 17 00:00:00 2001 From: pjurewicz Date: Wed, 22 Jul 2026 18:38:44 +0200 Subject: [PATCH 3/3] Label the stream page entry link Swimlane view, hide it on the global stream --- .../browser/views/streams/show.html.erb | 14 +++++++++----- ruby_event_store-browser/spec/swimlane_spec.rb | 8 +++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/views/streams/show.html.erb b/ruby_event_store-browser/lib/ruby_event_store/browser/views/streams/show.html.erb index 8c1182ccde..0d2aa5b600 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser/views/streams/show.html.erb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/views/streams/show.html.erb @@ -15,11 +15,15 @@
-
- <% [{ label: "Streamline", url: urls.swimlane_url([stream_name]) }].concat(extension_links).each do |link| -%> - <%= h(link[:label]) %> - <% end -%> -
+ <% swimlane_link = { label: "Swimlane view", url: urls.swimlane_url([stream_name]) } unless stream_name == SERIALIZED_GLOBAL_STREAM_NAME -%> + <% links = [swimlane_link, *extension_links].compact -%> + <% if links.any? -%> +
+ <% links.each do |link| -%> + <%= h(link[:label]) %> + <% end -%> +
+ <% end -%>
diff --git a/ruby_event_store-browser/spec/swimlane_spec.rb b/ruby_event_store-browser/spec/swimlane_spec.rb index 7480bf7115..0bccfe9a7d 100644 --- a/ruby_event_store-browser/spec/swimlane_spec.rb +++ b/ruby_event_store-browser/spec/swimlane_spec.rb @@ -169,10 +169,16 @@ def more_url(stream_names, cursor_time, sort = nil) 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("Swimlane view") expect(body).to include("http://example.org/swimlane?streams%5B%5D=fizz") end + specify "the global stream page does not link to the swimlane view" do + event_store.append(event_at(base_time), stream_name: "fizz") + + expect(client.get("/streams/all").body).not_to include("Swimlane view") + 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")