From 5dffc21da261751f09faf2219f1d6658e62829f0 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 18:35:47 +0200 Subject: [PATCH 01/39] Add ruby_event_store-cli gem skeleton Scaffold for the new CLI contrib gem: gemspec, Gemfile, version, main lib entry point, and executable bin/res. Co-Authored-By: Claude Sonnet 4.6 --- contrib/ruby_event_store-cli/Gemfile | 11 +++++++ contrib/ruby_event_store-cli/bin/res | 8 +++++ .../lib/ruby_event_store/cli.rb | 8 +++++ .../lib/ruby_event_store/cli/commands.rb | 11 +++++++ .../lib/ruby_event_store/cli/version.rb | 7 +++++ .../ruby_event_store-cli.gemspec | 30 +++++++++++++++++++ 6 files changed, 75 insertions(+) create mode 100644 contrib/ruby_event_store-cli/Gemfile create mode 100755 contrib/ruby_event_store-cli/bin/res create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli.rb create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/version.rb create mode 100644 contrib/ruby_event_store-cli/ruby_event_store-cli.gemspec diff --git a/contrib/ruby_event_store-cli/Gemfile b/contrib/ruby_event_store-cli/Gemfile new file mode 100644 index 0000000000..15ca2038cc --- /dev/null +++ b/contrib/ruby_event_store-cli/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source "https://rubygems.org" +gemspec + +eval_gemfile "../../support/bundler/Gemfile.shared" + +gem "ruby_event_store", path: "../.." +gem "rails_event_store_active_record", path: "../../ruby_event_store-active_record" +gem "sqlite3", ">= 2.1" +gem "rails", "~> 8.0.0" diff --git a/contrib/ruby_event_store-cli/bin/res b/contrib/ruby_event_store-cli/bin/res new file mode 100755 index 0000000000..3fc6d75e9e --- /dev/null +++ b/contrib/ruby_event_store-cli/bin/res @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "dry/cli" +require_relative "../lib/ruby_event_store/cli" +require_relative "../lib/ruby_event_store/cli/commands" + +Dry::CLI.new(RubyEventStore::CLI::Commands).call diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli.rb new file mode 100644 index 0000000000..1670aa2d89 --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require_relative "cli/version" + +module RubyEventStore + module CLI + end +end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb new file mode 100644 index 0000000000..4d5f228d9f --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "dry/cli" + +module RubyEventStore + module CLI + module Commands + extend Dry::CLI::Registry + end + end +end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/version.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/version.rb new file mode 100644 index 0000000000..bbd718ae13 --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module RubyEventStore + module CLI + VERSION = "0.1.0" + end +end diff --git a/contrib/ruby_event_store-cli/ruby_event_store-cli.gemspec b/contrib/ruby_event_store-cli/ruby_event_store-cli.gemspec new file mode 100644 index 0000000000..a4a0d120fc --- /dev/null +++ b/contrib/ruby_event_store-cli/ruby_event_store-cli.gemspec @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "lib/ruby_event_store/cli/version" + +Gem::Specification.new do |spec| + spec.name = "ruby_event_store-cli" + spec.version = RubyEventStore::CLI::VERSION + spec.license = "MIT" + spec.author = "Arkency" + spec.email = "dev@arkency.com" + spec.summary = "Command-line interface for Ruby Event Store" + spec.homepage = "https://railseventstore.org" + spec.files = Dir["lib/**/*"] + spec.require_paths = %w[lib] + spec.extra_rdoc_files = %w[README.md] + spec.bindir = "bin" + spec.executables = %w[res] + + 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 = ">= 2.7" + + spec.add_dependency "dry-cli", ">= 1.0" + spec.add_dependency "ruby_event_store", ">= 1.0.0" +end From 38ea9c0cbbca714d6361c841a5611b24410d3cd9 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 19:02:07 +0200 Subject: [PATCH 02/39] Add EventStoreResolver for CLI connection setup Auto-detects config/environment.rb in CWD. Supports explicit override via EventStoreResolver.event_store= for non-standard setups. Co-Authored-By: Claude Sonnet 4.6 --- .../cli/event_store_resolver.rb | 43 +++++++++++++++ .../spec/event_store_resolver_spec.rb | 54 +++++++++++++++++++ .../ruby_event_store-cli/spec/spec_helper.rb | 12 +++++ .../spec/support/fake_configuration.rb | 23 ++++++++ 4 files changed, 132 insertions(+) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_store_resolver.rb create mode 100644 contrib/ruby_event_store-cli/spec/event_store_resolver_spec.rb create mode 100644 contrib/ruby_event_store-cli/spec/spec_helper.rb create mode 100644 contrib/ruby_event_store-cli/spec/support/fake_configuration.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_store_resolver.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_store_resolver.rb new file mode 100644 index 0000000000..f1258893df --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_store_resolver.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module RubyEventStore + module CLI + module EventStoreResolver + DEFAULT_REQUIRE_PATH = "config/environment.rb" + CANDIDATE_CONSTS = %w[EVENT_STORE RES EventStore].freeze + + class << self + attr_accessor :event_store + end + + def self.resolve + return event_store if event_store + + require File.expand_path(DEFAULT_REQUIRE_PATH) + find_event_store || abort(<<~MSG) + Could not find event store instance after loading #{DEFAULT_REQUIRE_PATH}. + + Expected one of: + - Rails.configuration.event_store (standard RES setup) + - A constant named: #{CANDIDATE_CONSTS.join(", ")} + + Or configure it explicitly: + RubyEventStore::CLI::EventStoreResolver.event_store = MyApp::EventStore + MSG + end + + def self.find_event_store + if defined?(Rails) && Rails.respond_to?(:configuration) && + Rails.configuration.respond_to?(:event_store) + return Rails.configuration.event_store + end + + CANDIDATE_CONSTS.each do |const_name| + return Object.const_get(const_name) if Object.const_defined?(const_name) + end + + nil + end + end + end +end diff --git a/contrib/ruby_event_store-cli/spec/event_store_resolver_spec.rb b/contrib/ruby_event_store-cli/spec/event_store_resolver_spec.rb new file mode 100644 index 0000000000..1155d17e3b --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/event_store_resolver_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require "ruby_event_store/cli/event_store_resolver" + +module RubyEventStore + module CLI + RSpec.describe EventStoreResolver do + describe ".resolve" do + it "returns instance set via initializer without loading environment" do + fake_store = instance_double(RubyEventStore::Client) + EventStoreResolver.event_store = fake_store + + expect(EventStoreResolver).not_to receive(:require) + expect(EventStoreResolver.resolve).to eq(fake_store) + end + end + + describe ".find_event_store" do + it "returns Rails.configuration.event_store when available" do + fake_store = instance_double(RubyEventStore::Client) + fake_config = FakeConfiguration.new + fake_config.event_store = fake_store + stub_const("Rails", double(configuration: fake_config, respond_to?: true)) + + expect(EventStoreResolver.find_event_store).to eq(fake_store) + end + + it "falls back to EVENT_STORE constant" do + hide_const("Rails") + fake_store = instance_double(RubyEventStore::Client) + stub_const("EVENT_STORE", fake_store) + + expect(EventStoreResolver.find_event_store).to eq(fake_store) + end + + it "falls back to RES constant" do + hide_const("Rails") + fake_store = instance_double(RubyEventStore::Client) + stub_const("RES", fake_store) + + expect(EventStoreResolver.find_event_store).to eq(fake_store) + end + + it "returns nil when nothing found" do + hide_const("Rails") + EventStoreResolver::CANDIDATE_CONSTS.each { |c| hide_const(c) if Object.const_defined?(c) } + + expect(EventStoreResolver.find_event_store).to be_nil + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/spec/spec_helper.rb b/contrib/ruby_event_store-cli/spec/spec_helper.rb new file mode 100644 index 0000000000..01145a07d3 --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/spec_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "ruby_event_store" +require_relative "support/fake_configuration" + +RSpec.configure do |config| + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.after { RubyEventStore::CLI::EventStoreResolver.event_store = nil } +end diff --git a/contrib/ruby_event_store-cli/spec/support/fake_configuration.rb b/contrib/ruby_event_store-cli/spec/support/fake_configuration.rb new file mode 100644 index 0000000000..8505f4aa7e --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/support/fake_configuration.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class FakeConfiguration + def initialize + @options = {} + end + + def respond_to_missing?(name, include_private = false) + @options.key?(name) || super + end + + private + + def method_missing(name, *args, &blk) + if name.to_s.end_with?("=") + @options[name.to_s.chomp("=").to_sym] = args.first + elsif @options.key?(name) + @options[name] + else + super + end + end +end From 21dc6961111ce0cc97ca632d4a3c6e15b6d31f38 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 19:22:55 +0200 Subject: [PATCH 03/39] Add command routing with dry-cli registry Registers stream events as placeholder. res --help and res stream events --help work. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands.rb | 3 +++ .../cli/commands/stream_events.rb | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb index 4d5f228d9f..3e64584201 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true require "dry/cli" +require_relative "commands/stream_events" module RubyEventStore module CLI module Commands extend Dry::CLI::Registry + + register "stream events", StreamEvents end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb new file mode 100644 index 0000000000..a1f8918025 --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "dry/cli" + +module RubyEventStore + module CLI + module Commands + class StreamEvents < Dry::CLI::Command + desc "List events in a stream" + + argument :stream_name, required: true, desc: "Stream name" + + def call(stream_name:, **) + end + end + end + end +end From 71ffcbd50ec12a50cb49ecdc1536e60abab01efd Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 19:25:39 +0200 Subject: [PATCH 04/39] Implement stream events command res stream events STREAM_NAME reads and displays events in table or json format with configurable limit. Co-Authored-By: Claude Sonnet 4.6 --- .../cli/commands/stream_events.rb | 39 +++++++++++++++- .../spec/commands/stream_events_spec.rb | 45 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index a1f8918025..5f5a7c9d8a 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "dry/cli" +require_relative "../event_store_resolver" module RubyEventStore module CLI @@ -9,8 +10,44 @@ class StreamEvents < Dry::CLI::Command desc "List events in a stream" argument :stream_name, required: true, desc: "Stream name" + option :limit, type: :integer, default: 50, desc: "Max number of events (default: 50)" + option :format, default: "table", values: %w[table json], desc: "Output format" - def call(stream_name:, **) + def call(stream_name:, limit:, format:, **) + event_store = EventStoreResolver.resolve + events = event_store.read.stream(stream_name).limit(limit.to_i).to_a + render(events, format: format) + rescue => e + warn e.message + exit 1 + end + + private + + def render(events, format:) + case format + when "json" then render_json(events) + when "table" then render_table(events) + end + end + + def render_json(events) + require "json" + puts JSON.pretty_generate(events.map { |e| + { event_id: e.event_id, event_type: e.event_type, data: e.data, + metadata: e.metadata.to_h, timestamp: e.timestamp.iso8601(3) } + }) + end + + def render_table(events) + return puts "(no events)" if events.empty? + + puts "%-36s %-40s %s" % ["EVENT ID", "TYPE", "TIMESTAMP"] + puts "-" * 90 + events.each do |e| + puts "%-36s %-40s %s" % [e.event_id, e.event_type, e.timestamp.iso8601(3)] + end + puts "\n#{events.size} event(s)" end end end diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb new file mode 100644 index 0000000000..52b4f3c09e --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ruby_event_store/cli/commands/stream_events" + +module RubyEventStore + module CLI + module Commands + RSpec.describe StreamEvents do + let(:event_store) { RubyEventStore::Client.new } + let(:command) { StreamEvents.new } + + before { EventStoreResolver.event_store = event_store } + + describe "#call" do + it "reads events from given stream" do + event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") + + expect { command.call(stream_name: "test-stream", limit: 50, format: "table") } + .to output(/test-stream|RubyEventStore::Event/).to_stdout + end + + it "respects limit" do + 5.times { event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") } + + expect { command.call(stream_name: "test-stream", limit: 2, format: "table") } + .to output(/2 event\(s\)/).to_stdout + end + + it "outputs json when format is json" do + event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") + + expect { command.call(stream_name: "test-stream", limit: 50, format: "json") } + .to output(/event_id/).to_stdout + end + + it "prints message when stream is empty" do + expect { command.call(stream_name: "empty-stream", limit: 50, format: "table") } + .to output(/no events/).to_stdout + end + end + end + end + end +end From cb39f3e67d99fac8cdb6a9aa87ec833475a2a044 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 19:36:17 +0200 Subject: [PATCH 05/39] Add filtering options to stream events command Add --type, --after, --before, --from options to res stream events for filtering by event class, time range, and pagination anchor. Co-Authored-By: Claude Sonnet 4.6 --- .../cli/commands/stream_events.rb | 13 +++++-- .../spec/commands/stream_events_spec.rb | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index 5f5a7c9d8a..14a3d3309b 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -12,10 +12,19 @@ class StreamEvents < Dry::CLI::Command argument :stream_name, required: true, desc: "Stream name" option :limit, type: :integer, default: 50, desc: "Max number of events (default: 50)" option :format, default: "table", values: %w[table json], desc: "Output format" + option :type, desc: "Filter by event type (class name)" + option :after, desc: "Filter events newer than timestamp (ISO8601)" + option :before, desc: "Filter events older than timestamp (ISO8601)" + option :from, desc: "Start reading from event ID (exclusive)" - def call(stream_name:, limit:, format:, **) + def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from: nil, **) event_store = EventStoreResolver.resolve - events = event_store.read.stream(stream_name).limit(limit.to_i).to_a + reader = event_store.read.stream(stream_name) + reader = reader.of_type(Object.const_get(type)) if type + reader = reader.newer_than(Time.parse(after)) if after + reader = reader.older_than(Time.parse(before)) if before + reader = reader.from(from) if from + events = reader.limit(limit.to_i).to_a render(events, format: format) rescue => e warn e.message diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb index 52b4f3c09e..4a8cf176ba 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb @@ -6,6 +6,8 @@ module RubyEventStore module CLI module Commands + class OtherEvent < RubyEventStore::Event; end + RSpec.describe StreamEvents do let(:event_store) { RubyEventStore::Client.new } let(:command) { StreamEvents.new } @@ -38,6 +40,40 @@ module Commands expect { command.call(stream_name: "empty-stream", limit: 50, format: "table") } .to output(/no events/).to_stdout end + + it "filters by event type" do + event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") + event_store.publish(OtherEvent.new, stream_name: "test-stream") + + expect { command.call(stream_name: "test-stream", limit: 50, format: "table", type: "RubyEventStore::CLI::Commands::OtherEvent") } + .to output(/1 event\(s\)/).to_stdout + end + + it "filters by --after timestamp excluding past events" do + event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") + future = (Time.now + 3600).iso8601(3) + + expect { command.call(stream_name: "test-stream", limit: 50, format: "table", after: future) } + .to output(/no events/).to_stdout + end + + it "filters by --before timestamp excluding future events" do + event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") + past = (Time.now - 3600).iso8601(3) + + expect { command.call(stream_name: "test-stream", limit: 50, format: "table", before: past) } + .to output(/no events/).to_stdout + end + + it "starts from given event id" do + e1 = RubyEventStore::Event.new + e2 = RubyEventStore::Event.new + e3 = RubyEventStore::Event.new + event_store.publish([e1, e2, e3], stream_name: "test-stream") + + expect { command.call(stream_name: "test-stream", limit: 50, format: "table", from: e1.event_id) } + .to output(/2 event\(s\)/).to_stdout + end end end end From d431d16907150e1be1ac73a902a5645968e868b8 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 19:39:06 +0200 Subject: [PATCH 06/39] Show friendly error message for unknown event type Replace raw NameError with "Unknown event type: X" when --type argument doesn't match a known constant. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands/stream_events.rb | 8 +++++++- .../spec/commands/stream_events_spec.rb | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index 14a3d3309b..42d8cb9463 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -20,7 +20,7 @@ class StreamEvents < Dry::CLI::Command def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from: nil, **) event_store = EventStoreResolver.resolve reader = event_store.read.stream(stream_name) - reader = reader.of_type(Object.const_get(type)) if type + reader = reader.of_type(resolve_type(type)) if type reader = reader.newer_than(Time.parse(after)) if after reader = reader.older_than(Time.parse(before)) if before reader = reader.from(from) if from @@ -33,6 +33,12 @@ def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from private + def resolve_type(name) + Object.const_get(name) + rescue NameError + raise "Unknown event type: #{name}" + end + def render(events, format:) case format when "json" then render_json(events) diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb index 4a8cf176ba..ac5037c412 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb @@ -65,6 +65,15 @@ class OtherEvent < RubyEventStore::Event; end .to output(/no events/).to_stdout end + it "shows friendly error for unknown event type" do + expect { + begin + command.call(stream_name: "test-stream", limit: 50, format: "table", type: "NonExistent::Event") + rescue SystemExit + end + }.to output(/Unknown event type: NonExistent::Event/).to_stderr + end + it "starts from given event id" do e1 = RubyEventStore::Event.new e2 = RubyEventStore::Event.new From 53991436c5210e86e70edde673be8d610c695a99 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 19:40:26 +0200 Subject: [PATCH 07/39] Add --follow / -f flag to res stream events Polls for new events every second after initial read. Exits cleanly on Ctrl+C (exit 0). Co-Authored-By: Claude Sonnet 4.6 --- .../cli/commands/stream_events.rb | 19 +++++++- .../spec/commands/stream_events_spec.rb | 44 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index 42d8cb9463..c4427c4400 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -16,8 +16,9 @@ class StreamEvents < Dry::CLI::Command option :after, desc: "Filter events newer than timestamp (ISO8601)" option :before, desc: "Filter events older than timestamp (ISO8601)" option :from, desc: "Start reading from event ID (exclusive)" + option :follow, type: :boolean, default: false, aliases: ["-f"], desc: "Watch for new events (Ctrl+C to stop)" - def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from: nil, **) + def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from: nil, follow: false, **) event_store = EventStoreResolver.resolve reader = event_store.read.stream(stream_name) reader = reader.of_type(resolve_type(type)) if type @@ -26,6 +27,22 @@ def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from reader = reader.from(from) if from events = reader.limit(limit.to_i).to_a render(events, format: format) + + if follow + last_id = events.last&.event_id + loop do + sleep 1 + new_reader = event_store.read.stream(stream_name) + new_reader = new_reader.of_type(resolve_type(type)) if type + new_reader = new_reader.from(last_id) if last_id + new_events = new_reader.to_a + next if new_events.empty? + render(new_events, format: format) + last_id = new_events.last.event_id + end + end + rescue Interrupt + exit 0 rescue => e warn e.message exit 1 diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb index ac5037c412..0df4fa491d 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb @@ -83,6 +83,50 @@ class OtherEvent < RubyEventStore::Event; end expect { command.call(stream_name: "test-stream", limit: 50, format: "table", from: e1.event_id) } .to output(/2 event\(s\)/).to_stdout end + context "--follow" do + it "prints existing events before watching" do + event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") + allow(command).to receive(:sleep) { raise StopIteration } + + expect { + begin + command.call(stream_name: "test-stream", limit: 50, format: "table", follow: true) + rescue SystemExit + end + }.to output(/RubyEventStore::Event/).to_stdout + end + + it "prints new events published after initial read" do + e1 = RubyEventStore::Event.new + e2 = RubyEventStore::Event.new + event_store.publish(e1, stream_name: "test-stream") + call_count = 0 + allow(command).to receive(:sleep) do + call_count += 1 + event_store.publish(e2, stream_name: "test-stream") if call_count == 1 + raise StopIteration if call_count >= 2 + end + + output = StringIO.new + $stdout = output + begin + command.call(stream_name: "test-stream", limit: 50, format: "table", follow: true) + rescue SystemExit + end + $stdout = STDOUT + + expect(output.string).to include(e1.event_id) + expect(output.string).to include(e2.event_id) + end + + it "exits cleanly on Interrupt" do + allow(command).to receive(:sleep) { raise Interrupt } + + expect { + command.call(stream_name: "test-stream", limit: 50, format: "table", follow: true) + }.to raise_error(SystemExit) { |e| expect(e.status).to eq(0) } + end + end end end end From 6f279dd1793ead1472725415a32179da73ca1d70 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 21:02:02 +0200 Subject: [PATCH 08/39] Implement res stream show command Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands.rb | 2 + .../cli/commands/stream_show.rb | 40 +++++++++++ .../spec/commands/stream_show_spec.rb | 71 +++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb create mode 100644 contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb index 3e64584201..1aa05cecd8 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -2,6 +2,7 @@ require "dry/cli" require_relative "commands/stream_events" +require_relative "commands/stream_show" module RubyEventStore module CLI @@ -9,6 +10,7 @@ module Commands extend Dry::CLI::Registry register "stream events", StreamEvents + register "stream show", StreamShow end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb new file mode 100644 index 0000000000..4f3dff70bb --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "dry/cli" +require_relative "../event_store_resolver" + +module RubyEventStore + module CLI + module Commands + class StreamShow < Dry::CLI::Command + desc "Show stream details" + + argument :stream_name, required: true, desc: "Stream name" + + def call(stream_name:, **) + event_store = EventStoreResolver.resolve + reader = event_store.read.stream(stream_name) + count = reader.count + + if count.zero? + puts "Stream: #{stream_name}" + puts "Events: 0" + return + end + + first = reader.first + last = reader.last + + puts "Stream: #{stream_name}" + puts "Events: #{count}" + puts "Version: #{count - 1}" + puts "First: #{first.timestamp.iso8601(3)} (#{first.event_type})" + puts "Last: #{last.timestamp.iso8601(3)} (#{last.event_type})" + rescue => e + warn e.message + exit 1 + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb new file mode 100644 index 0000000000..b9210abc2f --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ruby_event_store/cli/commands/stream_show" + +module RubyEventStore + module CLI + module Commands + class FirstType < RubyEventStore::Event; end + class LastType < RubyEventStore::Event; end + + RSpec.describe StreamShow do + let(:event_store) { RubyEventStore::Client.new } + let(:command) { StreamShow.new } + + before { EventStoreResolver.event_store = event_store } + + describe "#call" do + it "shows stream details" do + event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") + event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") + + expect { command.call(stream_name: "test-stream") } + .to output(/Events:\s+2/).to_stdout + end + + it "shows version as count minus one" do + event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") + event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") + + expect { command.call(stream_name: "test-stream") } + .to output(/Version:\s+1/).to_stdout + end + + it "shows first and last event type" do + event_store.publish(FirstType.new, stream_name: "test-stream") + event_store.publish(LastType.new, stream_name: "test-stream") + + output = capture_stdout { command.call(stream_name: "test-stream") } + expect(output).to match(/First:.*FirstType/) + expect(output).to match(/Last:.*LastType/) + end + + it "shows zero events for empty stream" do + expect { command.call(stream_name: "empty-stream") } + .to output(/Events:\s+0/).to_stdout + end + + it "shows stream name" do + expect { command.call(stream_name: "my-stream") } + .to output(/Stream:\s+my-stream/).to_stdout + end + + it "does not show version for empty stream" do + expect { command.call(stream_name: "empty-stream") } + .not_to output(/Version/).to_stdout + end + end + + def capture_stdout + out = StringIO.new + $stdout = out + yield + out.string + ensure + $stdout = STDOUT + end + end + end + end +end From bcb2524eeb5c59125c48aa2630aa21ba9de67187 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 21:02:13 +0200 Subject: [PATCH 09/39] Implement res event show command Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands.rb | 2 + .../cli/commands/event_show.rb | 35 +++++++++++ .../spec/commands/event_show_spec.rb | 60 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb create mode 100644 contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb index 1aa05cecd8..a7b2559379 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -3,6 +3,7 @@ require "dry/cli" require_relative "commands/stream_events" require_relative "commands/stream_show" +require_relative "commands/event_show" module RubyEventStore module CLI @@ -11,6 +12,7 @@ module Commands register "stream events", StreamEvents register "stream show", StreamShow + register "event show", EventShow end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb new file mode 100644 index 0000000000..496ec72ab8 --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "dry/cli" +require "json" +require_relative "../event_store_resolver" + +module RubyEventStore + module CLI + module Commands + class EventShow < Dry::CLI::Command + desc "Show event details" + + argument :event_id, required: true, desc: "Event ID (UUID)" + + def call(event_id:, **) + event_store = EventStoreResolver.resolve + event = event_store.read.event!(event_id) + + puts "Event ID: #{event.event_id}" + puts "Type: #{event.event_type}" + puts "Timestamp: #{event.timestamp.iso8601(3)}" + puts "Valid at: #{event.valid_at.iso8601(3)}" + puts "Data: #{JSON.pretty_generate(event.data)}" + puts "Metadata: #{JSON.pretty_generate(event.metadata.to_h)}" + rescue RubyEventStore::EventNotFound + warn "Event not found: #{event_id}" + exit 1 + rescue => e + warn e.message + exit 1 + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb b/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb new file mode 100644 index 0000000000..1878fa7576 --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ruby_event_store/cli/commands/event_show" + +module RubyEventStore + module CLI + module Commands + RSpec.describe EventShow do + let(:event_store) { RubyEventStore::Client.new } + let(:command) { EventShow.new } + + before { EventStoreResolver.event_store = event_store } + + describe "#call" do + it "shows event details" do + event = RubyEventStore::Event.new + event_store.publish(event, stream_name: "test-stream") + + expect { command.call(event_id: event.event_id) } + .to output(/Event ID:.*#{event.event_id}/).to_stdout + end + + it "shows event type" do + event = RubyEventStore::Event.new + event_store.publish(event, stream_name: "test-stream") + + expect { command.call(event_id: event.event_id) } + .to output(/Type:.*RubyEventStore::Event/).to_stdout + end + + it "shows event data as JSON" do + event = RubyEventStore::Event.new(data: { order_id: "123" }) + event_store.publish(event, stream_name: "test-stream") + + expect { command.call(event_id: event.event_id) } + .to output(/order_id.*123/).to_stdout + end + + it "shows event metadata as JSON" do + event = RubyEventStore::Event.new(metadata: { remote_ip: "1.2.3.4" }) + event_store.publish(event, stream_name: "test-stream") + + expect { command.call(event_id: event.event_id) } + .to output(/remote_ip.*1\.2\.3\.4/).to_stdout + end + + it "exits with error for unknown event id" do + expect { + begin + command.call(event_id: "00000000-0000-0000-0000-000000000000") + rescue SystemExit + end + }.to output(/Event not found/).to_stderr + end + end + end + end + end +end From e06f6931dc40c5a48221cd6b5e52e34717457552 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 21:02:22 +0200 Subject: [PATCH 10/39] Implement res event streams command Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands.rb | 2 + .../cli/commands/event_streams.rb | 31 +++++++++++ .../spec/commands/event_streams_spec.rb | 52 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb create mode 100644 contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb index a7b2559379..92db97b000 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -4,6 +4,7 @@ require_relative "commands/stream_events" require_relative "commands/stream_show" require_relative "commands/event_show" +require_relative "commands/event_streams" module RubyEventStore module CLI @@ -13,6 +14,7 @@ module Commands register "stream events", StreamEvents register "stream show", StreamShow register "event show", EventShow + register "event streams", EventStreams end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb new file mode 100644 index 0000000000..f23a762d2e --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "dry/cli" +require_relative "../event_store_resolver" + +module RubyEventStore + module CLI + module Commands + class EventStreams < Dry::CLI::Command + desc "List streams containing an event" + + argument :event_id, required: true, desc: "Event ID (UUID)" + + def call(event_id:, **) + event_store = EventStoreResolver.resolve + streams = event_store.streams_of(event_id) + + if streams.empty? + puts "(no streams — event not found or not linked to any stream)" + return + end + + streams.each { |stream| puts stream.name } + rescue => e + warn e.message + exit 1 + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb b/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb new file mode 100644 index 0000000000..a28d04e4e3 --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ruby_event_store/cli/commands/event_streams" + +module RubyEventStore + module CLI + module Commands + RSpec.describe EventStreams do + let(:event_store) { RubyEventStore::Client.new } + let(:command) { EventStreams.new } + + before { EventStoreResolver.event_store = event_store } + + describe "#call" do + it "lists streams containing the event" do + event = RubyEventStore::Event.new + event_store.publish(event, stream_name: "orders") + event_store.link(event.event_id, stream_name: "reporting") + + expect { command.call(event_id: event.event_id) } + .to output(/orders/).to_stdout + end + + it "lists all streams the event was linked to" do + event = RubyEventStore::Event.new + event_store.publish(event, stream_name: "orders") + event_store.link(event.event_id, stream_name: "reporting") + + output = capture_stdout { command.call(event_id: event.event_id) } + expect(output).to include("orders") + expect(output).to include("reporting") + end + + it "prints message when event is not found" do + expect { command.call(event_id: "00000000-0000-0000-0000-000000000000") } + .to output(/no streams/).to_stdout + end + end + + def capture_stdout + out = StringIO.new + $stdout = out + yield + out.string + ensure + $stdout = STDOUT + end + end + end + end +end From bf1a93be88c9743afb0b1da4cdb0aeb028126e10 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 21:05:02 +0200 Subject: [PATCH 11/39] Implement res trace command Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands.rb | 2 + .../ruby_event_store/cli/commands/trace.rb | 48 ++++++++++++ .../spec/commands/trace_spec.rb | 76 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb create mode 100644 contrib/ruby_event_store-cli/spec/commands/trace_spec.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb index 92db97b000..1f7782a1c7 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -5,6 +5,7 @@ require_relative "commands/stream_show" require_relative "commands/event_show" require_relative "commands/event_streams" +require_relative "commands/trace" module RubyEventStore module CLI @@ -15,6 +16,7 @@ module Commands register "stream show", StreamShow register "event show", EventShow register "event streams", EventStreams + register "trace", Trace end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb new file mode 100644 index 0000000000..8148f0e6a5 --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "dry/cli" +require_relative "../event_store_resolver" + +module RubyEventStore + module CLI + module Commands + class Trace < Dry::CLI::Command + desc "Show causal tree for a correlation ID" + + argument :correlation_id, required: true, desc: "Correlation ID (UUID)" + + def call(correlation_id:, **) + event_store = EventStoreResolver.resolve + stream_name = "$by_correlation_id_#{correlation_id}" + events = event_store.read.stream(stream_name).to_a + + if events.empty? + puts "(no events found for correlation ID #{correlation_id})" + return + end + + by_causation = events.group_by { |e| e.metadata[:causation_id] } + event_ids = events.map(&:event_id).to_set + roots = events.select { |e| !event_ids.include?(e.metadata[:causation_id]) } + + roots.each { |e| print_tree(e, by_causation, "", true, roots.last == e) } + rescue => e + warn e.message + exit 1 + end + + private + + def print_tree(event, by_causation, prefix, root, last) + connector = root ? "" : (last ? "└── " : "├── ") + puts "#{prefix}#{connector}#{event.event_type} [#{event.event_id}]" + children = by_causation[event.event_id] || [] + child_prefix = root ? prefix : prefix + (last ? " " : "│ ") + children.each_with_index do |child, i| + print_tree(child, by_causation, child_prefix, false, i == children.size - 1) + end + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb b/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb new file mode 100644 index 0000000000..646530b0fb --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ruby_event_store/cli/commands/trace" + +module RubyEventStore + module CLI + module Commands + RSpec.describe Trace do + let(:event_store) { RubyEventStore::Client.new } + let(:command) { Trace.new } + let(:correlation_id) { SecureRandom.uuid } + + before { EventStoreResolver.event_store = event_store } + + def publish_correlated(event, causation_id: nil) + meta = { correlation_id: correlation_id } + meta[:causation_id] = causation_id if causation_id + event_store.with_metadata(meta) { event_store.publish(event, stream_name: "test") } + event_store.link(event.event_id, stream_name: "$by_correlation_id_#{correlation_id}") + event + end + + describe "#call" do + it "prints message when no events found" do + expect { command.call(correlation_id: SecureRandom.uuid) } + .to output(/no events found/).to_stdout + end + + it "shows root event" do + e1 = publish_correlated(RubyEventStore::Event.new) + + expect { command.call(correlation_id: correlation_id) } + .to output(/#{e1.event_id}/).to_stdout + end + + it "shows event types" do + publish_correlated(RubyEventStore::Event.new) + + expect { command.call(correlation_id: correlation_id) } + .to output(/RubyEventStore::Event/).to_stdout + end + + it "shows child event indented under parent" do + e1 = publish_correlated(RubyEventStore::Event.new) + e2 = publish_correlated(RubyEventStore::Event.new, causation_id: e1.event_id) + + output = capture_stdout { command.call(correlation_id: correlation_id) } + e1_line = output.lines.index { |l| l.include?(e1.event_id) } + e2_line = output.lines.index { |l| l.include?(e2.event_id) } + expect(e1_line).to be < e2_line + expect(output.lines[e2_line]).to include("└──") + end + + it "shows multiple root events when no causal relation" do + e1 = publish_correlated(RubyEventStore::Event.new) + e2 = publish_correlated(RubyEventStore::Event.new) + + output = capture_stdout { command.call(correlation_id: correlation_id) } + expect(output).to include(e1.event_id) + expect(output).to include(e2.event_id) + end + end + + def capture_stdout + out = StringIO.new + $stdout = out + yield + out.string + ensure + $stdout = STDOUT + end + end + end + end +end From a60ff22325d80f4aa7a7e5ed3355928d62324e5b Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 21:09:37 +0200 Subject: [PATCH 12/39] Implement res search command with shared EventRenderer Extract render logic from stream_events into EventRenderer module, reuse in search. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands.rb | 2 + .../ruby_event_store/cli/commands/search.rb | 45 +++++++++++ .../cli/commands/stream_events.rb | 27 +------ .../ruby_event_store/cli/event_renderer.rb | 34 +++++++++ .../spec/commands/search_spec.rb | 74 +++++++++++++++++++ 5 files changed, 157 insertions(+), 25 deletions(-) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_renderer.rb create mode 100644 contrib/ruby_event_store-cli/spec/commands/search_spec.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb index 1f7782a1c7..bd3a627611 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -6,6 +6,7 @@ require_relative "commands/event_show" require_relative "commands/event_streams" require_relative "commands/trace" +require_relative "commands/search" module RubyEventStore module CLI @@ -17,6 +18,7 @@ module Commands register "event show", EventShow register "event streams", EventStreams register "trace", Trace + register "search", Search end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb new file mode 100644 index 0000000000..d072aa8c2f --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "dry/cli" +require_relative "../event_store_resolver" +require_relative "../event_renderer" + +module RubyEventStore + module CLI + module Commands + class Search < Dry::CLI::Command + include EventRenderer + + desc "Search events by type, time, or stream" + + option :type, desc: "Filter by event type (class name)" + option :after, desc: "Filter events newer than timestamp (ISO8601)" + option :before, desc: "Filter events older than timestamp (ISO8601)" + option :stream, desc: "Limit search to a specific stream" + option :limit, type: :integer, default: 50, desc: "Max number of events (default: 50)" + option :format, default: "table", values: %w[table json], desc: "Output format" + + def call(limit:, format:, type: nil, after: nil, before: nil, stream: nil, **) + event_store = EventStoreResolver.resolve + reader = stream ? event_store.read.stream(stream) : event_store.read + reader = reader.of_type(resolve_type(type)) if type + reader = reader.newer_than(Time.parse(after)) if after + reader = reader.older_than(Time.parse(before)) if before + events = reader.limit(limit.to_i).to_a + render(events, format: format) + rescue => e + warn e.message + exit 1 + end + + private + + def resolve_type(name) + Object.const_get(name) + rescue NameError + raise "Unknown event type: #{name}" + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index c4427c4400..e25791dba0 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -2,11 +2,13 @@ require "dry/cli" require_relative "../event_store_resolver" +require_relative "../event_renderer" module RubyEventStore module CLI module Commands class StreamEvents < Dry::CLI::Command + include EventRenderer desc "List events in a stream" argument :stream_name, required: true, desc: "Stream name" @@ -56,31 +58,6 @@ def resolve_type(name) raise "Unknown event type: #{name}" end - def render(events, format:) - case format - when "json" then render_json(events) - when "table" then render_table(events) - end - end - - def render_json(events) - require "json" - puts JSON.pretty_generate(events.map { |e| - { event_id: e.event_id, event_type: e.event_type, data: e.data, - metadata: e.metadata.to_h, timestamp: e.timestamp.iso8601(3) } - }) - end - - def render_table(events) - return puts "(no events)" if events.empty? - - puts "%-36s %-40s %s" % ["EVENT ID", "TYPE", "TIMESTAMP"] - puts "-" * 90 - events.each do |e| - puts "%-36s %-40s %s" % [e.event_id, e.event_type, e.timestamp.iso8601(3)] - end - puts "\n#{events.size} event(s)" - end end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_renderer.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_renderer.rb new file mode 100644 index 0000000000..c70e1d45a6 --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_renderer.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "json" + +module RubyEventStore + module CLI + module EventRenderer + def render(events, format:) + case format + when "json" then render_json(events) + when "table" then render_table(events) + end + end + + def render_json(events) + puts JSON.pretty_generate(events.map { |e| + { event_id: e.event_id, event_type: e.event_type, data: e.data, + metadata: e.metadata.to_h, timestamp: e.timestamp.iso8601(3) } + }) + end + + def render_table(events) + return puts "(no events)" if events.empty? + + puts "%-36s %-40s %s" % ["EVENT ID", "TYPE", "TIMESTAMP"] + puts "-" * 90 + events.each do |e| + puts "%-36s %-40s %s" % [e.event_id, e.event_type, e.timestamp.iso8601(3)] + end + puts "\n#{events.size} event(s)" + end + end + end +end diff --git a/contrib/ruby_event_store-cli/spec/commands/search_spec.rb b/contrib/ruby_event_store-cli/spec/commands/search_spec.rb new file mode 100644 index 0000000000..ece6dad5da --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/commands/search_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ruby_event_store/cli/commands/search" + +module RubyEventStore + module CLI + module Commands + class SearchTestEvent < RubyEventStore::Event; end + + RSpec.describe Search do + let(:event_store) { RubyEventStore::Client.new } + let(:command) { Search.new } + + before { EventStoreResolver.event_store = event_store } + + describe "#call" do + it "searches all events when no filters given" do + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + + expect { command.call(limit: 50, format: "table") } + .to output(/1 event\(s\)/).to_stdout + end + + it "filters by event type" do + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + event_store.publish(SearchTestEvent.new, stream_name: "orders") + + expect { command.call(limit: 50, format: "table", type: "RubyEventStore::CLI::Commands::SearchTestEvent") } + .to output(/1 event\(s\)/).to_stdout + end + + it "filters by stream" do + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + event_store.publish(RubyEventStore::Event.new, stream_name: "payments") + + expect { command.call(limit: 50, format: "table", stream: "orders") } + .to output(/1 event\(s\)/).to_stdout + end + + it "filters by --after timestamp" do + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + future = (Time.now + 3600).iso8601(3) + + expect { command.call(limit: 50, format: "table", after: future) } + .to output(/no events/).to_stdout + end + + it "filters by --before timestamp" do + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + past = (Time.now - 3600).iso8601(3) + + expect { command.call(limit: 50, format: "table", before: past) } + .to output(/no events/).to_stdout + end + + it "outputs json when format is json" do + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + + expect { command.call(limit: 50, format: "json") } + .to output(/event_id/).to_stdout + end + + it "respects limit" do + 3.times { event_store.publish(RubyEventStore::Event.new, stream_name: "orders") } + + expect { command.call(limit: 2, format: "table") } + .to output(/2 event\(s\)/).to_stdout + end + end + end + end + end +end From 75578a92a37f1aa45fef1227691c4fdd22b3d896 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 21:12:28 +0200 Subject: [PATCH 13/39] Implement res stats command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses event_store.read.count — no AR internals. Supports optional --stream flag. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands.rb | 2 + .../ruby_event_store/cli/commands/stats.rb | 32 ++++++++++++ .../spec/commands/stats_spec.rb | 51 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb create mode 100644 contrib/ruby_event_store-cli/spec/commands/stats_spec.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb index bd3a627611..28063babee 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -7,6 +7,7 @@ require_relative "commands/event_streams" require_relative "commands/trace" require_relative "commands/search" +require_relative "commands/stats" module RubyEventStore module CLI @@ -19,6 +20,7 @@ module Commands register "event streams", EventStreams register "trace", Trace register "search", Search + register "stats", Stats end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb new file mode 100644 index 0000000000..4ee1ff7a0c --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "dry/cli" +require_relative "../event_store_resolver" + +module RubyEventStore + module CLI + module Commands + class Stats < Dry::CLI::Command + desc "Show event store statistics" + + option :stream, desc: "Show stats for a specific stream" + + def call(stream: nil, **) + event_store = EventStoreResolver.resolve + + if stream + count = event_store.read.stream(stream).count + puts "Stream: #{stream}" + puts "Events: #{count}" + else + count = event_store.read.count + puts "Events: #{count}" + end + rescue => e + warn e.message + exit 1 + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb new file mode 100644 index 0000000000..554687bc03 --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ruby_event_store/cli/commands/stats" + +module RubyEventStore + module CLI + module Commands + RSpec.describe Stats do + let(:event_store) { RubyEventStore::Client.new } + let(:command) { Stats.new } + + before { EventStoreResolver.event_store = event_store } + + describe "#call" do + it "shows total event count" do + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + event_store.publish(RubyEventStore::Event.new, stream_name: "payments") + + expect { command.call } + .to output(/Events:\s+2/).to_stdout + end + + it "shows zero when no events" do + expect { command.call } + .to output(/Events:\s+0/).to_stdout + end + + it "shows count for a specific stream" do + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + event_store.publish(RubyEventStore::Event.new, stream_name: "payments") + + expect { command.call(stream: "orders") } + .to output(/Events:\s+2/).to_stdout + end + + it "shows stream name when --stream given" do + expect { command.call(stream: "orders") } + .to output(/Stream:\s+orders/).to_stdout + end + + it "does not show stream name for global stats" do + expect { command.call } + .not_to output(/Stream/).to_stdout + end + end + end + end + end +end From f5a0d58bd7cf7de8e89cb330225fed558ad2fa8b Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 21:24:01 +0200 Subject: [PATCH 14/39] Show unique event types in res stats Co-Authored-By: Claude Sonnet 4.6 --- .../ruby_event_store/cli/commands/stats.rb | 15 +++++----- .../spec/commands/stats_spec.rb | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb index 4ee1ff7a0c..3beff79bdf 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb @@ -13,14 +13,15 @@ class Stats < Dry::CLI::Command def call(stream: nil, **) event_store = EventStoreResolver.resolve + reader = stream ? event_store.read.stream(stream) : event_store.read - if stream - count = event_store.read.stream(stream).count - puts "Stream: #{stream}" - puts "Events: #{count}" - else - count = event_store.read.count - puts "Events: #{count}" + puts "Stream: #{stream}" if stream + puts "Events: #{reader.count}" + + types = reader.map(&:event_type).uniq.sort + unless types.empty? + puts "\nEvent types:" + types.each { |t| puts " #{t}" } end rescue => e warn e.message diff --git a/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb index 554687bc03..cd61982f19 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb @@ -44,8 +44,38 @@ module Commands expect { command.call } .not_to output(/Stream/).to_stdout end + + it "lists unique event types" do + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + + expect { command.call } + .to output(/RubyEventStore::Event/).to_stdout + end + + it "shows each type only once" do + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + event_store.publish(RubyEventStore::Event.new, stream_name: "orders") + + output = capture_stdout { command.call } + expect(output.scan("RubyEventStore::Event").size).to eq(1) + end + + it "shows no event types section when store is empty" do + expect { command.call } + .not_to output(/Event types/).to_stdout + end end end end end end + +def capture_stdout + out = StringIO.new + $stdout = out + yield + out.string +ensure + $stdout = STDOUT +end From 422ff749ada6498b42543697045be56062221cc6 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 22:57:05 +0200 Subject: [PATCH 15/39] =?UTF-8?q?Implement=20res=20watch=20command=20?= =?UTF-8?q?=E2=80=94=20live=20event=20view=20grouped=20by=20namespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands.rb | 2 + .../ruby_event_store/cli/commands/follow.rb | 102 ++++++++++++++ .../ruby_event_store/cli/commands/watch.rb | 100 ++++++++++++++ .../spec/commands/follow_spec.rb | 119 ++++++++++++++++ .../spec/commands/watch_spec.rb | 127 ++++++++++++++++++ 5 files changed, 450 insertions(+) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb create mode 100644 contrib/ruby_event_store-cli/spec/commands/follow_spec.rb create mode 100644 contrib/ruby_event_store-cli/spec/commands/watch_spec.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb index 28063babee..078866c7f9 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -8,6 +8,7 @@ require_relative "commands/trace" require_relative "commands/search" require_relative "commands/stats" +require_relative "commands/watch" module RubyEventStore module CLI @@ -21,6 +22,7 @@ module Commands register "trace", Trace register "search", Search register "stats", Stats + register "watch", Watch end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb new file mode 100644 index 0000000000..cdabf118b6 --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "dry/cli" +require_relative "../event_store_resolver" + +module RubyEventStore + module CLI + module Commands + class Follow < Dry::CLI::Command + desc "Watch new events live, grouped by bounded context" + + option :namespace, desc: "Filter by namespace(s), comma-separated (e.g. Ordering,Payments)" + option :since, desc: "Watch events since timestamp (ISO8601, default: now)" + option :limit, type: :integer, default: 5, desc: "Max events shown per namespace (default: 5)" + option :interval, type: :integer, default: 1, desc: "Refresh interval in seconds (default: 1)" + option :follow, type: :boolean, default: true, desc: "Watch for new events (default: true, use --no-follow for one-shot)" + + def call(namespace: nil, since: nil, limit:, interval:, follow:, **) + event_store = EventStoreResolver.resolve + started_at = since ? Time.parse(since) : Time.now + namespaces = namespace&.split(",")&.map(&:strip) + + if follow + hide_cursor + loop do + render(event_store, limit: limit.to_i, since: started_at, namespaces: namespaces, follow: true) + sleep interval.to_i + end + else + render(event_store, limit: limit.to_i, since: started_at, namespaces: namespaces, follow: false) + end + rescue Interrupt + show_cursor + exit 0 + rescue => e + show_cursor + warn e.message + exit 1 + end + + private + + def render(event_store, limit:, since:, namespaces:, follow:) + events = event_store.read.newer_than(since).map do |e| + { event_id: e.event_id, type: e.event_type, timestamp: e.timestamp } + end + events = events.select { |e| namespaces.include?(namespace(e[:type])) } if namespaces + grouped = events.group_by { |e| namespace(e[:type]) }.sort + + lines = [] + if grouped.empty? + lines << dim("No events yet — waiting since #{since.strftime("%H:%M:%S")}") + else + grouped.each do |ns, ns_events| + lines << bold("#{ns} (#{ns_events.size} events)") + ns_events.last(limit).each do |e| + lines << " #{pad(short_type(e[:type]), 30)} #{e[:timestamp].strftime("%H:%M:%S")} #{e[:event_id]}" + end + lines << "" + end + end + lines << dim("Watching since #{since.strftime("%H:%M:%S")} — Press Ctrl+C to exit") if follow + + clear_screen + puts lines.join("\n") + end + + def namespace(type) + type.include?("::") ? type.split("::").first : "Other" + end + + def short_type(type) + type.split("::").last + end + + def pad(str, width) + str.ljust(width)[0, width] + end + + def clear_screen + system("clear") + end + + def hide_cursor + print "\e[?25l" + end + + def show_cursor + print "\e[?25h" + end + + def bold(str) + "\e[1m#{str}\e[0m" + end + + def dim(str) + "\e[2m#{str}\e[0m" + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb new file mode 100644 index 0000000000..cddc9abc00 --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "dry/cli" +require_relative "../event_store_resolver" + +module RubyEventStore + module CLI + module Commands + class Watch < Dry::CLI::Command + desc "Watch new events live, grouped by bounded context" + + option :namespace, desc: "Filter by namespace(s), comma-separated (e.g. Ordering,Payments)" + option :since, desc: "Watch events since timestamp (ISO8601, default: now)" + option :limit, type: :integer, default: 50, desc: "Max events shown per namespace (default: 50)" + option :interval, type: :integer, default: 1, desc: "Refresh interval in seconds (default: 1)" + + def call(namespace: nil, since: nil, limit:, interval:, **) + event_store = EventStoreResolver.resolve + started_at = since ? Time.parse(since) : Time.now + namespaces = namespace&.split(",")&.map(&:strip) + + hide_cursor + loop do + grouped = prepare(event_store, since: started_at, namespaces: namespaces) + render(grouped, limit: limit.to_i, since: started_at) + sleep interval.to_i + end + rescue Interrupt + show_cursor + exit 0 + rescue => e + show_cursor + warn e.message + exit 1 + end + + private + + def prepare(event_store, since:, namespaces:) + events = event_store.read.newer_than(since).map do |e| + { event_id: e.event_id, type: e.event_type, timestamp: e.timestamp } + end + events = events.select { |e| namespaces.include?(namespace(e[:type])) } if namespaces + events.group_by { |e| namespace(e[:type]) }.sort + end + + def render(grouped, limit:, since:) + lines = [] + if grouped.empty? + lines << dim("No events yet — waiting since #{since.strftime("%H:%M:%S")}") + else + grouped.each do |ns, ns_events| + lines << bold("#{ns} (#{ns_events.size} events)") + ns_events.last(limit).each do |e| + lines << " #{pad(short_type(e[:type]), 30)} #{e[:timestamp].strftime("%H:%M:%S")} #{e[:event_id]}" + end + lines << "" + end + end + lines << dim("Watching since #{since.strftime("%H:%M:%S")} — Press Ctrl+C to exit") + + clear_screen + puts lines.join("\n") + end + + def namespace(type) + type.include?("::") ? type.split("::").first : "Other" + end + + def short_type(type) + type.split("::").last + end + + def pad(str, width) + str.ljust(width)[0, width] + end + + def clear_screen + system("clear") + end + + def hide_cursor + print "\e[?25l" + end + + def show_cursor + print "\e[?25h" + end + + def bold(str) + "\e[1m#{str}\e[0m" + end + + def dim(str) + "\e[2m#{str}\e[0m" + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb b/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb new file mode 100644 index 0000000000..180bee9a23 --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ruby_event_store/cli/commands/follow" + +module RubyEventStore + module CLI + module Commands + class FollowOrdering < RubyEventStore::Event; end + class FollowOrderConfirmed < RubyEventStore::Event; end + class FollowPayment < RubyEventStore::Event; end + class FollowInventory < RubyEventStore::Event; end + + RSpec.describe Follow do + let(:event_store) { RubyEventStore::Client.new } + let(:command) { Follow.new } + let(:past) { (Time.now - 3600).iso8601(3) } + + before { EventStoreResolver.event_store = event_store } + + def call_once(**opts) + begin + command.call(limit: 5, interval: 1, follow: false, since: past, **opts) + rescue SystemExit + end + end + + describe "#call with --no-follow" do + it "shows nothing when no events" do + expect { call_once }.to output(/No events yet/).to_stdout + end + + it "shows events grouped by namespace" do + event_store.publish(FollowOrdering.new, stream_name: "test") + + expect { call_once }.to output(/FollowOrdering/).to_stdout + end + + it "groups events under namespace header" do + event_store.publish(FollowOrdering.new, stream_name: "test") + event_store.publish(FollowOrderConfirmed.new, stream_name: "test") + + output = capture_stdout { call_once } + expect(output.lines.count { |l| l.include?("RubyEventStore") && l.include?("events") }).to eq(1) + end + + it "filters by namespace" do + event_store.publish(FollowOrdering.new, stream_name: "test") + event_store.publish(FollowPayment.new, stream_name: "test") + + output = capture_stdout { call_once(namespace: "RubyEventStore") } + expect(output).to include("RubyEventStore") + end + + it "excludes events outside requested namespace" do + event_store.publish(FollowOrdering.new, stream_name: "test") + event_store.publish(FollowPayment.new, stream_name: "test") + + output = capture_stdout { call_once(namespace: "Other") } + expect(output).to include("No events yet") + end + + it "shows last N events per namespace when limit exceeded" do + 10.times { event_store.publish(FollowOrdering.new, stream_name: "test") } + + output = capture_stdout { call_once(limit: 3) } + event_lines = output.lines.select { |l| l.start_with?(" ") } + expect(event_lines.size).to eq(3) + end + + it "places events without namespace under Other" do + expect(command.send(:namespace, "OrderPlaced")).to eq("Other") + end + + it "does not show follow footer" do + expect { call_once }.not_to output(/Press Ctrl\+C/).to_stdout + end + + it "does not show events older than --since" do + newer_since = (Time.now + 3600).iso8601(3) + event_store.publish(FollowOrdering.new, stream_name: "test") + + expect { call_once(since: newer_since) }.to output(/No events yet/).to_stdout + end + end + + describe "#call with --follow" do + it "shows follow footer" do + allow(command).to receive(:sleep) { raise StopIteration } + + expect { + begin + command.call(limit: 5, interval: 1, follow: true, since: past) + rescue SystemExit + end + }.to output(/Press Ctrl\+C/).to_stdout + end + + it "exits cleanly on Interrupt" do + allow(command).to receive(:sleep) { raise Interrupt } + + expect { + command.call(limit: 5, interval: 1, follow: true, since: past) + }.to raise_error(SystemExit) { |e| expect(e.status).to eq(0) } + end + end + + def capture_stdout + out = StringIO.new + $stdout = out + yield + out.string + ensure + $stdout = STDOUT + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb new file mode 100644 index 0000000000..d327a82f1d --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ruby_event_store/cli/commands/watch" + +module RubyEventStore + module CLI + module Commands + class WatchOrdering < RubyEventStore::Event; end + class WatchOrderConfirmed < RubyEventStore::Event; end + class WatchPayment < RubyEventStore::Event; end + + RSpec.describe Watch do + let(:event_store) { RubyEventStore::Client.new } + let(:command) { Watch.new } + let(:past) { (Time.now - 3600).iso8601(3) } + let(:since) { Time.now - 3600 } + + before { EventStoreResolver.event_store = event_store } + + def call_once(**opts) + allow(command).to receive(:sleep) { raise StopIteration } + begin + command.call(limit: 50, interval: 1, since: past, **opts) + rescue SystemExit + end + end + + describe "prepare" do + it "returns events grouped by namespace" do + event_store.publish(WatchOrdering.new, stream_name: "test") + event_store.publish(WatchPayment.new, stream_name: "test") + + grouped = command.send(:prepare, event_store, since: since, namespaces: nil) + expect(grouped.map(&:first)).to eq(["RubyEventStore"]) + expect(grouped.first[1].size).to eq(2) + end + + it "filters by namespaces" do + event_store.publish(WatchOrdering.new, stream_name: "test") + event_store.publish(WatchPayment.new, stream_name: "test") + + grouped = command.send(:prepare, event_store, since: since, namespaces: ["Other"]) + expect(grouped).to be_empty + end + + it "returns empty when no events" do + grouped = command.send(:prepare, event_store, since: since, namespaces: nil) + expect(grouped).to be_empty + end + + it "excludes events older than since" do + event_store.publish(WatchOrdering.new, stream_name: "test") + future = Time.now + 3600 + + grouped = command.send(:prepare, event_store, since: future, namespaces: nil) + expect(grouped).to be_empty + end + end + + describe "render" do + it "shows no events message when grouped is empty" do + expect { command.send(:render, [], limit: 5, since: since) } + .to output(/No events yet/).to_stdout + end + + it "shows namespace header with event count" do + grouped = [["Ordering", [{ event_id: "abc", type: "Ordering::OrderPlaced", timestamp: Time.now }]]] + + expect { command.send(:render, grouped, limit: 5, since: since) } + .to output(/Ordering \(1 events\)/).to_stdout + end + + it "shows short type name" do + grouped = [["Ordering", [{ event_id: "abc", type: "Ordering::OrderPlaced", timestamp: Time.now }]]] + + expect { command.send(:render, grouped, limit: 5, since: since) } + .to output(/OrderPlaced/).to_stdout + end + + it "shows last N events per namespace" do + events = 10.times.map { |i| { event_id: "id-#{i}", type: "Ordering::OrderPlaced", timestamp: Time.now } } + grouped = [["Ordering", events]] + + output = capture_stdout { command.send(:render, grouped, limit: 3, since: since) } + event_lines = output.lines.select { |l| l.start_with?(" ") } + expect(event_lines.size).to eq(3) + end + + it "always shows follow footer" do + expect { command.send(:render, [], limit: 5, since: since) } + .to output(/Press Ctrl\+C/).to_stdout + end + end + + describe "#call" do + it "places events without namespace under Other" do + expect(command.send(:namespace, "OrderPlaced")).to eq("Other") + end + + it "exits cleanly on Interrupt" do + allow(command).to receive(:sleep) { raise Interrupt } + + expect { + command.call(limit: 5, interval: 1, since: past) + }.to raise_error(SystemExit) { |e| expect(e.status).to eq(0) } + end + + it "renders grouped events from event store" do + event_store.publish(WatchOrdering.new, stream_name: "test") + + expect { call_once }.to output(/WatchOrdering/).to_stdout + end + end + + def capture_stdout + out = StringIO.new + $stdout = out + yield + out.string + ensure + $stdout = STDOUT + end + end + end + end +end From c84a981e6fbad9faf6a97c846bba1f1b85a9bdc5 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 22:08:41 +0200 Subject: [PATCH 16/39] Add Makefile and clean up Gemfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove AR/Rails/SQLite deps — specs use in-memory client only. Co-Authored-By: Claude Sonnet 4.6 --- contrib/ruby_event_store-cli/Gemfile | 5 +- contrib/ruby_event_store-cli/Gemfile.lock | 102 ++++++++++++++++++++++ contrib/ruby_event_store-cli/Makefile | 8 ++ 3 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 contrib/ruby_event_store-cli/Gemfile.lock create mode 100644 contrib/ruby_event_store-cli/Makefile diff --git a/contrib/ruby_event_store-cli/Gemfile b/contrib/ruby_event_store-cli/Gemfile index 15ca2038cc..7cb06551b9 100644 --- a/contrib/ruby_event_store-cli/Gemfile +++ b/contrib/ruby_event_store-cli/Gemfile @@ -5,7 +5,4 @@ gemspec eval_gemfile "../../support/bundler/Gemfile.shared" -gem "ruby_event_store", path: "../.." -gem "rails_event_store_active_record", path: "../../ruby_event_store-active_record" -gem "sqlite3", ">= 2.1" -gem "rails", "~> 8.0.0" +gem "ruby_event_store", path: "../../ruby_event_store" diff --git a/contrib/ruby_event_store-cli/Gemfile.lock b/contrib/ruby_event_store-cli/Gemfile.lock new file mode 100644 index 0000000000..197a0974eb --- /dev/null +++ b/contrib/ruby_event_store-cli/Gemfile.lock @@ -0,0 +1,102 @@ +PATH + remote: ../../ruby_event_store + specs: + ruby_event_store (2.18.0) + concurrent-ruby (~> 1.0, >= 1.1.6) + +PATH + remote: . + specs: + ruby_event_store-cli (0.1.0) + dry-cli (>= 1.0) + ruby_event_store (>= 1.0.0) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + concurrent-ruby (1.3.6) + date (3.5.1) + diff-lcs (1.6.2) + drb (2.2.3) + dry-cli (1.4.1) + erb (6.0.2) + io-console (0.8.2) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) + mutant (0.15.1) + diff-lcs (>= 1.6, < 3) + irb (~> 1.15) + parser (~> 3.3.10) + regexp_parser (~> 2.10) + sorbet-runtime (~> 0.6.0) + unparser (~> 0.8.2) + mutant-minitest (0.15.1) + minitest (>= 5.11, < 7) + mutant (= 0.15.1) + mutex_m (~> 0.2) + mutant-rspec (0.15.1) + mutant (= 0.15.1) + rspec-core (>= 3.8.0, < 5.0.0) + mutex_m (0.3.0) + parser (3.3.10.2) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.3.1) + date + stringio + racc (1.8.1) + rake (13.3.1) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + 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) + sorbet-runtime (0.6.13055) + stringio (3.2.0) + tsort (0.2.0) + unparser (0.8.2) + diff-lcs (>= 1.6, < 3) + parser (>= 3.3.0) + prism (>= 1.5.1) + +PLATFORMS + arm64-darwin + +DEPENDENCIES + irb + mutant + mutant-minitest + mutant-rspec + rake (>= 10.0) + rspec + ruby_event_store! + ruby_event_store-cli! + +BUNDLED WITH + 2.7.1 diff --git a/contrib/ruby_event_store-cli/Makefile b/contrib/ruby_event_store-cli/Makefile new file mode 100644 index 0000000000..f75f086524 --- /dev/null +++ b/contrib/ruby_event_store-cli/Makefile @@ -0,0 +1,8 @@ +GEM_VERSION = $(shell cat lib/ruby_event_store/cli/version.rb | grep VERSION | egrep -o '[0-9]+\.[0-9]+\.[0-9]+') +GEM_NAME = ruby_event_store-cli + +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 From b2b1a31d6e4b4d98decaf1fe9398dacf919cda35 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 22:08:58 +0200 Subject: [PATCH 17/39] Add CI workflow for ruby_event_store-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests on Ruby 3.2, 3.3, 3.4 — no database needed. Co-Authored-By: Claude Sonnet 4.6 --- .../workflows/ruby_event_store-cli_test.yml | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/ruby_event_store-cli_test.yml diff --git a/.github/workflows/ruby_event_store-cli_test.yml b/.github/workflows/ruby_event_store-cli_test.yml new file mode 100644 index 0000000000..b60126602d --- /dev/null +++ b/.github/workflows/ruby_event_store-cli_test.yml @@ -0,0 +1,55 @@ +name: ruby_event_store-cli_test +on: + workflow_dispatch: + repository_dispatch: + types: + - script + push: + branches: + - master + paths: + - contrib/ruby_event_store-cli/** + - ".github/workflows/ruby_event_store-cli_test.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" + pull_request: + paths: + - contrib/ruby_event_store-cli/** + - ".github/workflows/ruby_event_store-cli_test.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + WORKING_DIRECTORY: contrib/ruby_event_store-cli + RUBY_VERSION: "${{ matrix.ruby_version }}" + BUNDLE_GEMFILE: "${{ matrix.bundle_gemfile }}" + strategy: + fail-fast: false + matrix: + include: + - ruby_version: ruby-3.4 + bundle_gemfile: Gemfile + - ruby_version: ruby-3.3 + bundle_gemfile: Gemfile + - ruby_version: ruby-3.2 + 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" From 74870e7253a6faee6277c895758c963539aacc28 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 22:21:57 +0200 Subject: [PATCH 18/39] Add mutant config Co-Authored-By: Claude Sonnet 4.6 --- contrib/ruby_event_store-cli/.mutant.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 contrib/ruby_event_store-cli/.mutant.yml diff --git a/contrib/ruby_event_store-cli/.mutant.yml b/contrib/ruby_event_store-cli/.mutant.yml new file mode 100644 index 0000000000..640d741ae0 --- /dev/null +++ b/contrib/ruby_event_store-cli/.mutant.yml @@ -0,0 +1,16 @@ +# https://github.com/mbj/mutant/blob/master/docs/configuration.md + +usage: opensource +requires: + - ruby_event_store/cli +includes: + - lib +integration: + name: rspec +mutation: + operators: light +coverage_criteria: + process_abort: true +matcher: + subjects: + - RubyEventStore::CLI* From e433e9855ce7bc2dbc02f119d84dc0161cc758bc Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 22:22:06 +0200 Subject: [PATCH 19/39] Add README Co-Authored-By: Claude Sonnet 4.6 --- contrib/ruby_event_store-cli/README.md | 98 ++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 contrib/ruby_event_store-cli/README.md diff --git a/contrib/ruby_event_store-cli/README.md b/contrib/ruby_event_store-cli/README.md new file mode 100644 index 0000000000..05199bce91 --- /dev/null +++ b/contrib/ruby_event_store-cli/README.md @@ -0,0 +1,98 @@ +# ruby_event_store-cli + +Command-line interface for inspecting a RubyEventStore event store without needing `rails console`. + +## Installation + +Add to your application's Gemfile: + +```ruby +gem "ruby_event_store-cli" +``` + +The `res` executable will be available in your project. Run all commands from your Rails app root directory — the CLI autodetects `config/environment.rb` and loads your environment. + +## Commands + +### Stream + +```bash +# List events in a stream (default: last 50) +bundle exec res stream events MyStream +bundle exec res stream events MyStream --limit 20 +bundle exec res stream events MyStream --format json +bundle exec res stream events MyStream --type OrderPlaced +bundle exec res stream events MyStream --after 2024-01-01T00:00:00Z +bundle exec res stream events MyStream --before 2024-03-01T00:00:00Z +bundle exec res stream events MyStream --from + +# Follow a stream for new events (Ctrl+C to stop) +bundle exec res stream events MyStream --follow +bundle exec res stream events MyStream -f + +# Show stream summary +bundle exec res stream show MyStream +``` + +### Event + +```bash +# Show full event details (data, metadata, timestamps) +bundle exec res event show + +# List all streams an event belongs to +bundle exec res event streams +``` + +### Search + +Search events across all streams or within a specific one: + +```bash +bundle exec res search --type OrderPlaced +bundle exec res search --type OrderPlaced --limit 100 +bundle exec res search --type OrderPlaced --after 2024-01-01T00:00:00Z +bundle exec res search --stream Orders --type OrderPlaced +bundle exec res search --format json | jq '.[].data' +``` + +### Trace + +Display the causal tree for a correlation ID — all events triggered by a single request, in order: + +```bash +bundle exec res trace +``` + +### Stats + +```bash +# Total event count and unique event types +bundle exec res stats + +# Stats for a specific stream +bundle exec res stats --stream Orders +``` + +### Watch + +Live view of new events as they arrive, grouped by bounded context (namespace prefix of the class name): + +```bash +# Watch all new events (Ctrl+C to stop) +bundle exec res watch + +# Filter to specific namespace(s) +bundle exec res watch --namespace Ordering +bundle exec res watch --namespace Ordering,Payments + +# Watch events from a point in time +bundle exec res watch --since 2024-01-15T10:00:00Z + +# Adjust polling interval and max events shown per namespace +bundle exec res watch --interval 2 --limit 20 +``` + +## License + +MIT From 35a1e2f3c35f80778fce183791f4964d6a448dcb Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 22:22:28 +0200 Subject: [PATCH 20/39] Improve command descriptions and add namespace subcommand hints Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands.rb | 9 +++++++++ .../lib/ruby_event_store/cli/commands/event_show.rb | 2 +- .../lib/ruby_event_store/cli/commands/event_streams.rb | 2 +- .../lib/ruby_event_store/cli/commands/search.rb | 2 +- .../lib/ruby_event_store/cli/commands/stats.rb | 2 +- .../lib/ruby_event_store/cli/commands/stream_events.rb | 2 +- .../lib/ruby_event_store/cli/commands/stream_show.rb | 2 +- .../lib/ruby_event_store/cli/commands/trace.rb | 2 +- 8 files changed, 16 insertions(+), 7 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb index 078866c7f9..f6cb5deb47 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -15,8 +15,17 @@ module CLI module Commands extend Dry::CLI::Registry + register "stream", Class.new(Dry::CLI::Command) { + desc "Inspect a stream" + def call(**) = warn "Usage: res stream SUBCOMMAND\n\nSubcommands: events, show\n\nRun `res stream --help` for details." + } register "stream events", StreamEvents register "stream show", StreamShow + + register "event", Class.new(Dry::CLI::Command) { + desc "Inspect an event" + def call(**) = warn "Usage: res event SUBCOMMAND\n\nSubcommands: show, streams\n\nRun `res event --help` for details." + } register "event show", EventShow register "event streams", EventStreams register "trace", Trace diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb index 496ec72ab8..17ac7a7557 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb @@ -8,7 +8,7 @@ module RubyEventStore module CLI module Commands class EventShow < Dry::CLI::Command - desc "Show event details" + desc "Print full event details including data, metadata, and timestamps" argument :event_id, required: true, desc: "Event ID (UUID)" diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb index f23a762d2e..aa54cb59d7 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb @@ -7,7 +7,7 @@ module RubyEventStore module CLI module Commands class EventStreams < Dry::CLI::Command - desc "List streams containing an event" + desc "List all streams the event has been published or linked to" argument :event_id, required: true, desc: "Event ID (UUID)" diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb index d072aa8c2f..7250abf272 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb @@ -10,7 +10,7 @@ module Commands class Search < Dry::CLI::Command include EventRenderer - desc "Search events by type, time, or stream" + desc "Search events across all streams by type, time range, or stream name" option :type, desc: "Filter by event type (class name)" option :after, desc: "Filter events newer than timestamp (ISO8601)" diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb index 3beff79bdf..7df9bb8eaf 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb @@ -7,7 +7,7 @@ module RubyEventStore module CLI module Commands class Stats < Dry::CLI::Command - desc "Show event store statistics" + desc "Show total event count and unique event types. Use --stream for per-stream stats." option :stream, desc: "Show stats for a specific stream" diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index e25791dba0..1229e363df 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -9,7 +9,7 @@ module CLI module Commands class StreamEvents < Dry::CLI::Command include EventRenderer - desc "List events in a stream" + desc "Print events from a stream. Supports filtering by type, time, and position. Use --follow/-f to tail live." argument :stream_name, required: true, desc: "Stream name" option :limit, type: :integer, default: 50, desc: "Max number of events (default: 50)" diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb index 4f3dff70bb..32408b77ca 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb @@ -7,7 +7,7 @@ module RubyEventStore module CLI module Commands class StreamShow < Dry::CLI::Command - desc "Show stream details" + desc "Show event count, version, and first/last event for a stream" argument :stream_name, required: true, desc: "Stream name" diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb index 8148f0e6a5..1e0d871b9d 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb @@ -7,7 +7,7 @@ module RubyEventStore module CLI module Commands class Trace < Dry::CLI::Command - desc "Show causal tree for a correlation ID" + desc "Print the causation tree for all events sharing a correlation ID" argument :correlation_id, required: true, desc: "Correlation ID (UUID)" From 2764a779f5645f1b967566c021e3fa7a594137a9 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Wed, 22 Apr 2026 23:15:48 +0200 Subject: [PATCH 21/39] Add missing platforms to Gemfile.lock Co-Authored-By: Claude Sonnet 4.6 --- contrib/ruby_event_store-cli/Gemfile.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contrib/ruby_event_store-cli/Gemfile.lock b/contrib/ruby_event_store-cli/Gemfile.lock index 197a0974eb..909e1b2580 100644 --- a/contrib/ruby_event_store-cli/Gemfile.lock +++ b/contrib/ruby_event_store-cli/Gemfile.lock @@ -87,6 +87,8 @@ GEM PLATFORMS arm64-darwin + x86_64-darwin + x86_64-linux DEPENDENCIES irb From eec0777c96fc74ce28d4fddece930fb4cdad7012 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 15:14:43 +0200 Subject: [PATCH 22/39] Move event store resolution to bin/res, remove EventStoreResolver Co-Authored-By: Claude Sonnet 4.6 --- contrib/ruby_event_store-cli/bin/res | 10 ++++ .../cli/commands/event_show.rb | 3 +- .../cli/commands/event_streams.rb | 3 +- .../ruby_event_store/cli/commands/follow.rb | 3 +- .../ruby_event_store/cli/commands/search.rb | 3 +- .../ruby_event_store/cli/commands/stats.rb | 3 +- .../cli/commands/stream_events.rb | 3 +- .../cli/commands/stream_show.rb | 3 +- .../ruby_event_store/cli/commands/trace.rb | 3 +- .../ruby_event_store/cli/commands/watch.rb | 3 +- .../cli/event_store_resolver.rb | 43 --------------- .../spec/commands/event_show_spec.rb | 2 +- .../spec/commands/event_streams_spec.rb | 2 +- .../spec/commands/follow_spec.rb | 2 +- .../spec/commands/search_spec.rb | 2 +- .../spec/commands/stats_spec.rb | 2 +- .../spec/commands/stream_events_spec.rb | 2 +- .../spec/commands/stream_show_spec.rb | 2 +- .../spec/commands/trace_spec.rb | 2 +- .../spec/commands/watch_spec.rb | 2 +- .../spec/event_store_resolver_spec.rb | 54 ------------------- .../ruby_event_store-cli/spec/spec_helper.rb | 2 +- 22 files changed, 29 insertions(+), 125 deletions(-) delete mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_store_resolver.rb delete mode 100644 contrib/ruby_event_store-cli/spec/event_store_resolver_spec.rb diff --git a/contrib/ruby_event_store-cli/bin/res b/contrib/ruby_event_store-cli/bin/res index 3fc6d75e9e..da4810da3e 100755 --- a/contrib/ruby_event_store-cli/bin/res +++ b/contrib/ruby_event_store-cli/bin/res @@ -5,4 +5,14 @@ require "dry/cli" require_relative "../lib/ruby_event_store/cli" require_relative "../lib/ruby_event_store/cli/commands" +require File.expand_path("config/environment") + +abort <<~MSG unless defined?(Rails) && Rails.configuration.respond_to?(:event_store) + Could not find event store instance after loading config/environment.rb. + + Expected Rails.configuration.event_store to be set (standard RES setup). +MSG + +RubyEventStore::CLI::EVENT_STORE = Rails.configuration.event_store + Dry::CLI.new(RubyEventStore::CLI::Commands).call diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb index 17ac7a7557..37d29f8b3b 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb @@ -2,7 +2,6 @@ require "dry/cli" require "json" -require_relative "../event_store_resolver" module RubyEventStore module CLI @@ -13,7 +12,7 @@ class EventShow < Dry::CLI::Command argument :event_id, required: true, desc: "Event ID (UUID)" def call(event_id:, **) - event_store = EventStoreResolver.resolve + event_store = RubyEventStore::CLI::EVENT_STORE event = event_store.read.event!(event_id) puts "Event ID: #{event.event_id}" diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb index aa54cb59d7..91cee30dee 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "dry/cli" -require_relative "../event_store_resolver" module RubyEventStore module CLI @@ -12,7 +11,7 @@ class EventStreams < Dry::CLI::Command argument :event_id, required: true, desc: "Event ID (UUID)" def call(event_id:, **) - event_store = EventStoreResolver.resolve + event_store = RubyEventStore::CLI::EVENT_STORE streams = event_store.streams_of(event_id) if streams.empty? diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb index cdabf118b6..8b88f2ec15 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "dry/cli" -require_relative "../event_store_resolver" module RubyEventStore module CLI @@ -16,7 +15,7 @@ class Follow < Dry::CLI::Command option :follow, type: :boolean, default: true, desc: "Watch for new events (default: true, use --no-follow for one-shot)" def call(namespace: nil, since: nil, limit:, interval:, follow:, **) - event_store = EventStoreResolver.resolve + event_store = RubyEventStore::CLI::EVENT_STORE started_at = since ? Time.parse(since) : Time.now namespaces = namespace&.split(",")&.map(&:strip) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb index 7250abf272..c34b7ea634 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "dry/cli" -require_relative "../event_store_resolver" require_relative "../event_renderer" module RubyEventStore @@ -20,7 +19,7 @@ class Search < Dry::CLI::Command option :format, default: "table", values: %w[table json], desc: "Output format" def call(limit:, format:, type: nil, after: nil, before: nil, stream: nil, **) - event_store = EventStoreResolver.resolve + event_store = RubyEventStore::CLI::EVENT_STORE reader = stream ? event_store.read.stream(stream) : event_store.read reader = reader.of_type(resolve_type(type)) if type reader = reader.newer_than(Time.parse(after)) if after diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb index 7df9bb8eaf..02ca100f9e 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "dry/cli" -require_relative "../event_store_resolver" module RubyEventStore module CLI @@ -12,7 +11,7 @@ class Stats < Dry::CLI::Command option :stream, desc: "Show stats for a specific stream" def call(stream: nil, **) - event_store = EventStoreResolver.resolve + event_store = RubyEventStore::CLI::EVENT_STORE reader = stream ? event_store.read.stream(stream) : event_store.read puts "Stream: #{stream}" if stream diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index 1229e363df..556b194f7b 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "dry/cli" -require_relative "../event_store_resolver" require_relative "../event_renderer" module RubyEventStore @@ -21,7 +20,7 @@ class StreamEvents < Dry::CLI::Command option :follow, type: :boolean, default: false, aliases: ["-f"], desc: "Watch for new events (Ctrl+C to stop)" def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from: nil, follow: false, **) - event_store = EventStoreResolver.resolve + event_store = RubyEventStore::CLI::EVENT_STORE reader = event_store.read.stream(stream_name) reader = reader.of_type(resolve_type(type)) if type reader = reader.newer_than(Time.parse(after)) if after diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb index 32408b77ca..4676fed0c3 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "dry/cli" -require_relative "../event_store_resolver" module RubyEventStore module CLI @@ -12,7 +11,7 @@ class StreamShow < Dry::CLI::Command argument :stream_name, required: true, desc: "Stream name" def call(stream_name:, **) - event_store = EventStoreResolver.resolve + event_store = RubyEventStore::CLI::EVENT_STORE reader = event_store.read.stream(stream_name) count = reader.count diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb index 1e0d871b9d..9730824916 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "dry/cli" -require_relative "../event_store_resolver" module RubyEventStore module CLI @@ -12,7 +11,7 @@ class Trace < Dry::CLI::Command argument :correlation_id, required: true, desc: "Correlation ID (UUID)" def call(correlation_id:, **) - event_store = EventStoreResolver.resolve + event_store = RubyEventStore::CLI::EVENT_STORE stream_name = "$by_correlation_id_#{correlation_id}" events = event_store.read.stream(stream_name).to_a diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb index cddc9abc00..5c0013255a 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "dry/cli" -require_relative "../event_store_resolver" module RubyEventStore module CLI @@ -15,7 +14,7 @@ class Watch < Dry::CLI::Command option :interval, type: :integer, default: 1, desc: "Refresh interval in seconds (default: 1)" def call(namespace: nil, since: nil, limit:, interval:, **) - event_store = EventStoreResolver.resolve + event_store = RubyEventStore::CLI::EVENT_STORE started_at = since ? Time.parse(since) : Time.now namespaces = namespace&.split(",")&.map(&:strip) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_store_resolver.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_store_resolver.rb deleted file mode 100644 index f1258893df..0000000000 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/event_store_resolver.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module RubyEventStore - module CLI - module EventStoreResolver - DEFAULT_REQUIRE_PATH = "config/environment.rb" - CANDIDATE_CONSTS = %w[EVENT_STORE RES EventStore].freeze - - class << self - attr_accessor :event_store - end - - def self.resolve - return event_store if event_store - - require File.expand_path(DEFAULT_REQUIRE_PATH) - find_event_store || abort(<<~MSG) - Could not find event store instance after loading #{DEFAULT_REQUIRE_PATH}. - - Expected one of: - - Rails.configuration.event_store (standard RES setup) - - A constant named: #{CANDIDATE_CONSTS.join(", ")} - - Or configure it explicitly: - RubyEventStore::CLI::EventStoreResolver.event_store = MyApp::EventStore - MSG - end - - def self.find_event_store - if defined?(Rails) && Rails.respond_to?(:configuration) && - Rails.configuration.respond_to?(:event_store) - return Rails.configuration.event_store - end - - CANDIDATE_CONSTS.each do |const_name| - return Object.const_get(const_name) if Object.const_defined?(const_name) - end - - nil - end - end - end -end diff --git a/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb b/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb index 1878fa7576..702acfdc91 100644 --- a/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb @@ -10,7 +10,7 @@ module Commands let(:event_store) { RubyEventStore::Client.new } let(:command) { EventShow.new } - before { EventStoreResolver.event_store = event_store } + before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } describe "#call" do it "shows event details" do diff --git a/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb b/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb index a28d04e4e3..a788af62c6 100644 --- a/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb @@ -10,7 +10,7 @@ module Commands let(:event_store) { RubyEventStore::Client.new } let(:command) { EventStreams.new } - before { EventStoreResolver.event_store = event_store } + before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } describe "#call" do it "lists streams containing the event" do diff --git a/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb b/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb index 180bee9a23..698482f6c3 100644 --- a/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb @@ -16,7 +16,7 @@ class FollowInventory < RubyEventStore::Event; end let(:command) { Follow.new } let(:past) { (Time.now - 3600).iso8601(3) } - before { EventStoreResolver.event_store = event_store } + before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } def call_once(**opts) begin diff --git a/contrib/ruby_event_store-cli/spec/commands/search_spec.rb b/contrib/ruby_event_store-cli/spec/commands/search_spec.rb index ece6dad5da..a201595daf 100644 --- a/contrib/ruby_event_store-cli/spec/commands/search_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/search_spec.rb @@ -12,7 +12,7 @@ class SearchTestEvent < RubyEventStore::Event; end let(:event_store) { RubyEventStore::Client.new } let(:command) { Search.new } - before { EventStoreResolver.event_store = event_store } + before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } describe "#call" do it "searches all events when no filters given" do diff --git a/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb index cd61982f19..ae3a318991 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb @@ -10,7 +10,7 @@ module Commands let(:event_store) { RubyEventStore::Client.new } let(:command) { Stats.new } - before { EventStoreResolver.event_store = event_store } + before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } describe "#call" do it "shows total event count" do diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb index 0df4fa491d..796d454f0b 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb @@ -12,7 +12,7 @@ class OtherEvent < RubyEventStore::Event; end let(:event_store) { RubyEventStore::Client.new } let(:command) { StreamEvents.new } - before { EventStoreResolver.event_store = event_store } + before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } describe "#call" do it "reads events from given stream" do diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb index b9210abc2f..5bfb73cd83 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb @@ -13,7 +13,7 @@ class LastType < RubyEventStore::Event; end let(:event_store) { RubyEventStore::Client.new } let(:command) { StreamShow.new } - before { EventStoreResolver.event_store = event_store } + before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } describe "#call" do it "shows stream details" do diff --git a/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb b/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb index 646530b0fb..b42fa01880 100644 --- a/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb @@ -11,7 +11,7 @@ module Commands let(:command) { Trace.new } let(:correlation_id) { SecureRandom.uuid } - before { EventStoreResolver.event_store = event_store } + before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } def publish_correlated(event, causation_id: nil) meta = { correlation_id: correlation_id } diff --git a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb index d327a82f1d..2878120546 100644 --- a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb @@ -16,7 +16,7 @@ class WatchPayment < RubyEventStore::Event; end let(:past) { (Time.now - 3600).iso8601(3) } let(:since) { Time.now - 3600 } - before { EventStoreResolver.event_store = event_store } + before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } def call_once(**opts) allow(command).to receive(:sleep) { raise StopIteration } diff --git a/contrib/ruby_event_store-cli/spec/event_store_resolver_spec.rb b/contrib/ruby_event_store-cli/spec/event_store_resolver_spec.rb deleted file mode 100644 index 1155d17e3b..0000000000 --- a/contrib/ruby_event_store-cli/spec/event_store_resolver_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require_relative "spec_helper" -require "ruby_event_store/cli/event_store_resolver" - -module RubyEventStore - module CLI - RSpec.describe EventStoreResolver do - describe ".resolve" do - it "returns instance set via initializer without loading environment" do - fake_store = instance_double(RubyEventStore::Client) - EventStoreResolver.event_store = fake_store - - expect(EventStoreResolver).not_to receive(:require) - expect(EventStoreResolver.resolve).to eq(fake_store) - end - end - - describe ".find_event_store" do - it "returns Rails.configuration.event_store when available" do - fake_store = instance_double(RubyEventStore::Client) - fake_config = FakeConfiguration.new - fake_config.event_store = fake_store - stub_const("Rails", double(configuration: fake_config, respond_to?: true)) - - expect(EventStoreResolver.find_event_store).to eq(fake_store) - end - - it "falls back to EVENT_STORE constant" do - hide_const("Rails") - fake_store = instance_double(RubyEventStore::Client) - stub_const("EVENT_STORE", fake_store) - - expect(EventStoreResolver.find_event_store).to eq(fake_store) - end - - it "falls back to RES constant" do - hide_const("Rails") - fake_store = instance_double(RubyEventStore::Client) - stub_const("RES", fake_store) - - expect(EventStoreResolver.find_event_store).to eq(fake_store) - end - - it "returns nil when nothing found" do - hide_const("Rails") - EventStoreResolver::CANDIDATE_CONSTS.each { |c| hide_const(c) if Object.const_defined?(c) } - - expect(EventStoreResolver.find_event_store).to be_nil - end - end - end - end -end diff --git a/contrib/ruby_event_store-cli/spec/spec_helper.rb b/contrib/ruby_event_store-cli/spec/spec_helper.rb index 01145a07d3..ebc471c421 100644 --- a/contrib/ruby_event_store-cli/spec/spec_helper.rb +++ b/contrib/ruby_event_store-cli/spec/spec_helper.rb @@ -8,5 +8,5 @@ mocks.verify_partial_doubles = true end - config.after { RubyEventStore::CLI::EventStoreResolver.event_store = nil } + end From 3e3edb996597aa52a3e049e89f55a2173a1abc26 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 15:30:20 +0200 Subject: [PATCH 23/39] Extract Base command class with event_store private method All commands inherit from Base instead of Dry::CLI::Command, removing the repeated local variable assignment in every call method. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands.rb | 1 + .../lib/ruby_event_store/cli/commands/base.rb | 17 +++++++++++++++++ .../ruby_event_store/cli/commands/event_show.rb | 4 ++-- .../cli/commands/event_streams.rb | 4 ++-- .../lib/ruby_event_store/cli/commands/follow.rb | 10 +++++----- .../lib/ruby_event_store/cli/commands/search.rb | 4 ++-- .../lib/ruby_event_store/cli/commands/stats.rb | 4 ++-- .../cli/commands/stream_events.rb | 4 ++-- .../cli/commands/stream_show.rb | 4 ++-- .../lib/ruby_event_store/cli/commands/trace.rb | 4 ++-- .../lib/ruby_event_store/cli/commands/watch.rb | 8 ++++---- .../spec/commands/watch_spec.rb | 8 ++++---- 12 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/base.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb index f6cb5deb47..e8227f5d88 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "dry/cli" +require_relative "commands/base" require_relative "commands/stream_events" require_relative "commands/stream_show" require_relative "commands/event_show" diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/base.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/base.rb new file mode 100644 index 0000000000..be0461e81d --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "dry/cli" + +module RubyEventStore + module CLI + module Commands + class Base < Dry::CLI::Command + private + + def event_store + CLI::EVENT_STORE + end + end + end + end +end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb index 37d29f8b3b..a7bdfa1e66 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb @@ -2,17 +2,17 @@ require "dry/cli" require "json" +require_relative "base" module RubyEventStore module CLI module Commands - class EventShow < Dry::CLI::Command + class EventShow < Base desc "Print full event details including data, metadata, and timestamps" argument :event_id, required: true, desc: "Event ID (UUID)" def call(event_id:, **) - event_store = RubyEventStore::CLI::EVENT_STORE event = event_store.read.event!(event_id) puts "Event ID: #{event.event_id}" diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb index 91cee30dee..0085015d63 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true require "dry/cli" +require_relative "base" module RubyEventStore module CLI module Commands - class EventStreams < Dry::CLI::Command + class EventStreams < Base desc "List all streams the event has been published or linked to" argument :event_id, required: true, desc: "Event ID (UUID)" def call(event_id:, **) - event_store = RubyEventStore::CLI::EVENT_STORE streams = event_store.streams_of(event_id) if streams.empty? diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb index 8b88f2ec15..5fb3e5b711 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true require "dry/cli" +require_relative "base" module RubyEventStore module CLI module Commands - class Follow < Dry::CLI::Command + class Follow < Base desc "Watch new events live, grouped by bounded context" option :namespace, desc: "Filter by namespace(s), comma-separated (e.g. Ordering,Payments)" @@ -15,18 +16,17 @@ class Follow < Dry::CLI::Command option :follow, type: :boolean, default: true, desc: "Watch for new events (default: true, use --no-follow for one-shot)" def call(namespace: nil, since: nil, limit:, interval:, follow:, **) - event_store = RubyEventStore::CLI::EVENT_STORE started_at = since ? Time.parse(since) : Time.now namespaces = namespace&.split(",")&.map(&:strip) if follow hide_cursor loop do - render(event_store, limit: limit.to_i, since: started_at, namespaces: namespaces, follow: true) + render(limit: limit.to_i, since: started_at, namespaces: namespaces, follow: true) sleep interval.to_i end else - render(event_store, limit: limit.to_i, since: started_at, namespaces: namespaces, follow: false) + render(limit: limit.to_i, since: started_at, namespaces: namespaces, follow: false) end rescue Interrupt show_cursor @@ -39,7 +39,7 @@ def call(namespace: nil, since: nil, limit:, interval:, follow:, **) private - def render(event_store, limit:, since:, namespaces:, follow:) + def render(limit:, since:, namespaces:, follow:) events = event_store.read.newer_than(since).map do |e| { event_id: e.event_id, type: e.event_type, timestamp: e.timestamp } end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb index c34b7ea634..b864919423 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true require "dry/cli" +require_relative "base" require_relative "../event_renderer" module RubyEventStore module CLI module Commands - class Search < Dry::CLI::Command + class Search < Base include EventRenderer desc "Search events across all streams by type, time range, or stream name" @@ -19,7 +20,6 @@ class Search < Dry::CLI::Command option :format, default: "table", values: %w[table json], desc: "Output format" def call(limit:, format:, type: nil, after: nil, before: nil, stream: nil, **) - event_store = RubyEventStore::CLI::EVENT_STORE reader = stream ? event_store.read.stream(stream) : event_store.read reader = reader.of_type(resolve_type(type)) if type reader = reader.newer_than(Time.parse(after)) if after diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb index 02ca100f9e..5b655e7dec 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true require "dry/cli" +require_relative "base" module RubyEventStore module CLI module Commands - class Stats < Dry::CLI::Command + class Stats < Base desc "Show total event count and unique event types. Use --stream for per-stream stats." option :stream, desc: "Show stats for a specific stream" def call(stream: nil, **) - event_store = RubyEventStore::CLI::EVENT_STORE reader = stream ? event_store.read.stream(stream) : event_store.read puts "Stream: #{stream}" if stream diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index 556b194f7b..078b20a965 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true require "dry/cli" +require_relative "base" require_relative "../event_renderer" module RubyEventStore module CLI module Commands - class StreamEvents < Dry::CLI::Command + class StreamEvents < Base include EventRenderer desc "Print events from a stream. Supports filtering by type, time, and position. Use --follow/-f to tail live." @@ -20,7 +21,6 @@ class StreamEvents < Dry::CLI::Command option :follow, type: :boolean, default: false, aliases: ["-f"], desc: "Watch for new events (Ctrl+C to stop)" def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from: nil, follow: false, **) - event_store = RubyEventStore::CLI::EVENT_STORE reader = event_store.read.stream(stream_name) reader = reader.of_type(resolve_type(type)) if type reader = reader.newer_than(Time.parse(after)) if after diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb index 4676fed0c3..18414d32c7 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true require "dry/cli" +require_relative "base" module RubyEventStore module CLI module Commands - class StreamShow < Dry::CLI::Command + class StreamShow < Base desc "Show event count, version, and first/last event for a stream" argument :stream_name, required: true, desc: "Stream name" def call(stream_name:, **) - event_store = RubyEventStore::CLI::EVENT_STORE reader = event_store.read.stream(stream_name) count = reader.count diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb index 9730824916..ea4d9b7b76 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true require "dry/cli" +require_relative "base" module RubyEventStore module CLI module Commands - class Trace < Dry::CLI::Command + class Trace < Base desc "Print the causation tree for all events sharing a correlation ID" argument :correlation_id, required: true, desc: "Correlation ID (UUID)" def call(correlation_id:, **) - event_store = RubyEventStore::CLI::EVENT_STORE stream_name = "$by_correlation_id_#{correlation_id}" events = event_store.read.stream(stream_name).to_a diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb index 5c0013255a..43a704f655 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true require "dry/cli" +require_relative "base" module RubyEventStore module CLI module Commands - class Watch < Dry::CLI::Command + class Watch < Base desc "Watch new events live, grouped by bounded context" option :namespace, desc: "Filter by namespace(s), comma-separated (e.g. Ordering,Payments)" @@ -14,13 +15,12 @@ class Watch < Dry::CLI::Command option :interval, type: :integer, default: 1, desc: "Refresh interval in seconds (default: 1)" def call(namespace: nil, since: nil, limit:, interval:, **) - event_store = RubyEventStore::CLI::EVENT_STORE started_at = since ? Time.parse(since) : Time.now namespaces = namespace&.split(",")&.map(&:strip) hide_cursor loop do - grouped = prepare(event_store, since: started_at, namespaces: namespaces) + grouped = prepare(since: started_at, namespaces: namespaces) render(grouped, limit: limit.to_i, since: started_at) sleep interval.to_i end @@ -35,7 +35,7 @@ def call(namespace: nil, since: nil, limit:, interval:, **) private - def prepare(event_store, since:, namespaces:) + def prepare(since:, namespaces:) events = event_store.read.newer_than(since).map do |e| { event_id: e.event_id, type: e.event_type, timestamp: e.timestamp } end diff --git a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb index 2878120546..35ca182e3a 100644 --- a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb @@ -31,7 +31,7 @@ def call_once(**opts) event_store.publish(WatchOrdering.new, stream_name: "test") event_store.publish(WatchPayment.new, stream_name: "test") - grouped = command.send(:prepare, event_store, since: since, namespaces: nil) + grouped = command.send(:prepare, since: since, namespaces: nil) expect(grouped.map(&:first)).to eq(["RubyEventStore"]) expect(grouped.first[1].size).to eq(2) end @@ -40,12 +40,12 @@ def call_once(**opts) event_store.publish(WatchOrdering.new, stream_name: "test") event_store.publish(WatchPayment.new, stream_name: "test") - grouped = command.send(:prepare, event_store, since: since, namespaces: ["Other"]) + grouped = command.send(:prepare, since: since, namespaces: ["Other"]) expect(grouped).to be_empty end it "returns empty when no events" do - grouped = command.send(:prepare, event_store, since: since, namespaces: nil) + grouped = command.send(:prepare, since: since, namespaces: nil) expect(grouped).to be_empty end @@ -53,7 +53,7 @@ def call_once(**opts) event_store.publish(WatchOrdering.new, stream_name: "test") future = Time.now + 3600 - grouped = command.send(:prepare, event_store, since: future, namespaces: nil) + grouped = command.send(:prepare, since: future, namespaces: nil) expect(grouped).to be_empty end end From c54933a17bfc5227de869ee427fa7521f3d3834c Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 15:51:31 +0200 Subject: [PATCH 24/39] Read event store from Rails.configuration instead of global constant Base#event_store reads Rails.configuration.event_store directly, removing the CLI::EVENT_STORE constant assignment from bin/res. Tests use stub_event_store helper backed by FakeConfiguration. Co-Authored-By: Claude Sonnet 4.6 --- contrib/ruby_event_store-cli/bin/res | 2 -- .../lib/ruby_event_store/cli/commands/base.rb | 2 +- .../ruby_event_store-cli/spec/commands/event_show_spec.rb | 2 +- .../spec/commands/event_streams_spec.rb | 2 +- contrib/ruby_event_store-cli/spec/commands/follow_spec.rb | 2 +- contrib/ruby_event_store-cli/spec/commands/search_spec.rb | 2 +- contrib/ruby_event_store-cli/spec/commands/stats_spec.rb | 2 +- .../spec/commands/stream_events_spec.rb | 2 +- .../spec/commands/stream_show_spec.rb | 2 +- contrib/ruby_event_store-cli/spec/commands/trace_spec.rb | 2 +- contrib/ruby_event_store-cli/spec/commands/watch_spec.rb | 2 +- contrib/ruby_event_store-cli/spec/spec_helper.rb | 8 +++++++- 12 files changed, 17 insertions(+), 13 deletions(-) diff --git a/contrib/ruby_event_store-cli/bin/res b/contrib/ruby_event_store-cli/bin/res index da4810da3e..fd470b0ede 100755 --- a/contrib/ruby_event_store-cli/bin/res +++ b/contrib/ruby_event_store-cli/bin/res @@ -13,6 +13,4 @@ abort <<~MSG unless defined?(Rails) && Rails.configuration.respond_to?(:event_st Expected Rails.configuration.event_store to be set (standard RES setup). MSG -RubyEventStore::CLI::EVENT_STORE = Rails.configuration.event_store - Dry::CLI.new(RubyEventStore::CLI::Commands).call diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/base.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/base.rb index be0461e81d..945fe5115f 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/base.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/base.rb @@ -9,7 +9,7 @@ class Base < Dry::CLI::Command private def event_store - CLI::EVENT_STORE + Rails.configuration.event_store end end end diff --git a/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb b/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb index 702acfdc91..c54ceea153 100644 --- a/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/event_show_spec.rb @@ -10,7 +10,7 @@ module Commands let(:event_store) { RubyEventStore::Client.new } let(:command) { EventShow.new } - before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } + before { stub_event_store(event_store) } describe "#call" do it "shows event details" do diff --git a/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb b/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb index a788af62c6..a64f1367bb 100644 --- a/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/event_streams_spec.rb @@ -10,7 +10,7 @@ module Commands let(:event_store) { RubyEventStore::Client.new } let(:command) { EventStreams.new } - before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } + before { stub_event_store(event_store) } describe "#call" do it "lists streams containing the event" do diff --git a/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb b/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb index 698482f6c3..b78e48f270 100644 --- a/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb @@ -16,7 +16,7 @@ class FollowInventory < RubyEventStore::Event; end let(:command) { Follow.new } let(:past) { (Time.now - 3600).iso8601(3) } - before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } + before { stub_event_store(event_store) } def call_once(**opts) begin diff --git a/contrib/ruby_event_store-cli/spec/commands/search_spec.rb b/contrib/ruby_event_store-cli/spec/commands/search_spec.rb index a201595daf..dd05be65ff 100644 --- a/contrib/ruby_event_store-cli/spec/commands/search_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/search_spec.rb @@ -12,7 +12,7 @@ class SearchTestEvent < RubyEventStore::Event; end let(:event_store) { RubyEventStore::Client.new } let(:command) { Search.new } - before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } + before { stub_event_store(event_store) } describe "#call" do it "searches all events when no filters given" do diff --git a/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb index ae3a318991..088b572c87 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stats_spec.rb @@ -10,7 +10,7 @@ module Commands let(:event_store) { RubyEventStore::Client.new } let(:command) { Stats.new } - before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } + before { stub_event_store(event_store) } describe "#call" do it "shows total event count" do diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb index 796d454f0b..6d6dc84305 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb @@ -12,7 +12,7 @@ class OtherEvent < RubyEventStore::Event; end let(:event_store) { RubyEventStore::Client.new } let(:command) { StreamEvents.new } - before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } + before { stub_event_store(event_store) } describe "#call" do it "reads events from given stream" do diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb index 5bfb73cd83..2a16cd3510 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb @@ -13,7 +13,7 @@ class LastType < RubyEventStore::Event; end let(:event_store) { RubyEventStore::Client.new } let(:command) { StreamShow.new } - before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } + before { stub_event_store(event_store) } describe "#call" do it "shows stream details" do diff --git a/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb b/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb index b42fa01880..857cce8fe6 100644 --- a/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/trace_spec.rb @@ -11,7 +11,7 @@ module Commands let(:command) { Trace.new } let(:correlation_id) { SecureRandom.uuid } - before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } + before { stub_event_store(event_store) } def publish_correlated(event, causation_id: nil) meta = { correlation_id: correlation_id } diff --git a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb index 35ca182e3a..1466de6cc8 100644 --- a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb @@ -16,7 +16,7 @@ class WatchPayment < RubyEventStore::Event; end let(:past) { (Time.now - 3600).iso8601(3) } let(:since) { Time.now - 3600 } - before { stub_const("RubyEventStore::CLI::EVENT_STORE", event_store) } + before { stub_event_store(event_store) } def call_once(**opts) allow(command).to receive(:sleep) { raise StopIteration } diff --git a/contrib/ruby_event_store-cli/spec/spec_helper.rb b/contrib/ruby_event_store-cli/spec/spec_helper.rb index ebc471c421..7164f9bea1 100644 --- a/contrib/ruby_event_store-cli/spec/spec_helper.rb +++ b/contrib/ruby_event_store-cli/spec/spec_helper.rb @@ -8,5 +8,11 @@ mocks.verify_partial_doubles = true end - + config.include(Module.new do + def stub_event_store(event_store) + config = FakeConfiguration.new + config.event_store = event_store + stub_const("Rails", double("Rails", configuration: config)) + end + end) end From 36a682a1192e1072afc39a6b78bc4e4480687888 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 16:24:36 +0200 Subject: [PATCH 25/39] Extract named methods in Watch#prepare Replace inline blocks with events_since, filter_by_namespaces, and group_by_namespace to make each step explicit. Co-Authored-By: Claude Sonnet 4.6 --- .../ruby_event_store/cli/commands/watch.rb | 21 +++++++++++++++---- .../spec/commands/watch_spec.rb | 10 ++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb index 43a704f655..3264e927ef 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb @@ -20,7 +20,7 @@ def call(namespace: nil, since: nil, limit:, interval:, **) hide_cursor loop do - grouped = prepare(since: started_at, namespaces: namespaces) + grouped = grouped_events(since: started_at, namespaces: namespaces) render(grouped, limit: limit.to_i, since: started_at) sleep interval.to_i end @@ -35,11 +35,24 @@ def call(namespace: nil, since: nil, limit:, interval:, **) private - def prepare(since:, namespaces:) - events = event_store.read.newer_than(since).map do |e| + def grouped_events(since:, namespaces:) + events = events_since(since) + events = filter_by_namespaces(events, namespaces) + group_by_namespace(events) + end + + def events_since(since) + event_store.read.newer_than(since).map do |e| { event_id: e.event_id, type: e.event_type, timestamp: e.timestamp } end - events = events.select { |e| namespaces.include?(namespace(e[:type])) } if namespaces + end + + def filter_by_namespaces(events, namespaces) + return events unless namespaces + events.select { |e| namespaces.include?(namespace(e[:type])) } + end + + def group_by_namespace(events) events.group_by { |e| namespace(e[:type]) }.sort end diff --git a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb index 1466de6cc8..2ba61ce453 100644 --- a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb @@ -26,12 +26,12 @@ def call_once(**opts) end end - describe "prepare" do + describe "grouped_events" do it "returns events grouped by namespace" do event_store.publish(WatchOrdering.new, stream_name: "test") event_store.publish(WatchPayment.new, stream_name: "test") - grouped = command.send(:prepare, since: since, namespaces: nil) + grouped = command.send(:grouped_events, since: since, namespaces: nil) expect(grouped.map(&:first)).to eq(["RubyEventStore"]) expect(grouped.first[1].size).to eq(2) end @@ -40,12 +40,12 @@ def call_once(**opts) event_store.publish(WatchOrdering.new, stream_name: "test") event_store.publish(WatchPayment.new, stream_name: "test") - grouped = command.send(:prepare, since: since, namespaces: ["Other"]) + grouped = command.send(:grouped_events, since: since, namespaces: ["Other"]) expect(grouped).to be_empty end it "returns empty when no events" do - grouped = command.send(:prepare, since: since, namespaces: nil) + grouped = command.send(:grouped_events, since: since, namespaces: nil) expect(grouped).to be_empty end @@ -53,7 +53,7 @@ def call_once(**opts) event_store.publish(WatchOrdering.new, stream_name: "test") future = Time.now + 3600 - grouped = command.send(:prepare, since: future, namespaces: nil) + grouped = command.send(:grouped_events, since: future, namespaces: nil) expect(grouped).to be_empty end end From ba3b86a497559f8b2739d3584231f3e8a06d4298 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 16:36:04 +0200 Subject: [PATCH 26/39] Remove Follow command accidentally added during Watch rename Co-Authored-By: Claude Sonnet 4.6 --- .../ruby_event_store/cli/commands/follow.rb | 101 --------------- .../spec/commands/follow_spec.rb | 119 ------------------ 2 files changed, 220 deletions(-) delete mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb delete mode 100644 contrib/ruby_event_store-cli/spec/commands/follow_spec.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb deleted file mode 100644 index 5fb3e5b711..0000000000 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/follow.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -require "dry/cli" -require_relative "base" - -module RubyEventStore - module CLI - module Commands - class Follow < Base - desc "Watch new events live, grouped by bounded context" - - option :namespace, desc: "Filter by namespace(s), comma-separated (e.g. Ordering,Payments)" - option :since, desc: "Watch events since timestamp (ISO8601, default: now)" - option :limit, type: :integer, default: 5, desc: "Max events shown per namespace (default: 5)" - option :interval, type: :integer, default: 1, desc: "Refresh interval in seconds (default: 1)" - option :follow, type: :boolean, default: true, desc: "Watch for new events (default: true, use --no-follow for one-shot)" - - def call(namespace: nil, since: nil, limit:, interval:, follow:, **) - started_at = since ? Time.parse(since) : Time.now - namespaces = namespace&.split(",")&.map(&:strip) - - if follow - hide_cursor - loop do - render(limit: limit.to_i, since: started_at, namespaces: namespaces, follow: true) - sleep interval.to_i - end - else - render(limit: limit.to_i, since: started_at, namespaces: namespaces, follow: false) - end - rescue Interrupt - show_cursor - exit 0 - rescue => e - show_cursor - warn e.message - exit 1 - end - - private - - def render(limit:, since:, namespaces:, follow:) - events = event_store.read.newer_than(since).map do |e| - { event_id: e.event_id, type: e.event_type, timestamp: e.timestamp } - end - events = events.select { |e| namespaces.include?(namespace(e[:type])) } if namespaces - grouped = events.group_by { |e| namespace(e[:type]) }.sort - - lines = [] - if grouped.empty? - lines << dim("No events yet — waiting since #{since.strftime("%H:%M:%S")}") - else - grouped.each do |ns, ns_events| - lines << bold("#{ns} (#{ns_events.size} events)") - ns_events.last(limit).each do |e| - lines << " #{pad(short_type(e[:type]), 30)} #{e[:timestamp].strftime("%H:%M:%S")} #{e[:event_id]}" - end - lines << "" - end - end - lines << dim("Watching since #{since.strftime("%H:%M:%S")} — Press Ctrl+C to exit") if follow - - clear_screen - puts lines.join("\n") - end - - def namespace(type) - type.include?("::") ? type.split("::").first : "Other" - end - - def short_type(type) - type.split("::").last - end - - def pad(str, width) - str.ljust(width)[0, width] - end - - def clear_screen - system("clear") - end - - def hide_cursor - print "\e[?25l" - end - - def show_cursor - print "\e[?25h" - end - - def bold(str) - "\e[1m#{str}\e[0m" - end - - def dim(str) - "\e[2m#{str}\e[0m" - end - end - end - end -end diff --git a/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb b/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb deleted file mode 100644 index b78e48f270..0000000000 --- a/contrib/ruby_event_store-cli/spec/commands/follow_spec.rb +++ /dev/null @@ -1,119 +0,0 @@ -# frozen_string_literal: true - -require_relative "../spec_helper" -require "ruby_event_store/cli/commands/follow" - -module RubyEventStore - module CLI - module Commands - class FollowOrdering < RubyEventStore::Event; end - class FollowOrderConfirmed < RubyEventStore::Event; end - class FollowPayment < RubyEventStore::Event; end - class FollowInventory < RubyEventStore::Event; end - - RSpec.describe Follow do - let(:event_store) { RubyEventStore::Client.new } - let(:command) { Follow.new } - let(:past) { (Time.now - 3600).iso8601(3) } - - before { stub_event_store(event_store) } - - def call_once(**opts) - begin - command.call(limit: 5, interval: 1, follow: false, since: past, **opts) - rescue SystemExit - end - end - - describe "#call with --no-follow" do - it "shows nothing when no events" do - expect { call_once }.to output(/No events yet/).to_stdout - end - - it "shows events grouped by namespace" do - event_store.publish(FollowOrdering.new, stream_name: "test") - - expect { call_once }.to output(/FollowOrdering/).to_stdout - end - - it "groups events under namespace header" do - event_store.publish(FollowOrdering.new, stream_name: "test") - event_store.publish(FollowOrderConfirmed.new, stream_name: "test") - - output = capture_stdout { call_once } - expect(output.lines.count { |l| l.include?("RubyEventStore") && l.include?("events") }).to eq(1) - end - - it "filters by namespace" do - event_store.publish(FollowOrdering.new, stream_name: "test") - event_store.publish(FollowPayment.new, stream_name: "test") - - output = capture_stdout { call_once(namespace: "RubyEventStore") } - expect(output).to include("RubyEventStore") - end - - it "excludes events outside requested namespace" do - event_store.publish(FollowOrdering.new, stream_name: "test") - event_store.publish(FollowPayment.new, stream_name: "test") - - output = capture_stdout { call_once(namespace: "Other") } - expect(output).to include("No events yet") - end - - it "shows last N events per namespace when limit exceeded" do - 10.times { event_store.publish(FollowOrdering.new, stream_name: "test") } - - output = capture_stdout { call_once(limit: 3) } - event_lines = output.lines.select { |l| l.start_with?(" ") } - expect(event_lines.size).to eq(3) - end - - it "places events without namespace under Other" do - expect(command.send(:namespace, "OrderPlaced")).to eq("Other") - end - - it "does not show follow footer" do - expect { call_once }.not_to output(/Press Ctrl\+C/).to_stdout - end - - it "does not show events older than --since" do - newer_since = (Time.now + 3600).iso8601(3) - event_store.publish(FollowOrdering.new, stream_name: "test") - - expect { call_once(since: newer_since) }.to output(/No events yet/).to_stdout - end - end - - describe "#call with --follow" do - it "shows follow footer" do - allow(command).to receive(:sleep) { raise StopIteration } - - expect { - begin - command.call(limit: 5, interval: 1, follow: true, since: past) - rescue SystemExit - end - }.to output(/Press Ctrl\+C/).to_stdout - end - - it "exits cleanly on Interrupt" do - allow(command).to receive(:sleep) { raise Interrupt } - - expect { - command.call(limit: 5, interval: 1, follow: true, since: past) - }.to raise_error(SystemExit) { |e| expect(e.status).to eq(0) } - end - end - - def capture_stdout - out = StringIO.new - $stdout = out - yield - out.string - ensure - $stdout = STDOUT - end - end - end - end -end From ab173a5af084c83e8ca0e12e5401e1e55d00963c Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 16:39:52 +0200 Subject: [PATCH 27/39] Extract watch loop into private method in Watch command Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands/watch.rb | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb index 3264e927ef..1beeeddca0 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb @@ -17,13 +17,7 @@ class Watch < Base def call(namespace: nil, since: nil, limit:, interval:, **) started_at = since ? Time.parse(since) : Time.now namespaces = namespace&.split(",")&.map(&:strip) - - hide_cursor - loop do - grouped = grouped_events(since: started_at, namespaces: namespaces) - render(grouped, limit: limit.to_i, since: started_at) - sleep interval.to_i - end + watch(since: started_at, namespaces: namespaces, limit: limit.to_i, interval: interval.to_i) rescue Interrupt show_cursor exit 0 @@ -35,6 +29,15 @@ def call(namespace: nil, since: nil, limit:, interval:, **) private + def watch(since:, namespaces:, limit:, interval:) + hide_cursor + loop do + events = grouped_events(since: since, namespaces: namespaces) + render(events, limit: limit, since: since) + sleep interval + end + end + def grouped_events(since:, namespaces:) events = events_since(since) events = filter_by_namespaces(events, namespaces) From 627605ec9c02b844980ed1f3eb4a0db5c5d1f9d0 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 16:42:15 +0200 Subject: [PATCH 28/39] Refactor Watch#render into named helper methods Co-Authored-By: Claude Sonnet 4.6 --- .../ruby_event_store/cli/commands/watch.rb | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb index 1beeeddca0..844178708b 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/watch.rb @@ -60,24 +60,34 @@ def group_by_namespace(events) end def render(grouped, limit:, since:) - lines = [] - if grouped.empty? - lines << dim("No events yet — waiting since #{since.strftime("%H:%M:%S")}") - else - grouped.each do |ns, ns_events| - lines << bold("#{ns} (#{ns_events.size} events)") - ns_events.last(limit).each do |e| - lines << " #{pad(short_type(e[:type]), 30)} #{e[:timestamp].strftime("%H:%M:%S")} #{e[:event_id]}" - end - lines << "" - end - end - lines << dim("Watching since #{since.strftime("%H:%M:%S")} — Press Ctrl+C to exit") - + lines = grouped.empty? ? [empty_message(since)] : event_lines(grouped, limit) + lines << footer(since) clear_screen puts lines.join("\n") end + def event_lines(grouped, limit) + grouped.flat_map do |ns, ns_events| + [ + bold("#{ns} (#{ns_events.size} events)"), + *ns_events.last(limit).map { |e| format_event(e) }, + "" + ] + end + end + + def format_event(e) + " #{pad(short_type(e[:type]), 30)} #{e[:timestamp].strftime("%H:%M:%S")} #{e[:event_id]}" + end + + def empty_message(since) + dim("No events yet — waiting since #{since.strftime("%H:%M:%S")}") + end + + def footer(since) + dim("Watching since #{since.strftime("%H:%M:%S")} — Press Ctrl+C to exit") + end + def namespace(type) type.include?("::") ? type.split("::").first : "Other" end From ceee4ee1186594eaef025f0b05b7729c30bf9542 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 17:11:17 +0200 Subject: [PATCH 29/39] Guard against missing config/environment.rb with a friendly error Co-Authored-By: Claude Sonnet 4.6 --- contrib/ruby_event_store-cli/bin/res | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contrib/ruby_event_store-cli/bin/res b/contrib/ruby_event_store-cli/bin/res index fd470b0ede..153ff01d12 100755 --- a/contrib/ruby_event_store-cli/bin/res +++ b/contrib/ruby_event_store-cli/bin/res @@ -5,7 +5,11 @@ require "dry/cli" require_relative "../lib/ruby_event_store/cli" require_relative "../lib/ruby_event_store/cli/commands" -require File.expand_path("config/environment") +env_file = File.expand_path("config/environment.rb") + +abort "Could not find config/environment.rb. Run `res` from the root of your Rails application." unless File.exist?(env_file) + +require env_file abort <<~MSG unless defined?(Rails) && Rails.configuration.respond_to?(:event_store) Could not find event store instance after loading config/environment.rb. From f36ca3dc021d469a27ba241aa4c7aed6a5d759f7 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 19:29:55 +0200 Subject: [PATCH 30/39] Add integration specs covering all CLI commands via Dry::CLI dispatch Co-Authored-By: Claude Sonnet 4.6 --- .../spec/integration_spec.rb | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 contrib/ruby_event_store-cli/spec/integration_spec.rb diff --git a/contrib/ruby_event_store-cli/spec/integration_spec.rb b/contrib/ruby_event_store-cli/spec/integration_spec.rb new file mode 100644 index 0000000000..69722394bc --- /dev/null +++ b/contrib/ruby_event_store-cli/spec/integration_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require "ruby_event_store/cli/commands" + +module RubyEventStore + module CLI + class OrderPlaced < RubyEventStore::Event; end + + RSpec.describe "CLI integration" do + let(:event_store) { RubyEventStore::Client.new } + before { stub_event_store(event_store) } + + def cli(argv) + stub_const("ARGV", argv) + Dry::CLI.new(Commands).call + end + + it "stream events" do + event_store.publish(OrderPlaced.new, stream_name: "orders") + expect { cli(["stream", "events", "orders"]) }.to output(/OrderPlaced/).to_stdout + end + + it "stream show" do + event_store.publish(OrderPlaced.new, stream_name: "orders") + expect { cli(["stream", "show", "orders"]) }.to output(/Events:\s+1/).to_stdout + end + + it "event show" do + event = OrderPlaced.new + event_store.publish(event, stream_name: "orders") + expect { cli(["event", "show", event.event_id]) }.to output(/OrderPlaced/).to_stdout + end + + it "event streams" do + event = OrderPlaced.new + event_store.publish(event, stream_name: "orders") + expect { cli(["event", "streams", event.event_id]) }.to output(/orders/).to_stdout + end + + it "trace" do + correlation_id = SecureRandom.uuid + event = OrderPlaced.new + event_store.with_metadata(correlation_id: correlation_id) { event_store.publish(event, stream_name: "orders") } + event_store.link(event.event_id, stream_name: "$by_correlation_id_#{correlation_id}") + expect { cli(["trace", correlation_id]) }.to output(/OrderPlaced/).to_stdout + end + + it "search" do + event_store.publish(OrderPlaced.new, stream_name: "orders") + expect { cli(["search"]) }.to output(/OrderPlaced/).to_stdout + end + + it "stats" do + event_store.publish(OrderPlaced.new, stream_name: "orders") + expect { cli(["stats"]) }.to output(/Events:\s+1/).to_stdout + end + + it "watch" do + allow_any_instance_of(Commands::Watch).to receive(:sleep) { raise Interrupt } + expect { cli(["watch"]) }.to raise_error(SystemExit) { |e| expect(e.status).to eq(0) } + end + end + end +end From 801c28b8e9bb4d4c560b7cd0aa9609bf03035e60 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 20:05:21 +0200 Subject: [PATCH 31/39] Refactor StreamEvents#call into build_reader and tail methods Co-Authored-By: Claude Sonnet 4.6 --- .../cli/commands/stream_events.rb | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index 078b20a965..bd3c875cf0 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -21,27 +21,9 @@ class StreamEvents < Base option :follow, type: :boolean, default: false, aliases: ["-f"], desc: "Watch for new events (Ctrl+C to stop)" def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from: nil, follow: false, **) - reader = event_store.read.stream(stream_name) - reader = reader.of_type(resolve_type(type)) if type - reader = reader.newer_than(Time.parse(after)) if after - reader = reader.older_than(Time.parse(before)) if before - reader = reader.from(from) if from - events = reader.limit(limit.to_i).to_a + events = build_reader(stream_name, type: type, after: after, before: before, from: from, limit: limit).to_a render(events, format: format) - - if follow - last_id = events.last&.event_id - loop do - sleep 1 - new_reader = event_store.read.stream(stream_name) - new_reader = new_reader.of_type(resolve_type(type)) if type - new_reader = new_reader.from(last_id) if last_id - new_events = new_reader.to_a - next if new_events.empty? - render(new_events, format: format) - last_id = new_events.last.event_id - end - end + tail(stream_name, last_id: events.last&.event_id, type: type, format: format) if follow rescue Interrupt exit 0 rescue => e @@ -51,6 +33,28 @@ def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from private + def build_reader(stream_name, type:, after:, before:, from:, limit:) + reader = event_store.read.stream(stream_name) + reader = reader.of_type(resolve_type(type)) if type + reader = reader.newer_than(Time.parse(after)) if after + reader = reader.older_than(Time.parse(before)) if before + reader = reader.from(from) if from + reader.limit(limit.to_i) + end + + def tail(stream_name, last_id:, type:, format:) + loop do + sleep 1 + reader = event_store.read.stream(stream_name) + reader = reader.of_type(resolve_type(type)) if type + reader = reader.from(last_id) if last_id + events = reader.to_a + next if events.empty? + render(events, format: format) + last_id = events.last.event_id + end + end + def resolve_type(name) Object.const_get(name) rescue NameError From d18b3eac669915bd55b2f3ff36a02559b46f002d Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 20:15:41 +0200 Subject: [PATCH 32/39] Extract print_event method in EventShow command Co-Authored-By: Claude Sonnet 4.6 --- .../ruby_event_store/cli/commands/event_show.rb | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb index a7bdfa1e66..65a646c346 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_show.rb @@ -14,19 +14,24 @@ class EventShow < Base def call(event_id:, **) event = event_store.read.event!(event_id) + print_event(event) + rescue RubyEventStore::EventNotFound + warn "Event not found: #{event_id}" + exit 1 + rescue => e + warn e.message + exit 1 + end + private + + def print_event(event) puts "Event ID: #{event.event_id}" puts "Type: #{event.event_type}" puts "Timestamp: #{event.timestamp.iso8601(3)}" puts "Valid at: #{event.valid_at.iso8601(3)}" puts "Data: #{JSON.pretty_generate(event.data)}" puts "Metadata: #{JSON.pretty_generate(event.metadata.to_h)}" - rescue RubyEventStore::EventNotFound - warn "Event not found: #{event_id}" - exit 1 - rescue => e - warn e.message - exit 1 end end end From f9b83055f3d0ad9bcb32a48dea5da582fd51c6d1 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 20:21:08 +0200 Subject: [PATCH 33/39] Extract streams_of private method in EventStreams command Co-Authored-By: Claude Sonnet 4.6 --- .../cli/commands/event_streams.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb index 0085015d63..94ae1c5d61 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/event_streams.rb @@ -12,18 +12,18 @@ class EventStreams < Base argument :event_id, required: true, desc: "Event ID (UUID)" def call(event_id:, **) - streams = event_store.streams_of(event_id) - - if streams.empty? - puts "(no streams — event not found or not linked to any stream)" - return - end - - streams.each { |stream| puts stream.name } + streams = streams_of(event_id) + streams.empty? ? puts("(no streams — event not found or not linked to any stream)") : streams.each { |stream| puts stream.name } rescue => e warn e.message exit 1 end + + private + + def streams_of(event_id) + event_store.streams_of(event_id) + end end end end From ae4e0e28b45c5f18356a5822438beffddd469d52 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 20:59:00 +0200 Subject: [PATCH 34/39] Extract ReadEvents class with self.of(specification, ...) for shared filtering Co-Authored-By: Claude Sonnet 4.6 --- .../ruby_event_store/cli/commands/search.rb | 16 ++--------- .../cli/commands/stream_events.rb | 28 +++++-------------- .../lib/ruby_event_store/cli/read_events.rb | 21 ++++++++++++++ 3 files changed, 31 insertions(+), 34 deletions(-) create mode 100644 contrib/ruby_event_store-cli/lib/ruby_event_store/cli/read_events.rb diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb index b864919423..a8fbb52f7c 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/search.rb @@ -3,6 +3,7 @@ require "dry/cli" require_relative "base" require_relative "../event_renderer" +require_relative "../read_events" module RubyEventStore module CLI @@ -20,24 +21,13 @@ class Search < Base option :format, default: "table", values: %w[table json], desc: "Output format" def call(limit:, format:, type: nil, after: nil, before: nil, stream: nil, **) - reader = stream ? event_store.read.stream(stream) : event_store.read - reader = reader.of_type(resolve_type(type)) if type - reader = reader.newer_than(Time.parse(after)) if after - reader = reader.older_than(Time.parse(before)) if before - events = reader.limit(limit.to_i).to_a + specification = stream ? event_store.read.stream(stream) : event_store.read + events = ReadEvents.of(specification, type: type, after: after, before: before, limit: limit) render(events, format: format) rescue => e warn e.message exit 1 end - - private - - def resolve_type(name) - Object.const_get(name) - rescue NameError - raise "Unknown event type: #{name}" - end end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index bd3c875cf0..7cca248783 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -3,6 +3,7 @@ require "dry/cli" require_relative "base" require_relative "../event_renderer" +require_relative "../read_events" module RubyEventStore module CLI @@ -21,7 +22,8 @@ class StreamEvents < Base option :follow, type: :boolean, default: false, aliases: ["-f"], desc: "Watch for new events (Ctrl+C to stop)" def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from: nil, follow: false, **) - events = build_reader(stream_name, type: type, after: after, before: before, from: from, limit: limit).to_a + specification = event_store.read.stream(stream_name) + events = ReadEvents.of(specification, type: type, after: after, before: before, from: from, limit: limit) render(events, format: format) tail(stream_name, last_id: events.last&.event_id, type: type, format: format) if follow rescue Interrupt @@ -33,34 +35,18 @@ def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from private - def build_reader(stream_name, type:, after:, before:, from:, limit:) - reader = event_store.read.stream(stream_name) - reader = reader.of_type(resolve_type(type)) if type - reader = reader.newer_than(Time.parse(after)) if after - reader = reader.older_than(Time.parse(before)) if before - reader = reader.from(from) if from - reader.limit(limit.to_i) - end - def tail(stream_name, last_id:, type:, format:) loop do sleep 1 - reader = event_store.read.stream(stream_name) - reader = reader.of_type(resolve_type(type)) if type - reader = reader.from(last_id) if last_id - events = reader.to_a + specification = event_store.read.stream(stream_name) + specification = specification.of_type(ReadEvents.resolve_type(type)) if type + specification = specification.from(last_id) if last_id + events = specification.to_a next if events.empty? render(events, format: format) last_id = events.last.event_id end end - - def resolve_type(name) - Object.const_get(name) - rescue NameError - raise "Unknown event type: #{name}" - end - end end end diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/read_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/read_events.rb new file mode 100644 index 0000000000..06cecdde32 --- /dev/null +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/read_events.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module RubyEventStore + module CLI + class ReadEvents + def self.of(specification, type: nil, after: nil, before: nil, from: nil, limit:) + specification = specification.of_type(resolve_type(type)) if type + specification = specification.newer_than(Time.parse(after)) if after + specification = specification.older_than(Time.parse(before)) if before + specification = specification.from(from) if from + specification.limit(limit.to_i).to_a + end + + def self.resolve_type(name) + Object.const_get(name) + rescue NameError + raise "Unknown event type: #{name}" + end + end + end +end From e7b9cf9a697c9db733bd01e9b7c74cb0d39ad4c4 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 21:34:53 +0200 Subject: [PATCH 35/39] Refactor Stats#call into print_stats and print_event_types methods Co-Authored-By: Claude Sonnet 4.6 --- .../ruby_event_store/cli/commands/stats.rb | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb index 5b655e7dec..c057342d04 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stats.rb @@ -12,20 +12,27 @@ class Stats < Base option :stream, desc: "Show stats for a specific stream" def call(stream: nil, **) - reader = stream ? event_store.read.stream(stream) : event_store.read - - puts "Stream: #{stream}" if stream - puts "Events: #{reader.count}" - - types = reader.map(&:event_type).uniq.sort - unless types.empty? - puts "\nEvent types:" - types.each { |t| puts " #{t}" } - end + specification = stream ? event_store.read.stream(stream) : event_store.read + print_stats(specification, stream: stream) rescue => e warn e.message exit 1 end + + private + + def print_stats(specification, stream:) + puts "Stream: #{stream}" if stream + puts "Events: #{specification.count}" + print_event_types(specification) + end + + def print_event_types(specification) + types = specification.map(&:event_type).uniq.sort + return if types.empty? + puts "\nEvent types:" + types.each { |t| puts " #{t}" } + end end end end From 8c74b7c6752a1cdea96cbb93740f549ac492b743 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 21:40:27 +0200 Subject: [PATCH 36/39] Use ReadEvents.of in tail, make limit optional Co-Authored-By: Claude Sonnet 4.6 --- .../lib/ruby_event_store/cli/commands/stream_events.rb | 5 +---- .../lib/ruby_event_store/cli/read_events.rb | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb index 7cca248783..b935e635d1 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_events.rb @@ -38,10 +38,7 @@ def call(stream_name:, limit:, format:, type: nil, after: nil, before: nil, from def tail(stream_name, last_id:, type:, format:) loop do sleep 1 - specification = event_store.read.stream(stream_name) - specification = specification.of_type(ReadEvents.resolve_type(type)) if type - specification = specification.from(last_id) if last_id - events = specification.to_a + events = ReadEvents.of(event_store.read.stream(stream_name), type: type, from: last_id) next if events.empty? render(events, format: format) last_id = events.last.event_id diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/read_events.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/read_events.rb index 06cecdde32..136f43fc06 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/read_events.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/read_events.rb @@ -3,12 +3,12 @@ module RubyEventStore module CLI class ReadEvents - def self.of(specification, type: nil, after: nil, before: nil, from: nil, limit:) + def self.of(specification, type: nil, after: nil, before: nil, from: nil, limit: nil) specification = specification.of_type(resolve_type(type)) if type specification = specification.newer_than(Time.parse(after)) if after specification = specification.older_than(Time.parse(before)) if before specification = specification.from(from) if from - specification.limit(limit.to_i).to_a + limit ? specification.limit(limit.to_i).to_a : specification.to_a end def self.resolve_type(name) From 9b19e6777afcf9dde06bcaf29b06d4f663adc4d6 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 21:41:50 +0200 Subject: [PATCH 37/39] Refactor StreamShow#call into print_stream method Co-Authored-By: Claude Sonnet 4.6 --- .../cli/commands/stream_show.rb | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb index 18414d32c7..a28b66ef1c 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/stream_show.rb @@ -12,26 +12,25 @@ class StreamShow < Base argument :stream_name, required: true, desc: "Stream name" def call(stream_name:, **) - reader = event_store.read.stream(stream_name) - count = reader.count - - if count.zero? - puts "Stream: #{stream_name}" - puts "Events: 0" - return - end + specification = event_store.read.stream(stream_name) + print_stream(stream_name, specification) + rescue => e + warn e.message + exit 1 + end - first = reader.first - last = reader.last + private + def print_stream(stream_name, specification) + count = specification.count puts "Stream: #{stream_name}" puts "Events: #{count}" + return if count.zero? + first = specification.first + last = specification.last puts "Version: #{count - 1}" puts "First: #{first.timestamp.iso8601(3)} (#{first.event_type})" puts "Last: #{last.timestamp.iso8601(3)} (#{last.event_type})" - rescue => e - warn e.message - exit 1 end end end From 7bd166e5c5e3f83f6f7e49146f5e9a742be88947 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 21:53:57 +0200 Subject: [PATCH 38/39] Refactor Trace#call into events_for, print_causation_tree, root_events methods Co-Authored-By: Claude Sonnet 4.6 --- .../ruby_event_store/cli/commands/trace.rb | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb index ea4d9b7b76..7ae83f7b11 100644 --- a/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb +++ b/contrib/ruby_event_store-cli/lib/ruby_event_store/cli/commands/trace.rb @@ -12,19 +12,12 @@ class Trace < Base argument :correlation_id, required: true, desc: "Correlation ID (UUID)" def call(correlation_id:, **) - stream_name = "$by_correlation_id_#{correlation_id}" - events = event_store.read.stream(stream_name).to_a - + events = events_for(correlation_id) if events.empty? puts "(no events found for correlation ID #{correlation_id})" return end - - by_causation = events.group_by { |e| e.metadata[:causation_id] } - event_ids = events.map(&:event_id).to_set - roots = events.select { |e| !event_ids.include?(e.metadata[:causation_id]) } - - roots.each { |e| print_tree(e, by_causation, "", true, roots.last == e) } + print_causation_tree(events) rescue => e warn e.message exit 1 @@ -32,6 +25,21 @@ def call(correlation_id:, **) private + def events_for(correlation_id) + event_store.read.stream("$by_correlation_id_#{correlation_id}").to_a + end + + def print_causation_tree(events) + causation = events.group_by { |e| e.metadata[:causation_id] } + roots = root_events(events) + roots.each { |e| print_tree(e, causation, "", true, roots.last == e) } + end + + def root_events(events) + event_ids = events.map(&:event_id).to_set + events.reject { |e| event_ids.include?(e.metadata[:causation_id]) } + end + def print_tree(event, by_causation, prefix, root, last) connector = root ? "" : (last ? "└── " : "├── ") puts "#{prefix}#{connector}#{event.event_type} [#{event.event_id}]" From 37fa1d111280164c9990c1489adb61fe8ddd5e53 Mon Sep 17 00:00:00 2001 From: Tomasz Patrzek Date: Thu, 23 Apr 2026 21:57:37 +0200 Subject: [PATCH 39/39] Rename test event classes to past tense convention Co-Authored-By: Claude Sonnet 4.6 --- .../spec/commands/search_spec.rb | 6 +++--- .../spec/commands/stream_events_spec.rb | 6 +++--- .../spec/commands/stream_show_spec.rb | 12 +++++------ .../spec/commands/watch_spec.rb | 20 +++++++++---------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/contrib/ruby_event_store-cli/spec/commands/search_spec.rb b/contrib/ruby_event_store-cli/spec/commands/search_spec.rb index dd05be65ff..67b6b0bbbe 100644 --- a/contrib/ruby_event_store-cli/spec/commands/search_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/search_spec.rb @@ -6,7 +6,7 @@ module RubyEventStore module CLI module Commands - class SearchTestEvent < RubyEventStore::Event; end + class OrderPlaced < RubyEventStore::Event; end RSpec.describe Search do let(:event_store) { RubyEventStore::Client.new } @@ -24,9 +24,9 @@ class SearchTestEvent < RubyEventStore::Event; end it "filters by event type" do event_store.publish(RubyEventStore::Event.new, stream_name: "orders") - event_store.publish(SearchTestEvent.new, stream_name: "orders") + event_store.publish(OrderPlaced.new, stream_name: "orders") - expect { command.call(limit: 50, format: "table", type: "RubyEventStore::CLI::Commands::SearchTestEvent") } + expect { command.call(limit: 50, format: "table", type: "RubyEventStore::CLI::Commands::OrderPlaced") } .to output(/1 event\(s\)/).to_stdout end diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb index 6d6dc84305..0fd22c2198 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stream_events_spec.rb @@ -6,7 +6,7 @@ module RubyEventStore module CLI module Commands - class OtherEvent < RubyEventStore::Event; end + class OrderShipped < RubyEventStore::Event; end RSpec.describe StreamEvents do let(:event_store) { RubyEventStore::Client.new } @@ -43,9 +43,9 @@ class OtherEvent < RubyEventStore::Event; end it "filters by event type" do event_store.publish(RubyEventStore::Event.new, stream_name: "test-stream") - event_store.publish(OtherEvent.new, stream_name: "test-stream") + event_store.publish(OrderShipped.new, stream_name: "test-stream") - expect { command.call(stream_name: "test-stream", limit: 50, format: "table", type: "RubyEventStore::CLI::Commands::OtherEvent") } + expect { command.call(stream_name: "test-stream", limit: 50, format: "table", type: "RubyEventStore::CLI::Commands::OrderShipped") } .to output(/1 event\(s\)/).to_stdout end diff --git a/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb b/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb index 2a16cd3510..1b14ee82dd 100644 --- a/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/stream_show_spec.rb @@ -6,8 +6,8 @@ module RubyEventStore module CLI module Commands - class FirstType < RubyEventStore::Event; end - class LastType < RubyEventStore::Event; end + class OrderCreated < RubyEventStore::Event; end + class OrderShipped < RubyEventStore::Event; end RSpec.describe StreamShow do let(:event_store) { RubyEventStore::Client.new } @@ -33,12 +33,12 @@ class LastType < RubyEventStore::Event; end end it "shows first and last event type" do - event_store.publish(FirstType.new, stream_name: "test-stream") - event_store.publish(LastType.new, stream_name: "test-stream") + event_store.publish(OrderCreated.new, stream_name: "test-stream") + event_store.publish(OrderShipped.new, stream_name: "test-stream") output = capture_stdout { command.call(stream_name: "test-stream") } - expect(output).to match(/First:.*FirstType/) - expect(output).to match(/Last:.*LastType/) + expect(output).to match(/First:.*OrderCreated/) + expect(output).to match(/Last:.*OrderShipped/) end it "shows zero events for empty stream" do diff --git a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb index 2ba61ce453..a217ba17e4 100644 --- a/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb +++ b/contrib/ruby_event_store-cli/spec/commands/watch_spec.rb @@ -6,9 +6,9 @@ module RubyEventStore module CLI module Commands - class WatchOrdering < RubyEventStore::Event; end - class WatchOrderConfirmed < RubyEventStore::Event; end - class WatchPayment < RubyEventStore::Event; end + class OrderReceived < RubyEventStore::Event; end + class OrderConfirmed < RubyEventStore::Event; end + class PaymentProcessed < RubyEventStore::Event; end RSpec.describe Watch do let(:event_store) { RubyEventStore::Client.new } @@ -28,8 +28,8 @@ def call_once(**opts) describe "grouped_events" do it "returns events grouped by namespace" do - event_store.publish(WatchOrdering.new, stream_name: "test") - event_store.publish(WatchPayment.new, stream_name: "test") + event_store.publish(OrderReceived.new, stream_name: "test") + event_store.publish(PaymentProcessed.new, stream_name: "test") grouped = command.send(:grouped_events, since: since, namespaces: nil) expect(grouped.map(&:first)).to eq(["RubyEventStore"]) @@ -37,8 +37,8 @@ def call_once(**opts) end it "filters by namespaces" do - event_store.publish(WatchOrdering.new, stream_name: "test") - event_store.publish(WatchPayment.new, stream_name: "test") + event_store.publish(OrderReceived.new, stream_name: "test") + event_store.publish(PaymentProcessed.new, stream_name: "test") grouped = command.send(:grouped_events, since: since, namespaces: ["Other"]) expect(grouped).to be_empty @@ -50,7 +50,7 @@ def call_once(**opts) end it "excludes events older than since" do - event_store.publish(WatchOrdering.new, stream_name: "test") + event_store.publish(OrderReceived.new, stream_name: "test") future = Time.now + 3600 grouped = command.send(:grouped_events, since: future, namespaces: nil) @@ -107,9 +107,9 @@ def call_once(**opts) end it "renders grouped events from event store" do - event_store.publish(WatchOrdering.new, stream_name: "test") + event_store.publish(OrderReceived.new, stream_name: "test") - expect { call_once }.to output(/WatchOrdering/).to_stdout + expect { call_once }.to output(/OrderReceived/).to_stdout end end