From a2d425cf16196aa02aedb0a1bfa6d39325f5258c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Thu, 6 Nov 2025 22:17:08 +0100 Subject: [PATCH 01/16] Introduce EventType struct for event types listing --- .../lib/ruby_event_store/browser.rb | 1 + .../browser/event_types_querying.rb | 10 +++++++ .../event_types_querying/event_type.rb | 9 +++++++ .../event_types_querying/event_type_spec.rb | 26 +++++++++++++++++++ 4 files changed, 46 insertions(+) create mode 100644 ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb create mode 100644 ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/event_type.rb create mode 100644 ruby_event_store-browser/spec/event_types_querying/event_type_spec.rb 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 c82d1cefce..b1465610ec 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser.rb @@ -16,3 +16,4 @@ module Browser require_relative "browser/urls" require_relative "browser/gem_source" require_relative "browser/router" +require_relative "browser/event_types_querying" diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb new file mode 100644 index 0000000000..f17458a5ca --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + module EventTypesQuerying + end + end +end + +require_relative "event_types_querying/event_type" diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/event_type.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/event_type.rb new file mode 100644 index 0000000000..bca8c2c9ee --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/event_type.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + module EventTypesQuerying + EventType = Data.define(:event_type, :stream_name) + end + end +end diff --git a/ruby_event_store-browser/spec/event_types_querying/event_type_spec.rb b/ruby_event_store-browser/spec/event_types_querying/event_type_spec.rb new file mode 100644 index 0000000000..060c021c74 --- /dev/null +++ b/ruby_event_store-browser/spec/event_types_querying/event_type_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + module Browser + module EventTypesQuerying + ::RSpec.describe EventType do + specify "can be initialized with event_type and stream_name" do + event_type = EventType.new(event_type: "OrderPlaced", stream_name: "$by_type_OrderPlaced") + + expect(event_type.event_type).to eq("OrderPlaced") + expect(event_type.stream_name).to eq("$by_type_OrderPlaced") + end + + specify "requires event_type keyword argument" do + expect { EventType.new(stream_name: "$by_type_OrderPlaced") }.to raise_error(ArgumentError) + end + + specify "requires stream_name keyword argument" do + expect { EventType.new(event_type: "OrderPlaced") }.to raise_error(ArgumentError) + end + end + end + end +end From 558bf2ba05f7b2e4c545222a5ce7bb0c431ec861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Thu, 6 Nov 2025 22:19:44 +0100 Subject: [PATCH 02/16] Add event types query interface contract specification --- .../query_interface_spec.rb | 23 ++++++++++++++ ruby_event_store-browser/spec/spec_helper.rb | 1 + .../shared_examples/event_types_query.rb | 30 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 ruby_event_store-browser/spec/event_types_querying/query_interface_spec.rb create mode 100644 ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb diff --git a/ruby_event_store-browser/spec/event_types_querying/query_interface_spec.rb b/ruby_event_store-browser/spec/event_types_querying/query_interface_spec.rb new file mode 100644 index 0000000000..ffa8c8d937 --- /dev/null +++ b/ruby_event_store-browser/spec/event_types_querying/query_interface_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + module Browser + module EventTypesQuerying + ::RSpec.describe "Event Types Query Interface" do + specify "defines the interface contract" do + # This spec documents the expected interface for event types query objects. + # Any query object implementing this interface should: + # + # 1. Accept an event_store argument in the initializer + # 2. Respond to #call method + # 3. Return an Array of EventType objects from #call + # + # To verify a query object conforms to this interface, use: + # it_behaves_like :event_types_query, YourQueryClass + end + end + end + end +end diff --git a/ruby_event_store-browser/spec/spec_helper.rb b/ruby_event_store-browser/spec/spec_helper.rb index 8f16413edd..69d6775cf3 100644 --- a/ruby_event_store-browser/spec/spec_helper.rb +++ b/ruby_event_store-browser/spec/spec_helper.rb @@ -7,6 +7,7 @@ require "support/api_client" require "support/csp_app" require "support/integration_helpers" +require "support/shared_examples/event_types_query" require_relative "../../support/helpers/rspec_defaults" require_relative "../../support/helpers/time_enrichment" diff --git a/ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb b/ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb new file mode 100644 index 0000000000..2a987b2bec --- /dev/null +++ b/ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.shared_examples :event_types_query do |query_class| + specify "responds to call" do + event_store = RubyEventStore::Client.new + query = query_class.new(event_store) + + expect(query).to respond_to(:call) + end + + specify "returns array of EventType objects" do + event_store = RubyEventStore::Client.new + query = query_class.new(event_store) + + result = query.call + + expect(result).to be_an(Array) + result.each do |event_type| + expect(event_type).to be_a(RubyEventStore::Browser::EventTypesQuerying::EventType) + expect(event_type.event_type).to be_a(String) + expect(event_type.stream_name).to be_a(String) + end + end + + specify "can be initialized with event_store" do + event_store = RubyEventStore::Client.new + + expect { query_class.new(event_store) }.not_to raise_error + end +end From 1d43f225bda6351494d625ea55b819e3d2a23002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Thu, 6 Nov 2025 22:42:11 +0100 Subject: [PATCH 03/16] feat: implement DefaultQuery for discovering event types at runtime --- .../browser/event_types_querying.rb | 1 + .../event_types_querying/default_query.rb | 29 ++++++++++ .../default_query_spec.rb | 55 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb create mode 100644 ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb index f17458a5ca..04b306c07c 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb @@ -8,3 +8,4 @@ module EventTypesQuerying end require_relative "event_types_querying/event_type" +require_relative "event_types_querying/default_query" diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb new file mode 100644 index 0000000000..48d01936b9 --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + module EventTypesQuerying + class DefaultQuery + def initialize(event_store) + @event_store = event_store + end + + def call + event_classes = [] + + ObjectSpace.each_object(Class) do |klass| + event_classes << klass if klass < RubyEventStore::Event && !klass.name.nil? + end + + event_classes.sort_by(&:name).map do |klass| + EventType.new(event_type: klass.name, stream_name: "$by_type_#{klass.name}") + end + end + + private + + attr_reader :event_store + end + end + end +end diff --git a/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb new file mode 100644 index 0000000000..dc22cb16e7 --- /dev/null +++ b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + module Browser + module EventTypesQuerying + ::RSpec.describe DefaultQuery do + it_behaves_like :event_types_query, DefaultQuery + + specify "finds all classes inheriting from RubyEventStore::Event" do + event_store = RubyEventStore::Client.new + + # Define some test event classes + test_event_1 = Class.new(RubyEventStore::Event) + stub_const("TestEvent1", test_event_1) + + test_event_2 = Class.new(RubyEventStore::Event) + stub_const("TestEvent2", test_event_2) + + query = DefaultQuery.new(event_store) + result = query.call + + event_types = result.map(&:event_type) + expect(event_types).to include("TestEvent1", "TestEvent2") + end + + specify "generates stream names in format $by_type_EVENT_NAME" do + event_store = RubyEventStore::Client.new + + test_event = Class.new(RubyEventStore::Event) + stub_const("OrderPlaced", test_event) + + query = DefaultQuery.new(event_store) + result = query.call + + order_placed_type = result.find { |et| et.event_type == "OrderPlaced" } + expect(order_placed_type.stream_name).to eq("$by_type_OrderPlaced") + end + + specify "returns empty array when no event classes are defined" do + event_store = RubyEventStore::Client.new + + # Remove all test event classes from ObjectSpace + allow(ObjectSpace).to receive(:each_object).with(Class).and_return([]) + + query = DefaultQuery.new(event_store) + result = query.call + + expect(result).to eq([]) + end + end + end + end +end From a7a91091b830448b815dba233b68a6647eafd3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Thu, 6 Nov 2025 22:44:47 +0100 Subject: [PATCH 04/16] feat: make event types querying configurable in Browser::App Allow applications to provide custom event types query implementations while maintaining sensible defaults via DefaultQuery. Follows the same configuration pattern as related_streams_query. --- .../lib/ruby_event_store/browser.rb | 1 + .../lib/ruby_event_store/browser/app.rb | 9 ++++--- ruby_event_store-browser/spec/app_spec.rb | 25 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 ruby_event_store-browser/spec/app_spec.rb 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 b1465610ec..cd6a42265e 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser.rb @@ -5,6 +5,7 @@ module Browser PAGE_SIZE = 20 SERIALIZED_GLOBAL_STREAM_NAME = "all".freeze DEFAULT_RELATED_STREAMS_QUERY = ->(stream_name) { [] } + DEFAULT_EXPERIMENTAL_EVENT_TYPES_QUERY = ->(event_store) { EventTypesQuerying::DefaultQuery.new(event_store) } end end 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 d52b180cc4..36cd50109f 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 @@ -14,7 +14,8 @@ def self.for( path: nil, api_url: nil, environment: nil, - related_streams_query: DEFAULT_RELATED_STREAMS_QUERY + related_streams_query: DEFAULT_RELATED_STREAMS_QUERY, + experimental_event_types_query: DEFAULT_EXPERIMENTAL_EVENT_TYPES_QUERY ) warn(<<~WARN) if environment Passing :environment to RubyEventStore::Browser::App.for is deprecated. @@ -62,6 +63,7 @@ def self.for( run App.new( event_store_locator: event_store_locator, related_streams_query: related_streams_query, + experimental_event_types_query: experimental_event_types_query, host: host, root_path: path, api_url: api_url, @@ -69,9 +71,10 @@ def self.for( end end - def initialize(event_store_locator:, related_streams_query:, host:, root_path:, api_url:) + def initialize(event_store_locator:, related_streams_query:, experimental_event_types_query:, host:, root_path:, api_url:) @event_store_locator = event_store_locator @related_streams_query = related_streams_query + @experimental_event_types_query = experimental_event_types_query @routing = Urls.from_configuration(host, root_path, api_url) end @@ -116,7 +119,7 @@ def call(env) private - attr_reader :event_store_locator, :related_streams_query, :routing + attr_reader :event_store_locator, :related_streams_query, :experimental_event_types_query, :routing def event_store event_store_locator.call diff --git a/ruby_event_store-browser/spec/app_spec.rb b/ruby_event_store-browser/spec/app_spec.rb new file mode 100644 index 0000000000..925d85a2d3 --- /dev/null +++ b/ruby_event_store-browser/spec/app_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + ::RSpec.describe Browser do + specify "accepts experimental_event_types_query parameter" do + custom_query = ->(event_store) { [] } + + app = + Browser::App.for( + event_store_locator: -> { Client.new }, + experimental_event_types_query: custom_query, + ) + + expect(app).not_to be_nil + end + + specify "uses DefaultQuery when experimental_event_types_query not provided" do + app = Browser::App.for(event_store_locator: -> { Client.new }) + + expect(app).not_to be_nil + end + end +end From 5fe251ff18e30c05e99276868920b78da801716e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Thu, 6 Nov 2025 23:01:16 +0100 Subject: [PATCH 05/16] feat: Add GET /api/event_types endpoint for event type discovery --- .../lib/ruby_event_store/browser.rb | 2 + .../lib/ruby_event_store/browser/app.rb | 6 ++ .../browser/get_event_types.rb | 27 ++++++ .../browser/json_api_event_type.rb | 26 ++++++ .../spec/api/event_types_spec.rb | 88 +++++++++++++++++++ 5 files changed, 149 insertions(+) create mode 100644 ruby_event_store-browser/lib/ruby_event_store/browser/get_event_types.rb create mode 100644 ruby_event_store-browser/lib/ruby_event_store/browser/json_api_event_type.rb create mode 100644 ruby_event_store-browser/spec/api/event_types_spec.rb 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 cd6a42265e..3563282413 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser.rb @@ -18,3 +18,5 @@ module Browser require_relative "browser/gem_source" require_relative "browser/router" require_relative "browser/event_types_querying" +require_relative "browser/json_api_event_type" +require_relative "browser/get_event_types" 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 36cd50109f..bd0e346625 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 @@ -98,6 +98,12 @@ def call(env) page: params["page"], ) end + router.add_route("GET", "/api/event_types") do + json GetEventTypes.new( + event_store: event_store, + event_types_query: experimental_event_types_query, + ) + end %w[/ /events/:event_id /streams/:stream_name].each do |starting_route| router.add_route("GET", starting_route) do |_, urls| diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/get_event_types.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/get_event_types.rb new file mode 100644 index 0000000000..27d7fd5910 --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/get_event_types.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + class GetEventTypes + def initialize(event_store:, event_types_query:) + @event_store = event_store + @event_types_query = event_types_query + end + + def to_h + { + data: event_types.map { |event_type| JsonApiEventType.new(event_type).to_h }, + } + end + + private + + attr_reader :event_store, :event_types_query + + def event_types + query = event_types_query.call(event_store) + query.call + end + end + end +end diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/json_api_event_type.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/json_api_event_type.rb new file mode 100644 index 0000000000..21c9aa3a55 --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/json_api_event_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + class JsonApiEventType + def initialize(event_type) + @event_type = event_type + end + + def to_h + { + id: event_type.event_type, + type: "event_types", + attributes: { + event_type: event_type.event_type, + stream_name: event_type.stream_name, + }, + } + end + + private + + attr_reader :event_type + end + end +end diff --git a/ruby_event_store-browser/spec/api/event_types_spec.rb b/ruby_event_store-browser/spec/api/event_types_spec.rb new file mode 100644 index 0000000000..dc51365c71 --- /dev/null +++ b/ruby_event_store-browser/spec/api/event_types_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + ::RSpec.describe Browser do + include Browser::IntegrationHelpers + + specify "returns list of event types" do + # Define some test event classes + test_event_1 = Class.new(RubyEventStore::Event) + stub_const("OrderPlaced", test_event_1) + + test_event_2 = Class.new(RubyEventStore::Event) + stub_const("OrderCancelled", test_event_2) + + api_client.get "/api/event_types" + + expect(api_client.last_response).to be_ok + expect(api_client.parsed_body["data"]).to be_an(Array) + + event_types = api_client.parsed_body["data"] + order_placed = event_types.find { |et| et["attributes"]["event_type"] == "OrderPlaced" } + order_cancelled = event_types.find { |et| et["attributes"]["event_type"] == "OrderCancelled" } + + expect(order_placed).to match( + { + "id" => "OrderPlaced", + "type" => "event_types", + "attributes" => { + "event_type" => "OrderPlaced", + "stream_name" => "$by_type_OrderPlaced", + }, + }, + ) + + expect(order_cancelled).to match( + { + "id" => "OrderCancelled", + "type" => "event_types", + "attributes" => { + "event_type" => "OrderCancelled", + "stream_name" => "$by_type_OrderCancelled", + }, + }, + ) + end + + specify "uses custom query when provided" do + custom_query = + lambda do |event_store| + query = Object.new + def query.call + [ + RubyEventStore::Browser::EventTypesQuerying::EventType.new( + event_type: "CustomEvent", + stream_name: "$custom_stream", + ), + ] + end + query + end + + app = + Browser::App.for( + event_store_locator: -> { event_store }, + experimental_event_types_query: custom_query, + ) + + custom_api_client = ApiClient.new(app, "www.example.com") + custom_api_client.get "/api/event_types" + + expect(custom_api_client.last_response).to be_ok + expect(custom_api_client.parsed_body["data"]).to match( + [ + { + "id" => "CustomEvent", + "type" => "event_types", + "attributes" => { + "event_type" => "CustomEvent", + "stream_name" => "$custom_stream", + }, + }, + ], + ) + end + end +end From ac35b753b0a707f3e4d5b2891476e93f4e560735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Fri, 7 Nov 2025 00:34:42 +0100 Subject: [PATCH 06/16] feat: Add ShowEventTypes page with event type listing UI Adds new page to browse all event types with links to their streams. Includes API integration, routing, and proper state management following existing Elm patterns. Also fixes duplicate event types in DefaultQuery by adding uniqueness check. --- ruby_event_store-browser/elm/src/Api.elm | 32 ++++- ruby_event_store-browser/elm/src/Main.elm | 24 ++++ .../elm/src/Page/ShowEventTypes.elm | 121 ++++++++++++++++++ ruby_event_store-browser/elm/src/Route.elm | 2 + .../event_types_querying/default_query.rb | 2 +- 5 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm diff --git a/ruby_event_store-browser/elm/src/Api.elm b/ruby_event_store-browser/elm/src/Api.elm index b1b597a0fb..5f47fe93b8 100644 --- a/ruby_event_store-browser/elm/src/Api.elm +++ b/ruby_event_store-browser/elm/src/Api.elm @@ -1,4 +1,4 @@ -module Api exposing (Event, PaginatedList, PaginationLink, PaginationLinks, RemoteResource(..), Stream, emptyPaginatedList, eventDecoder, eventsDecoder, getEvent, getEvents, getStream) +module Api exposing (Event, EventType, PaginatedList, PaginationLink, PaginationLinks, RemoteResource(..), Stream, emptyPaginatedList, eventDecoder, eventsDecoder, getEvent, getEventTypes, getEvents, getStream) import Flags exposing (Flags) import Http @@ -65,6 +65,12 @@ type alias Stream = } +type alias EventType = + { eventType : String + , streamName : String + } + + buildUrl : String -> String -> String buildUrl baseUrl id = baseUrl ++ "/" ++ Url.percentEncode id @@ -85,6 +91,10 @@ streamUrl flags streamId = buildUrl (Url.toString flags.apiUrl ++ "/streams") streamId +eventTypesUrl : Flags -> String +eventTypesUrl flags = + Url.toString flags.apiUrl ++ "/event_types" + getEvent : (Result Http.Error Event -> msg) -> Flags -> String -> Cmd msg getEvent msgBuilder flags eventId = @@ -142,6 +152,26 @@ streamDecoder_ = |> optionalAt [ "attributes", "related_streams" ] (maybe (list string)) Nothing +getEventTypes : (Result Http.Error (List EventType) -> msg) -> Flags -> Cmd msg +getEventTypes msgBuilder flags = + Http.get + { url = eventTypesUrl flags + , expect = Http.expectJson msgBuilder eventTypesDecoder + } + + +eventTypesDecoder : Decoder (List EventType) +eventTypesDecoder = + field "data" (list eventTypeDecoder_) + + +eventTypeDecoder_ : Decoder EventType +eventTypeDecoder_ = + succeed EventType + |> requiredAt [ "attributes", "event_type" ] string + |> requiredAt [ "attributes", "stream_name" ] string + + getEvents : (Result Http.Error (PaginatedList Event) -> msg) -> Flags -> String -> Pagination.Specification -> Cmd msg getEvents msgBuilder flags streamId paginationSpecification = Http.get diff --git a/ruby_event_store-browser/elm/src/Main.elm b/ruby_event_store-browser/elm/src/Main.elm index d18aa4cab6..8abcba0349 100644 --- a/ruby_event_store-browser/elm/src/Main.elm +++ b/ruby_event_store-browser/elm/src/Main.elm @@ -9,6 +9,7 @@ import Html exposing (..) import Layout import LinkedTimezones exposing (mapLinkedTimeZone) import Page.ShowEvent +import Page.ShowEventTypes import Page.ShowStream import Route import Task @@ -47,6 +48,7 @@ type Msg | ClickedLink Browser.UrlRequest | GotLayoutMsg Layout.Msg | GotShowEventMsg Page.ShowEvent.Msg + | GotShowEventTypesMsg Page.ShowEventTypes.Msg | GotShowStreamMsg Page.ShowStream.Msg | ReceiveTimeZone (Result String Time.ZoneName) @@ -54,6 +56,7 @@ type Msg type Page = NotFound | ShowEvent Page.ShowEvent.Model + | ShowEventTypes Page.ShowEventTypes.Model | ShowStream Page.ShowStream.Model @@ -128,6 +131,15 @@ update msg model = , Cmd.map GotShowEventMsg subCmd ) + ( GotShowEventTypesMsg showEventTypesUIMsg, ShowEventTypes showEventTypesModel ) -> + let + ( subModel, subCmd ) = + Page.ShowEventTypes.update showEventTypesUIMsg showEventTypesModel + in + ( { model | page = ShowEventTypes subModel } + , Cmd.map GotShowEventTypesMsg subCmd + ) + ( GotLayoutMsg layoutMsg, _ ) -> case model.flags of Nothing -> @@ -212,6 +224,11 @@ navigate model location = Nothing -> ( { model | page = NotFound }, Cmd.none ) + Just Route.ShowEventTypes -> + ( { model | page = ShowEventTypes (Page.ShowEventTypes.initModel flags) } + , Cmd.map GotShowEventTypesMsg (Page.ShowEventTypes.initCmd flags) + ) + Nothing -> ( { model | page = NotFound }, Cmd.none ) @@ -264,5 +281,12 @@ viewPage page selectedTime = in ( Just title, Html.map GotShowEventMsg content ) + ShowEventTypes pageModel -> + let + ( title, content ) = + Page.ShowEventTypes.view pageModel selectedTime + in + ( Just title, Html.map GotShowEventTypesMsg content ) + NotFound -> ( Nothing, Layout.viewNotFound ) diff --git a/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm b/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm new file mode 100644 index 0000000000..de7c820ae0 --- /dev/null +++ b/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm @@ -0,0 +1,121 @@ +module Page.ShowEventTypes exposing (Model, Msg(..), initCmd, initModel, update, view) + +import Api +import BrowserTime +import Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (class, href, title) +import Http +import Route +import Url + + +-- MODEL + + +type alias Model = + { eventTypes : List Api.EventType + , flags : Flags + , problems : List Problem + } + + +type Problem + = ServerError String + + +initModel : Flags -> Model +initModel flags = + { eventTypes = [] + , flags = flags + , problems = [] + } + + +-- UPDATE + + +type Msg + = EventTypesFetched (Result Http.Error (List Api.EventType)) + + +initCmd : Flags -> Cmd Msg +initCmd flags = + Api.getEventTypes EventTypesFetched flags + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + EventTypesFetched (Ok result) -> + ( { model | eventTypes = result }, Cmd.none ) + + EventTypesFetched (Err _) -> + let + serverErrors = + [ ServerError "Server error, please check backend logs for details" ] + in + ( { model | problems = serverErrors }, Cmd.none ) + + +-- VIEW + + +view : Model -> BrowserTime.TimeZone -> ( String, Html Msg ) +view { eventTypes, problems, flags } _ = + let + title = + "Event Types" + + header = + "Event Types" + in + case problems of + [] -> + ( title + , viewEventTypes flags.rootUrl header eventTypes + ) + + _ -> + ( title + , div [ class "py-8" ] + [ div [] + [ ul [] + (List.map + (\problem -> + case problem of + ServerError error -> + li [] [ text error ] + ) + problems + ) + ] + ] + ) + + +viewEventTypes : Url.Url -> String -> List Api.EventType -> Html Msg +viewEventTypes rootUrl header eventTypes = + div [ class "py-8" ] + [ h1 [ class "font-semibold text-2xl mb-4" ] [ text header ] + , if List.isEmpty eventTypes then + p [ class "text-gray-500" ] [ text "No event types found" ] + + else + ul [ class "space-y-2" ] + (List.map (viewEventType rootUrl) eventTypes) + ] + + +viewEventType : Url.Url -> Api.EventType -> Html Msg +viewEventType rootUrl eventType = + li [ class "border-b border-gray-200 py-2" ] + [ a + [ href (Route.streamUrl rootUrl eventType.streamName) + , class "text-blue-600 hover:text-blue-800 font-medium" + , title ("View " ++ eventType.eventType ++ " events") + ] + [ text eventType.eventType ] + , span [ class "text-gray-500 text-sm ml-2" ] + [ text ("→ " ++ eventType.streamName) ] + ] diff --git a/ruby_event_store-browser/elm/src/Route.elm b/ruby_event_store-browser/elm/src/Route.elm index e50aa6a95d..3e02eb6c8e 100644 --- a/ruby_event_store-browser/elm/src/Route.elm +++ b/ruby_event_store-browser/elm/src/Route.elm @@ -12,6 +12,7 @@ import Url.Parser.Query as Query type Route = BrowseEvents String Pagination.Specification | ShowEvent String + | ShowEventTypes decodeLocation : Url.Url -> Url.Url -> Maybe Route @@ -25,6 +26,7 @@ routeParser = [ Url.Parser.map (BrowseEvents "all" Pagination.empty) Url.Parser.top , Url.Parser.map browseEvents (Url.Parser.s "streams" Url.Parser.string Query.string "page[position]" Query.string "page[direction]" Query.string "page[count]") , Url.Parser.map ShowEvent (Url.Parser.s "events" Url.Parser.string) + , Url.Parser.map ShowEventTypes (Url.Parser.s "types") ] diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb index 48d01936b9..4ea6fdd70d 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb @@ -15,7 +15,7 @@ def call event_classes << klass if klass < RubyEventStore::Event && !klass.name.nil? end - event_classes.sort_by(&:name).map do |klass| + event_classes.sort_by(&:name).uniq(&:name).map do |klass| EventType.new(event_type: klass.name, stream_name: "$by_type_#{klass.name}") end end From 5282b09acbea3e0ddcbaabcff250d749d4bf9a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Fri, 7 Nov 2025 00:39:33 +0100 Subject: [PATCH 07/16] fix: serve HTML bootstrap for /types route --- ruby_event_store-browser/lib/ruby_event_store/browser/app.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bd0e346625..76dc380223 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 @@ -105,7 +105,7 @@ def call(env) ) end - %w[/ /events/:event_id /streams/:stream_name].each do |starting_route| + %w[/ /events/:event_id /streams/:stream_name /types].each do |starting_route| router.add_route("GET", starting_route) do |_, urls| erb bootstrap_html, browser_js_src: urls.browser_js_url, From 2428c6fc033053897981a1ee6e92a0279894bec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Fri, 7 Nov 2025 10:00:04 +0100 Subject: [PATCH 08/16] Use actual event classes in devserver demo data --- ruby_event_store-browser/devserver/config.ru | 73 +++++++++++++++----- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/ruby_event_store-browser/devserver/config.ru b/ruby_event_store-browser/devserver/config.ru index df5a60aac8..805f334b01 100644 --- a/ruby_event_store-browser/devserver/config.ru +++ b/ruby_event_store-browser/devserver/config.ru @@ -17,28 +17,68 @@ sample_data = { some_float_infinity_attribute: 1.0 / 0, } -sample_event_type = - lambda do - namespaces = %w[IdentityAndAccess Subscriptions Payments Accounting Banking Reporting] +module IdentityAndAccess + SomethingHappened = Class.new(RubyEventStore::Event) + SomeoneDidThing = Class.new(RubyEventStore::Event) + ThingConfirmed = Class.new(RubyEventStore::Event) + ThingRejected = Class.new(RubyEventStore::Event) +end +module Subscriptions + SomethingHappened = Class.new(RubyEventStore::Event) + SomeoneDidThing = Class.new(RubyEventStore::Event) + ThingConfirmed = Class.new(RubyEventStore::Event) + ThingRejected = Class.new(RubyEventStore::Event) +end + +module Payments + SomethingHappened = Class.new(RubyEventStore::Event) + SomeoneDidThing = Class.new(RubyEventStore::Event) + ThingConfirmed = Class.new(RubyEventStore::Event) + ThingRejected = Class.new(RubyEventStore::Event) +end + +module Accounting + SomethingHappened = Class.new(RubyEventStore::Event) + SomeoneDidThing = Class.new(RubyEventStore::Event) + ThingConfirmed = Class.new(RubyEventStore::Event) + ThingRejected = Class.new(RubyEventStore::Event) +end + +module Banking + SomethingHappened = Class.new(RubyEventStore::Event) + SomeoneDidThing = Class.new(RubyEventStore::Event) + ThingConfirmed = Class.new(RubyEventStore::Event) + ThingRejected = Class.new(RubyEventStore::Event) +end + +# Reporting namespace intentionally not defined - these will use event_type metadata for testing + +sample_event_class = + lambda do + namespaces = %w[IdentityAndAccess Subscriptions Payments Accounting Banking] events = %w[SomethingHappened SomeoneDidThing ThingConfirmed ThingRejected] - [namespaces.sample, events.sample].join("::") + namespace = namespaces.sample + event = events.sample + Object.const_get("#{namespace}::#{event}") + end + +sample_event_type_without_class = + lambda do + events = %w[SomethingHappened SomeoneDidThing ThingConfirmed ThingRejected] + "Reporting::#{events.sample}" end event_store.publish( - 90.times.map { RubyEventStore::Event.new(data: sample_data, metadata: { event_type: sample_event_type.call }) }, + 80 + .times + .map { sample_event_class.call.new(data: sample_data) } + + 10.times.map { RubyEventStore::Event.new(data: sample_data, metadata: { event_type: sample_event_type_without_class.call }) }, stream_name: "DummyStream$78", ) -other_event = - RubyEventStore::Event.new( - data: sample_data, - metadata: { - event_type: sample_event_type.call, - correlation_id: "469904c5-46ee-43a3-857f-16a455cfe337", - }, - ) +other_event = sample_event_class.call.new(data: sample_data, metadata: { correlation_id: "469904c5-46ee-43a3-857f-16a455cfe337" }) event_store.publish(other_event, stream_name: "OtherStream$91") 21.times do @@ -46,15 +86,12 @@ event_store.publish(other_event, stream_name: "OtherStream$91") correlation_id: other_event.metadata[:correlation_id] || other_event.event_id, causation_id: other_event.event_id, ) do - event_store.publish( - RubyEventStore::Event.new(data: sample_data, metadata: { event_type: sample_event_type.call }), - stream_name: "DummyStream$79", - ) + event_store.publish(sample_event_class.call.new(data: sample_data), stream_name: "DummyStream$79") end end RELATED_STREAMS_QUERY = ->(stream_name) do - stream_name.start_with?("$by_type_#{sample_event_type.call}") ? %w[all $by_type_#{sample_event_type.call}] : [] + stream_name.start_with?("$by_type_#{sample_event_class.call.name}") ? %w[all $by_type_#{sample_event_class.call.name}] : [] end browser_app = From 81f5284dc9dd3ec066012a3d96d2be00d3dd1e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Fri, 7 Nov 2025 11:54:31 +0100 Subject: [PATCH 09/16] refactor: simplify devserver related streams query and clarify comment --- ruby_event_store-browser/devserver/config.ru | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ruby_event_store-browser/devserver/config.ru b/ruby_event_store-browser/devserver/config.ru index 805f334b01..8ac48036ef 100644 --- a/ruby_event_store-browser/devserver/config.ru +++ b/ruby_event_store-browser/devserver/config.ru @@ -53,6 +53,7 @@ module Banking end # Reporting namespace intentionally not defined - these will use event_type metadata for testing +# the events which don't have the event classes available in the namespace sample_event_class = lambda do @@ -91,7 +92,7 @@ event_store.publish(other_event, stream_name: "OtherStream$91") end RELATED_STREAMS_QUERY = ->(stream_name) do - stream_name.start_with?("$by_type_#{sample_event_class.call.name}") ? %w[all $by_type_#{sample_event_class.call.name}] : [] + stream_name.start_with?("$by_type_") ? %w[all] : [] end browser_app = From 829d7a9d3fe70f2ae29d6d2276e8e8887b990c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Fri, 7 Nov 2025 12:45:59 +0100 Subject: [PATCH 10/16] feat: make event types feature opt-in with feature gate --- .../elm/src/Page/ShowEventTypes.elm | 23 +++++++++++++++---- .../lib/ruby_event_store/browser.rb | 1 - .../lib/ruby_event_store/browser/app.rb | 18 +++++++++++---- .../spec/api/event_types_spec.rb | 23 +++++++++++++++---- 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm b/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm index de7c820ae0..d89f885a5f 100644 --- a/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm +++ b/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm @@ -22,6 +22,7 @@ type alias Model = type Problem = ServerError String + | FeatureNotEnabled initModel : Flags -> Model @@ -50,12 +51,17 @@ update msg model = EventTypesFetched (Ok result) -> ( { model | eventTypes = result }, Cmd.none ) - EventTypesFetched (Err _) -> + EventTypesFetched (Err error) -> let - serverErrors = - [ ServerError "Server error, please check backend logs for details" ] + problem = + case error of + Http.BadStatus 422 -> + FeatureNotEnabled + + _ -> + ServerError "Server error, please check backend logs for details" in - ( { model | problems = serverErrors }, Cmd.none ) + ( { model | problems = [ problem ] }, Cmd.none ) -- VIEW @@ -86,6 +92,15 @@ view { eventTypes, problems, flags } _ = case problem of ServerError error -> li [] [ text error ] + + FeatureNotEnabled -> + li [ class "text-gray-700" ] + [ p [ class "font-semibold mb-2" ] [ text "Event Types feature is not enabled" ] + , p [ class "text-sm" ] + [ text "This experimental feature must be explicitly enabled when configuring the Browser. " + , text "Please check the documentation for configuration details." + ] + ] ) problems ) 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 3563282413..3ec2d285de 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser.rb @@ -5,7 +5,6 @@ module Browser PAGE_SIZE = 20 SERIALIZED_GLOBAL_STREAM_NAME = "all".freeze DEFAULT_RELATED_STREAMS_QUERY = ->(stream_name) { [] } - DEFAULT_EXPERIMENTAL_EVENT_TYPES_QUERY = ->(event_store) { EventTypesQuerying::DefaultQuery.new(event_store) } end end 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 76dc380223..7d279b263d 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 @@ -15,7 +15,7 @@ def self.for( api_url: nil, environment: nil, related_streams_query: DEFAULT_RELATED_STREAMS_QUERY, - experimental_event_types_query: DEFAULT_EXPERIMENTAL_EVENT_TYPES_QUERY + experimental_event_types_query: nil ) warn(<<~WARN) if environment Passing :environment to RubyEventStore::Browser::App.for is deprecated. @@ -99,10 +99,14 @@ def call(env) ) end router.add_route("GET", "/api/event_types") do - json GetEventTypes.new( - event_store: event_store, - event_types_query: experimental_event_types_query, - ) + if experimental_event_types_query.nil? + feature_not_enabled + else + json GetEventTypes.new( + event_store: event_store, + event_types_query: experimental_event_types_query, + ) + end end %w[/ /events/:event_id /streams/:stream_name /types].each do |starting_route| @@ -159,6 +163,10 @@ def not_found [404, {}, []] end + def feature_not_enabled + [422, {}, []] + end + def json(body) [200, { "content-type" => "application/vnd.api+json" }, [JSON.dump(body.to_h)]] end diff --git a/ruby_event_store-browser/spec/api/event_types_spec.rb b/ruby_event_store-browser/spec/api/event_types_spec.rb index dc51365c71..76ed78c608 100644 --- a/ruby_event_store-browser/spec/api/event_types_spec.rb +++ b/ruby_event_store-browser/spec/api/event_types_spec.rb @@ -6,7 +6,20 @@ module RubyEventStore ::RSpec.describe Browser do include Browser::IntegrationHelpers - specify "returns list of event types" do + specify "returns 422 when feature not enabled" do + api_client.get "/api/event_types" + + expect(api_client.last_response.status).to eq(422) + end + + specify "returns list of event types when feature enabled" do + app = + Browser::App.for( + event_store_locator: -> { event_store }, + experimental_event_types_query: ->(es) { Browser::EventTypesQuerying::DefaultQuery.new(es) }, + ) + + enabled_api_client = ApiClient.new(app, "www.example.com") # Define some test event classes test_event_1 = Class.new(RubyEventStore::Event) stub_const("OrderPlaced", test_event_1) @@ -14,12 +27,12 @@ module RubyEventStore test_event_2 = Class.new(RubyEventStore::Event) stub_const("OrderCancelled", test_event_2) - api_client.get "/api/event_types" + enabled_api_client.get "/api/event_types" - expect(api_client.last_response).to be_ok - expect(api_client.parsed_body["data"]).to be_an(Array) + expect(enabled_api_client.last_response).to be_ok + expect(enabled_api_client.parsed_body["data"]).to be_an(Array) - event_types = api_client.parsed_body["data"] + event_types = enabled_api_client.parsed_body["data"] order_placed = event_types.find { |et| et["attributes"]["event_type"] == "OrderPlaced" } order_cancelled = event_types.find { |et| et["attributes"]["event_type"] == "OrderCancelled" } From c4c02fd868562bd7167e66511f79248cc44e66df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Fri, 7 Nov 2025 12:46:32 +0100 Subject: [PATCH 11/16] feat: enable event types discovery in devserver demo --- ruby_event_store-browser/devserver/config.ru | 1 + 1 file changed, 1 insertion(+) diff --git a/ruby_event_store-browser/devserver/config.ru b/ruby_event_store-browser/devserver/config.ru index 8ac48036ef..47589582a7 100644 --- a/ruby_event_store-browser/devserver/config.ru +++ b/ruby_event_store-browser/devserver/config.ru @@ -99,6 +99,7 @@ browser_app = RubyEventStore::Browser::App.for( event_store_locator: -> { event_store }, related_streams_query: RELATED_STREAMS_QUERY, + experimental_event_types_query: ->(es) { RubyEventStore::Browser::EventTypesQuerying::DefaultQuery.new(es) }, ) mount_point = "/" From bd8d4eb447c266587a00b64c168adeb0893cf671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Fri, 7 Nov 2025 13:37:57 +0100 Subject: [PATCH 12/16] ui: improve event type listing layout with prominent names --- .../elm/src/Page/ShowEventTypes.elm | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm b/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm index d89f885a5f..3f9059d42a 100644 --- a/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm +++ b/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm @@ -125,12 +125,12 @@ viewEventTypes rootUrl header eventTypes = viewEventType : Url.Url -> Api.EventType -> Html Msg viewEventType rootUrl eventType = li [ class "border-b border-gray-200 py-2" ] - [ a + [ span [ class "font-medium" ] [ text eventType.eventType ] + , span [ class "text-gray-500 text-sm mx-2" ] [ text "→" ] + , a [ href (Route.streamUrl rootUrl eventType.streamName) - , class "text-blue-600 hover:text-blue-800 font-medium" + , class "text-blue-600 hover:text-blue-800 text-sm" , title ("View " ++ eventType.eventType ++ " events") ] - [ text eventType.eventType ] - , span [ class "text-gray-500 text-sm ml-2" ] - [ text ("→ " ++ eventType.streamName) ] + [ text eventType.streamName ] ] From 8169e7af474b6095d1b4eb0131c7794f0f831f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Fri, 7 Nov 2025 14:40:56 +0100 Subject: [PATCH 13/16] test: improve event types query shared examples and mutation coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed shared examples to accept query instance instead of class - Added expectation for result.size > 1 in shared examples - Added test to verify event_store is passed correctly to query factory - Achieved 100% mutation coverage for GetEventTypes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../spec/api/event_types_spec.rb | 29 +++++++++++++++++++ .../default_query_spec.rb | 11 ++++++- .../shared_examples/event_types_query.rb | 15 ++-------- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/ruby_event_store-browser/spec/api/event_types_spec.rb b/ruby_event_store-browser/spec/api/event_types_spec.rb index 76ed78c608..2e98555935 100644 --- a/ruby_event_store-browser/spec/api/event_types_spec.rb +++ b/ruby_event_store-browser/spec/api/event_types_spec.rb @@ -97,5 +97,34 @@ def query.call ], ) end + + specify "passes event_store to query factory" do + received_event_store = nil + custom_query = + lambda do |es| + received_event_store = es + query = Object.new + def query.call + [ + RubyEventStore::Browser::EventTypesQuerying::EventType.new( + event_type: "TestEvent", + stream_name: "$by_type_TestEvent", + ), + ] + end + query + end + + app = + Browser::App.for( + event_store_locator: -> { event_store }, + experimental_event_types_query: custom_query, + ) + + custom_api_client = ApiClient.new(app, "www.example.com") + custom_api_client.get "/api/event_types" + + expect(received_event_store).to eq(event_store) + end end end diff --git a/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb index dc22cb16e7..d245d2c3d7 100644 --- a/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb +++ b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb @@ -6,7 +6,16 @@ module RubyEventStore module Browser module EventTypesQuerying ::RSpec.describe DefaultQuery do - it_behaves_like :event_types_query, DefaultQuery + before do + # Define test event classes for shared examples + test_event_1 = Class.new(RubyEventStore::Event) + stub_const("SharedExampleEvent1", test_event_1) + + test_event_2 = Class.new(RubyEventStore::Event) + stub_const("SharedExampleEvent2", test_event_2) + end + + it_behaves_like :event_types_query, -> { DefaultQuery.new(RubyEventStore::Client.new) }.call specify "finds all classes inheriting from RubyEventStore::Event" do event_store = RubyEventStore::Client.new diff --git a/ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb b/ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb index 2a987b2bec..323d13c0ac 100644 --- a/ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb +++ b/ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb @@ -1,30 +1,19 @@ # frozen_string_literal: true -RSpec.shared_examples :event_types_query do |query_class| +RSpec.shared_examples :event_types_query do |query| specify "responds to call" do - event_store = RubyEventStore::Client.new - query = query_class.new(event_store) - expect(query).to respond_to(:call) end specify "returns array of EventType objects" do - event_store = RubyEventStore::Client.new - query = query_class.new(event_store) - result = query.call expect(result).to be_an(Array) + expect(result.size).to be > 1 result.each do |event_type| expect(event_type).to be_a(RubyEventStore::Browser::EventTypesQuerying::EventType) expect(event_type.event_type).to be_a(String) expect(event_type.stream_name).to be_a(String) end end - - specify "can be initialized with event_store" do - event_store = RubyEventStore::Client.new - - expect { query_class.new(event_store) }.not_to raise_error - end end From d2de3a0ba2cf520d000f2834d3498bd3a3d45dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Fri, 7 Nov 2025 14:43:25 +0100 Subject: [PATCH 14/16] test: add edge case tests for DefaultQuery to improve mutation coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added test for filtering out classes without names - Added test for filtering out non-Event classes - Added test for stream name using class name format - Added test for deduplication of classes with same name Improved mutation coverage from 77.92% to 88.31% 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../default_query_spec.rb | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb index d245d2c3d7..54d3f84a9a 100644 --- a/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb +++ b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb @@ -58,6 +58,76 @@ module EventTypesQuerying expect(result).to eq([]) end + + specify "filters out classes without names" do + event_store = RubyEventStore::Client.new + + # Create an anonymous event class (no name) + anonymous_event_class = Class.new(RubyEventStore::Event) + + # Create a named event class + named_event_class = Class.new(RubyEventStore::Event) + stub_const("NamedEvent", named_event_class) + + query = DefaultQuery.new(event_store) + result = query.call + + event_types = result.map(&:event_type) + expect(event_types).to include("NamedEvent") + expect(event_types).not_to include(nil) + end + + specify "filters out non-Event classes" do + event_store = RubyEventStore::Client.new + + # Define event classes + event_class = Class.new(RubyEventStore::Event) + stub_const("MyEvent", event_class) + + # Define non-event class + non_event_class = Class.new + stub_const("NonEvent", non_event_class) + + query = DefaultQuery.new(event_store) + result = query.call + + event_types = result.map(&:event_type) + expect(event_types).to include("MyEvent") + expect(event_types).not_to include("NonEvent") + end + + specify "stream name uses class name, not class object" do + event_store = RubyEventStore::Client.new + + event_class = Class.new(RubyEventStore::Event) + stub_const("TestEventClass", event_class) + + query = DefaultQuery.new(event_store) + result = query.call + + test_event_type = result.find { |et| et.event_type == "TestEventClass" } + expect(test_event_type.stream_name).to eq("$by_type_TestEventClass") + expect(test_event_type.stream_name).not_to match(/Class:0x/) + end + + specify "deduplicates classes with same name" do + event_store = RubyEventStore::Client.new + + # Create event class + event_class = Class.new(RubyEventStore::Event) + stub_const("DuplicateEvent", event_class) + + # Simulate ObjectSpace returning duplicates by stubbing + classes_to_return = [event_class, event_class, String, Integer] + + allow(ObjectSpace).to receive(:each_object).with(Class).and_yield(event_class).and_yield(event_class).and_yield(String).and_yield(Integer) + + query = DefaultQuery.new(event_store) + result = query.call + + duplicate_events = result.select { |et| et.event_type == "DuplicateEvent" } + expect(duplicate_events.count).to eq(1) + end end end end From fc1644f481202d3faef764371b942cf890f0971d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Fri, 7 Nov 2025 21:33:06 +0100 Subject: [PATCH 15/16] refactor: use Class#subclasses instead of ObjectSpace for better performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ObjectSpace iteration with recursive Class#subclasses calls: - More performant in production environments - Cleaner and more idiomatic Ruby code - Updated tests to stub subclasses instead of ObjectSpace 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../event_types_querying/default_query.rb | 18 +++++++++--------- .../event_types_querying/default_query_spec.rb | 15 ++++++++++----- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb index 4ea6fdd70d..c7dd5f5cae 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb @@ -9,20 +9,20 @@ def initialize(event_store) end def call - event_classes = [] - - ObjectSpace.each_object(Class) do |klass| - event_classes << klass if klass < RubyEventStore::Event && !klass.name.nil? - end - - event_classes.sort_by(&:name).uniq(&:name).map do |klass| - EventType.new(event_type: klass.name, stream_name: "$by_type_#{klass.name}") - end + all_event_subclasses(RubyEventStore::Event) + .select { |klass| !klass.name.nil? } + .sort_by(&:name) + .uniq(&:name) + .map { |klass| EventType.new(event_type: klass.name, stream_name: "$by_type_#{klass.name}") } end private attr_reader :event_store + + def all_event_subclasses(klass) + klass.subclasses + klass.subclasses.flat_map { |subclass| all_event_subclasses(subclass) } + end end end end diff --git a/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb index 54d3f84a9a..bf15b358a0 100644 --- a/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb +++ b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb @@ -50,8 +50,8 @@ module EventTypesQuerying specify "returns empty array when no event classes are defined" do event_store = RubyEventStore::Client.new - # Remove all test event classes from ObjectSpace - allow(ObjectSpace).to receive(:each_object).with(Class).and_return([]) + # Stub subclasses to return empty array + allow(RubyEventStore::Event).to receive(:subclasses).and_return([]) query = DefaultQuery.new(event_store) result = query.call @@ -117,10 +117,15 @@ module EventTypesQuerying event_class = Class.new(RubyEventStore::Event) stub_const("DuplicateEvent", event_class) - # Simulate ObjectSpace returning duplicates by stubbing - classes_to_return = [event_class, event_class, String, Integer] + # Create a subclass with the same name (edge case) + # This simulates the rare case where class reloading might cause duplicates + duplicate_class = Class.new(RubyEventStore::Event) + allow(duplicate_class).to receive(:name).and_return("DuplicateEvent") + allow(duplicate_class).to receive(:subclasses).and_return([]) - allow(ObjectSpace).to receive(:each_object).with(Class).and_yield(event_class).and_yield(event_class).and_yield(String).and_yield(Integer) + # Stub to return the duplicate + allow(RubyEventStore::Event).to receive(:subclasses).and_return([event_class, duplicate_class]) + allow(event_class).to receive(:subclasses).and_return([]) query = DefaultQuery.new(event_store) result = query.call From 14818c46d040049d71f37957ca577e2568aa500f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20=C5=81asocha?= Date: Sat, 8 Nov 2025 10:03:38 +0100 Subject: [PATCH 16/16] test: achieve 98.72% mutation coverage for event types feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test for nested subclasses recursion - Add test for alphabetical sorting of event types - Remove unused @event_store instance variable from DefaultQuery - 154/156 mutations killed (2 remaining are semantic equivalents) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../event_types_querying/default_query.rb | 6 +-- .../default_query_spec.rb | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb index c7dd5f5cae..b92f0f84e2 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb @@ -4,9 +4,7 @@ module RubyEventStore module Browser module EventTypesQuerying class DefaultQuery - def initialize(event_store) - @event_store = event_store - end + def initialize(event_store); end def call all_event_subclasses(RubyEventStore::Event) @@ -18,8 +16,6 @@ def call private - attr_reader :event_store - def all_event_subclasses(klass) klass.subclasses + klass.subclasses.flat_map { |subclass| all_event_subclasses(subclass) } end diff --git a/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb index bf15b358a0..2ff879206a 100644 --- a/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb +++ b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb @@ -133,6 +133,51 @@ module EventTypesQuerying duplicate_events = result.select { |et| et.event_type == "DuplicateEvent" } expect(duplicate_events.count).to eq(1) end + + specify "finds nested subclasses recursively" do + event_store = RubyEventStore::Client.new + + # Create a base event class + base_event = Class.new(RubyEventStore::Event) + stub_const("BaseEvent", base_event) + + # Create a nested subclass + nested_event = Class.new(base_event) + stub_const("NestedEvent", nested_event) + + # Create a deeply nested subclass + deep_nested_event = Class.new(nested_event) + stub_const("DeepNestedEvent", deep_nested_event) + + query = DefaultQuery.new(event_store) + result = query.call + + event_types = result.map(&:event_type) + expect(event_types).to include("BaseEvent", "NestedEvent", "DeepNestedEvent") + end + + specify "returns event types sorted alphabetically by name" do + event_store = RubyEventStore::Client.new + + # Create events in non-alphabetical order + zebra_event = Class.new(RubyEventStore::Event) + stub_const("ZebraEvent", zebra_event) + + apple_event = Class.new(RubyEventStore::Event) + stub_const("AppleEvent", apple_event) + + middle_event = Class.new(RubyEventStore::Event) + stub_const("MiddleEvent", middle_event) + + query = DefaultQuery.new(event_store) + result = query.call + + # Extract just the test events we care about + test_event_names = + result.map(&:event_type).select { |name| %w[ZebraEvent AppleEvent MiddleEvent].include?(name) } + + expect(test_event_names).to eq(%w[AppleEvent MiddleEvent ZebraEvent]) + end end end end