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

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

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