vector_mcp 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,11 +134,12 @@ module VectorMCP
134
134
 
135
135
  # Runs the server using the specified transport mechanism.
136
136
  #
137
- # @param transport [:stdio, :sse, VectorMCP::Transport::Base] The transport to use.
138
- # Can be a symbol (`:stdio`, `:sse`) or an initialized transport instance.
137
+ # @param transport [:stdio, :sse, :http_stream, VectorMCP::Transport::Base] The transport to use.
138
+ # Can be a symbol (`:stdio`, `:sse`, `:http_stream`) or an initialized transport instance.
139
139
  # If a symbol is provided, the method will instantiate the corresponding transport class.
140
- # If `:sse` is chosen, it uses Puma as the HTTP server.
141
- # @param options [Hash] Transport-specific options (e.g., `:host`, `:port` for SSE).
140
+ # If `:sse` is chosen, it uses Puma as the HTTP server (deprecated).
141
+ # If `:http_stream` is chosen, it uses the MCP-compliant streamable HTTP transport.
142
+ # @param options [Hash] Transport-specific options (e.g., `:host`, `:port` for HTTP transports).
142
143
  # These are passed to the transport's constructor if a symbol is provided for `transport`.
143
144
  # @return [void]
144
145
  # @raise [ArgumentError] if an unsupported transport symbol is given.
@@ -150,11 +151,20 @@ module VectorMCP
150
151
  when :sse
151
152
  begin
152
153
  require_relative "transport/sse"
154
+ logger.warn("SSE transport is deprecated. Please use :http_stream instead.")
153
155
  VectorMCP::Transport::SSE.new(self, **options)
154
156
  rescue LoadError => e
155
157
  logger.fatal("SSE transport requires additional dependencies.")
156
158
  raise NotImplementedError, "SSE transport dependencies not available: #{e.message}"
157
159
  end
160
+ when :http_stream
161
+ begin
162
+ require_relative "transport/http_stream"
163
+ VectorMCP::Transport::HttpStream.new(self, **options)
164
+ rescue LoadError => e
165
+ logger.fatal("HttpStream transport requires additional dependencies.")
166
+ raise NotImplementedError, "HttpStream transport dependencies not available: #{e.message}"
167
+ end
158
168
  when VectorMCP::Transport::Base # Allow passing an initialized transport instance
159
169
  transport.server = self if transport.respond_to?(:server=) && transport.server.nil? # Ensure server is set
160
170
  transport
@@ -277,14 +287,14 @@ module VectorMCP
277
287
  # server.use_middleware(LoggingMiddleware, :after_tool_call, conditions: { only_operations: ['important_tool'] })
278
288
  def use_middleware(middleware_class, hooks, priority: Middleware::Hook::DEFAULT_PRIORITY, conditions: {})
279
289
  @middleware_manager.register(middleware_class, hooks, priority: priority, conditions: conditions)
280
- @logger.info("Registered middleware: #{middleware_class.name}")
290
+ @logger.debug("Registered middleware: #{middleware_class.name}")
281
291
  end
282
292
 
283
293
  # Remove all middleware hooks for a specific class
284
294
  # @param middleware_class [Class] Middleware class to remove
285
295
  def remove_middleware(middleware_class)
286
296
  @middleware_manager.unregister(middleware_class)
287
- @logger.info("Removed middleware: #{middleware_class.name}")
297
+ @logger.debug("Removed middleware: #{middleware_class.name}")
288
298
  end
289
299
 
290
300
  # Get middleware statistics
@@ -296,7 +306,7 @@ module VectorMCP
296
306
  # Clear all middleware (useful for testing)
297
307
  def clear_middleware!
298
308
  @middleware_manager.clear!
299
- @logger.info("Cleared all middleware")
309
+ @logger.debug("Cleared all middleware")
300
310
  end
301
311
 
302
312
  private
@@ -3,6 +3,7 @@
3
3
  require_relative "sampling/request"
4
4
  require_relative "sampling/result"
5
5
  require_relative "errors"
6
+ require_relative "request_context"
6
7
 
7
8
  module VectorMCP
8
9
  # Represents the state of a single client-server connection session in MCP.
@@ -13,8 +14,9 @@ module VectorMCP
13
14
  # @attr_reader protocol_version [String] The MCP protocol version used by the server.
14
15
  # @attr_reader client_info [Hash, nil] Information about the client, received during initialization.
15
16
  # @attr_reader client_capabilities [Hash, nil] Capabilities supported by the client, received during initialization.
17
+ # @attr_reader request_context [RequestContext] The request context for this session.
16
18
  class Session
17
- attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id
19
+ attr_reader :server_info, :server_capabilities, :protocol_version, :client_info, :client_capabilities, :server, :transport, :id, :request_context
18
20
  attr_accessor :data # For user-defined session-specific storage
19
21
 
20
22
  # Initializes a new session.
@@ -22,7 +24,8 @@ module VectorMCP
22
24
  # @param server [VectorMCP::Server] The server instance managing this session.
23
25
  # @param transport [VectorMCP::Transport::Base, nil] The transport handling this session. Required for sampling.
24
26
  # @param id [String] A unique identifier for this session (e.g., from transport layer).
25
- def initialize(server, transport = nil, id: SecureRandom.uuid)
27
+ # @param request_context [RequestContext, Hash, nil] The request context for this session.
28
+ def initialize(server, transport = nil, id: SecureRandom.uuid, request_context: nil)
26
29
  @server = server
27
30
  @transport = transport # Store the transport for sending requests
28
31
  @id = id
@@ -31,6 +34,16 @@ module VectorMCP
31
34
  @client_capabilities = nil
32
35
  @data = {} # Initialize user data hash
33
36
  @logger = server.logger
37
+
38
+ # Initialize request context
39
+ @request_context = case request_context
40
+ when RequestContext
41
+ request_context
42
+ when Hash
43
+ RequestContext.new(**request_context)
44
+ else
45
+ RequestContext.new
46
+ end
34
47
  end
35
48
 
36
49
  # Marks the session as initialized using parameters from the client's `initialize` request.
@@ -75,6 +88,75 @@ module VectorMCP
75
88
  @initialized_state == :succeeded
76
89
  end
77
90
 
91
+ # Sets the request context for this session.
92
+ # This method should be called by transport layers to populate request-specific data.
93
+ #
94
+ # @param context [RequestContext, Hash] The request context to set.
95
+ # Can be a RequestContext object or a hash of attributes.
96
+ # @return [RequestContext] The newly set request context.
97
+ # @raise [ArgumentError] If the context is not a RequestContext or Hash.
98
+ def request_context=(context)
99
+ @request_context = case context
100
+ when RequestContext
101
+ context
102
+ when Hash
103
+ RequestContext.new(**context)
104
+ else
105
+ raise ArgumentError, "Request context must be a RequestContext or Hash, got #{context.class}"
106
+ end
107
+ end
108
+
109
+ # Updates the request context with new data.
110
+ # This merges the provided attributes with the existing context.
111
+ #
112
+ # @param attributes [Hash] The attributes to merge into the request context.
113
+ # @return [RequestContext] The updated request context.
114
+ def update_request_context(**attributes)
115
+ current_attrs = @request_context.to_h
116
+
117
+ # Deep merge nested hashes like headers and params
118
+ merged_attrs = current_attrs.dup
119
+ attributes.each do |key, value|
120
+ merged_attrs[key] = if value.is_a?(Hash) && current_attrs[key].is_a?(Hash)
121
+ current_attrs[key].merge(value)
122
+ else
123
+ value
124
+ end
125
+ end
126
+
127
+ @request_context = RequestContext.new(**merged_attrs)
128
+ end
129
+
130
+ # Convenience method to check if the session has request headers.
131
+ #
132
+ # @return [Boolean] True if the request context has headers, false otherwise.
133
+ def request_headers?
134
+ @request_context.headers?
135
+ end
136
+
137
+ # Convenience method to check if the session has request parameters.
138
+ #
139
+ # @return [Boolean] True if the request context has parameters, false otherwise.
140
+ def request_params?
141
+ @request_context.params?
142
+ end
143
+
144
+ # Convenience method to get a request header value.
145
+ #
146
+ # @param name [String] The header name.
147
+ # @return [String, nil] The header value or nil if not found.
148
+ def request_header(name)
149
+ @request_context.header(name)
150
+ end
151
+
152
+ # Convenience method to get a request parameter value.
153
+ #
154
+ # @param name [String] The parameter name.
155
+ # @return [String, nil] The parameter value or nil if not found.
156
+ def request_param(name)
157
+ @request_context.param(name)
158
+ end
159
+
78
160
  # Helper to check client capabilities later if needed
79
161
  # def supports?(capability_key)
80
162
  # @client_capabilities.key?(capability_key.to_s)
@@ -111,7 +193,7 @@ module VectorMCP
111
193
 
112
194
  begin
113
195
  sampling_req_obj = VectorMCP::Sampling::Request.new(request_params)
114
- @logger.info("[Session #{@id}] Sending sampling/createMessage request to client.")
196
+ @logger.debug("[Session #{@id}] Sending sampling/createMessage request to client.")
115
197
 
116
198
  result = send_sampling_request(sampling_req_obj, timeout)
117
199
 
@@ -161,17 +243,25 @@ module VectorMCP
161
243
  send_request_kwargs = {}
162
244
  send_request_kwargs[:timeout] = timeout if timeout
163
245
 
164
- raw_result = @transport.send_request(*send_request_args, **send_request_kwargs)
246
+ # For HTTP transport, we need to use send_request_to_session to target this specific session
247
+ raw_result = if @transport.respond_to?(:send_request_to_session)
248
+ @transport.send_request_to_session(@id, *send_request_args, **send_request_kwargs)
249
+ else
250
+ # Fallback to generic send_request for other transports
251
+ @transport.send_request(*send_request_args, **send_request_kwargs)
252
+ end
253
+
165
254
  VectorMCP::Sampling::Result.new(raw_result)
166
255
  rescue ArgumentError => e
167
256
  @logger.error("[Session #{@id}] Invalid parameters for sampling request or result: #{e.message}")
168
- raise VectorMCP::SamplingError, "Invalid sampling parameters or malformed client response: #{e.message}", details: { original_error: e.to_s }
257
+ raise VectorMCP::SamplingError.new("Invalid sampling parameters or malformed client response: #{e.message}",
258
+ details: { original_error: e.to_s })
169
259
  rescue VectorMCP::SamplingError => e
170
260
  @logger.warn("[Session #{@id}] Sampling request failed: #{e.message}")
171
261
  raise e
172
262
  rescue StandardError => e
173
263
  @logger.error("[Session #{@id}] Unexpected error during sampling: #{e.class.name}: #{e.message}")
174
- raise VectorMCP::SamplingError, "An unexpected error occurred during sampling: #{e.message}", details: { original_error: e.to_s }
264
+ raise VectorMCP::SamplingError.new("An unexpected error occurred during sampling: #{e.message}", details: { original_error: e.to_s })
175
265
  end
176
266
  end
177
267
  end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "concurrent-ruby"
5
+
6
+ module VectorMCP
7
+ module Transport
8
+ # Base session manager providing unified session lifecycle management across all transports.
9
+ # This abstract base class defines the standard interface that all transport session managers
10
+ # should implement, ensuring consistent session handling regardless of transport type.
11
+ #
12
+ # @abstract Subclass and implement transport-specific methods
13
+ class BaseSessionManager
14
+ # Session data structure for unified session management
15
+ Session = Struct.new(:id, :context, :created_at, :last_accessed_at, :metadata) do
16
+ def touch!
17
+ self.last_accessed_at = Time.now
18
+ end
19
+
20
+ def expired?(timeout)
21
+ Time.now - last_accessed_at > timeout
22
+ end
23
+
24
+ def age
25
+ Time.now - created_at
26
+ end
27
+ end
28
+
29
+ attr_reader :transport, :session_timeout, :logger
30
+
31
+ # Initializes a new session manager.
32
+ #
33
+ # @param transport [Object] The parent transport instance
34
+ # @param session_timeout [Integer] Session timeout in seconds (default: 300)
35
+ def initialize(transport, session_timeout = 300)
36
+ @transport = transport
37
+ @session_timeout = session_timeout
38
+ @logger = transport.logger
39
+ @sessions = Concurrent::Hash.new
40
+ @cleanup_timer = nil
41
+
42
+ start_cleanup_timer if auto_cleanup_enabled?
43
+ logger.debug { "#{self.class.name} initialized with session_timeout: #{session_timeout}" }
44
+ end
45
+
46
+ # Gets an existing session by ID.
47
+ #
48
+ # @param session_id [String] The session ID
49
+ # @return [Session, nil] The session if found and valid
50
+ def get_session(session_id)
51
+ return nil unless session_id
52
+
53
+ session = @sessions[session_id]
54
+ return nil unless session && !session.expired?(@session_timeout)
55
+
56
+ session.touch!
57
+ session
58
+ end
59
+
60
+ # Gets an existing session or creates a new one.
61
+ #
62
+ # @param session_id [String, nil] The session ID (optional)
63
+ # @return [Session] The existing or newly created session
64
+ def get_or_create_session(session_id = nil)
65
+ if session_id
66
+ session = get_session(session_id)
67
+ return session if session
68
+
69
+ # If session_id was provided but not found, create with that ID
70
+ return create_session(session_id)
71
+ end
72
+
73
+ create_session
74
+ end
75
+
76
+ # Creates a new session.
77
+ #
78
+ # @param session_id [String, nil] Optional specific session ID to use
79
+ # @return [Session] The newly created session
80
+ def create_session(session_id = nil)
81
+ session_id ||= generate_session_id
82
+ now = Time.now
83
+
84
+ # Create VectorMCP session context
85
+ session_context = VectorMCP::Session.new(@transport.server, @transport, id: session_id)
86
+
87
+ # Create internal session record with transport-specific metadata
88
+ session = Session.new(
89
+ session_id,
90
+ session_context,
91
+ now,
92
+ now,
93
+ create_session_metadata
94
+ )
95
+
96
+ @sessions[session_id] = session
97
+
98
+ logger.info { "Session created: #{session_id}" }
99
+ on_session_created(session)
100
+ session
101
+ end
102
+
103
+ # Terminates a session by ID.
104
+ #
105
+ # @param session_id [String] The session ID to terminate
106
+ # @return [Boolean] True if session was found and terminated
107
+ def session_terminated?(session_id)
108
+ session = @sessions.delete(session_id)
109
+ return false unless session
110
+
111
+ on_session_terminated(session)
112
+ logger.info { "Session terminated: #{session_id}" }
113
+ true
114
+ end
115
+
116
+ # Gets the current number of active sessions.
117
+ #
118
+ # @return [Integer] Number of active sessions
119
+ def session_count
120
+ @sessions.size
121
+ end
122
+
123
+ # Gets all active session IDs.
124
+ #
125
+ # @return [Array<String>] Array of session IDs
126
+ def active_session_ids
127
+ @sessions.keys
128
+ end
129
+
130
+ # Checks if any sessions exist.
131
+ #
132
+ # @return [Boolean] True if at least one session exists
133
+ def sessions?
134
+ !@sessions.empty?
135
+ end
136
+
137
+ # Cleans up all sessions and stops the cleanup timer.
138
+ #
139
+ # @return [void]
140
+ def cleanup_all_sessions
141
+ logger.info { "Cleaning up all sessions: #{@sessions.size}" }
142
+
143
+ @sessions.each_value do |session|
144
+ on_session_terminated(session)
145
+ end
146
+
147
+ @sessions.clear
148
+ stop_cleanup_timer
149
+ end
150
+
151
+ # Updates session metadata.
152
+ #
153
+ # @param session_id [String] The session ID
154
+ # @param metadata [Hash] Metadata to merge
155
+ # @return [Boolean] True if session was found and updated
156
+ def session_metadata_updated?(session_id, metadata)
157
+ session = @sessions[session_id]
158
+ return false unless session
159
+
160
+ session.metadata.merge!(metadata)
161
+ session.touch!
162
+ true
163
+ end
164
+
165
+ # Gets session metadata.
166
+ #
167
+ # @param session_id [String] The session ID
168
+ # @return [Hash, nil] Session metadata or nil if session not found
169
+ def get_session_metadata(session_id)
170
+ session = @sessions[session_id]
171
+ session&.metadata
172
+ end
173
+
174
+ # Finds sessions matching criteria.
175
+ #
176
+ # @param criteria [Hash] Search criteria
177
+ # @option criteria [Symbol] :created_after Time to search after
178
+ # @option criteria [Symbol] :metadata Hash of metadata to match
179
+ # @return [Array<Session>] Matching sessions
180
+ def find_sessions(criteria = {})
181
+ @sessions.values.select do |session|
182
+ matches_criteria?(session, criteria)
183
+ end
184
+ end
185
+
186
+ # Broadcasts a message to all sessions that support messaging.
187
+ #
188
+ # @param message [Hash] The message to broadcast
189
+ # @return [Integer] Number of sessions the message was sent to
190
+ def broadcast_message(message)
191
+ count = 0
192
+ @sessions.each_value do |session|
193
+ next unless can_send_message_to_session?(session)
194
+
195
+ count += 1 if message_sent_to_session?(session, message)
196
+ end
197
+
198
+ # Message broadcasted to recipients
199
+ count
200
+ end
201
+
202
+ protected
203
+
204
+ # Hook called when a session is created. Override in subclasses for transport-specific logic.
205
+ #
206
+ # @param session [Session] The newly created session
207
+ # @return [void]
208
+ def on_session_created(session)
209
+ # Override in subclasses
210
+ end
211
+
212
+ # Hook called when a session is terminated. Override in subclasses for transport-specific cleanup.
213
+ #
214
+ # @param session [Session] The session being terminated
215
+ # @return [void]
216
+ def on_session_terminated(session)
217
+ # Override in subclasses
218
+ end
219
+
220
+ # Creates transport-specific session metadata. Override in subclasses.
221
+ #
222
+ # @return [Hash] Initial metadata for the session
223
+ def create_session_metadata
224
+ {}
225
+ end
226
+
227
+ # Determines if this session manager should enable automatic cleanup.
228
+ # Override in subclasses that don't need automatic cleanup (e.g., stdio with single session).
229
+ #
230
+ # @return [Boolean] True if auto-cleanup should be enabled
231
+ def auto_cleanup_enabled?
232
+ true
233
+ end
234
+
235
+ # Checks if a message can be sent to the given session.
236
+ # Override in subclasses based on transport capabilities.
237
+ #
238
+ # @param session [Session] The session to check
239
+ # @return [Boolean] True if messaging is supported for this session
240
+ def can_send_message_to_session?(_session)
241
+ false # Override in subclasses
242
+ end
243
+
244
+ # Sends a message to a specific session.
245
+ # Override in subclasses based on transport messaging mechanism.
246
+ #
247
+ # @param session [Session] The target session
248
+ # @param message [Hash] The message to send
249
+ # @return [Boolean] True if message was sent successfully
250
+ def message_sent_to_session?(_session, _message)
251
+ false # Override in subclasses
252
+ end
253
+
254
+ private
255
+
256
+ # Generates a cryptographically secure session ID.
257
+ #
258
+ # @return [String] A unique session ID
259
+ def generate_session_id
260
+ SecureRandom.uuid
261
+ end
262
+
263
+ # Starts the automatic cleanup timer if auto-cleanup is enabled.
264
+ #
265
+ # @return [void]
266
+ def start_cleanup_timer
267
+ return unless auto_cleanup_enabled?
268
+
269
+ # Run cleanup every 60 seconds
270
+ @cleanup_timer = Concurrent::TimerTask.new(execution_interval: 60) do
271
+ cleanup_expired_sessions
272
+ end
273
+ @cleanup_timer.execute
274
+ end
275
+
276
+ # Stops the automatic cleanup timer.
277
+ #
278
+ # @return [void]
279
+ def stop_cleanup_timer
280
+ @cleanup_timer&.shutdown
281
+ @cleanup_timer = nil
282
+ end
283
+
284
+ # Cleans up expired sessions.
285
+ #
286
+ # @return [void]
287
+ def cleanup_expired_sessions
288
+ expired_sessions = []
289
+
290
+ @sessions.each do |session_id, session|
291
+ expired_sessions << session_id if session.expired?(@session_timeout)
292
+ end
293
+
294
+ expired_sessions.each do |session_id|
295
+ session = @sessions.delete(session_id)
296
+ on_session_terminated(session) if session
297
+ end
298
+
299
+ return unless expired_sessions.any?
300
+
301
+ logger.debug { "Cleaned up expired sessions: #{expired_sessions.size}" }
302
+ end
303
+
304
+ # Checks if a session matches the given criteria.
305
+ #
306
+ # @param session [Session] The session to check
307
+ # @param criteria [Hash] The search criteria
308
+ # @return [Boolean] True if session matches all criteria
309
+ def matches_criteria?(session, criteria)
310
+ return false if criteria[:created_after] && session.created_at <= criteria[:created_after]
311
+
312
+ criteria[:metadata]&.each do |key, value|
313
+ return false unless session.metadata[key] == value
314
+ end
315
+
316
+ true
317
+ end
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+ require "securerandom"
5
+
6
+ module VectorMCP
7
+ module Transport
8
+ class HttpStream
9
+ # Manages Server-Sent Events storage for resumable connections.
10
+ #
11
+ # Handles:
12
+ # - Event storage with unique IDs
13
+ # - Event replay from a specific Last-Event-ID
14
+ # - Circular buffer for memory efficiency
15
+ # - Thread-safe operations
16
+ #
17
+ # @api private
18
+ class EventStore
19
+ # Event data structure
20
+ Event = Struct.new(:id, :data, :type, :timestamp) do
21
+ def to_sse_format
22
+ lines = []
23
+ lines << "id: #{id}"
24
+ lines << "event: #{type}" if type
25
+ lines << "data: #{data}"
26
+ lines << ""
27
+ lines.join("\n")
28
+ end
29
+ end
30
+
31
+ attr_reader :max_events, :logger
32
+
33
+ # Initializes a new event store.
34
+ #
35
+ # @param max_events [Integer] Maximum number of events to retain
36
+ def initialize(max_events)
37
+ @max_events = max_events
38
+ @events = Concurrent::Array.new
39
+ @event_index = Concurrent::Hash.new # event_id -> index for fast lookup
40
+ @current_sequence = Concurrent::AtomicFixnum.new(0)
41
+ end
42
+
43
+ # Stores a new event and returns its ID.
44
+ #
45
+ # @param data [String] The event data
46
+ # @param type [String] The event type (optional)
47
+ # @return [String] The generated event ID
48
+ def store_event(data, type = nil)
49
+ event_id = generate_event_id
50
+ timestamp = Time.now
51
+
52
+ event = Event.new(event_id, data, type, timestamp)
53
+
54
+ # Add to events array
55
+ @events.push(event)
56
+
57
+ # Update index
58
+ @event_index[event_id] = @events.length - 1
59
+
60
+ # Maintain circular buffer
61
+ if @events.length > @max_events
62
+ removed_event = @events.shift
63
+ @event_index.delete(removed_event.id)
64
+
65
+ # Update all indices after removal
66
+ @event_index.transform_values! { |index| index - 1 }
67
+ end
68
+
69
+ event_id
70
+ end
71
+
72
+ # Retrieves events starting from a specific event ID.
73
+ #
74
+ # @param last_event_id [String] The last event ID received by client
75
+ # @return [Array<Event>] Array of events after the specified ID
76
+ def get_events_after(last_event_id)
77
+ return @events.to_a if last_event_id.nil?
78
+
79
+ last_index = @event_index[last_event_id]
80
+ return [] if last_index.nil?
81
+
82
+ # Return events after the last_event_id
83
+ start_index = last_index + 1
84
+ return [] if start_index >= @events.length
85
+
86
+ @events[start_index..]
87
+ end
88
+
89
+ # Gets the total number of stored events.
90
+ #
91
+ # @return [Integer] Number of events currently stored
92
+ def event_count
93
+ @events.length
94
+ end
95
+
96
+ # Gets the oldest event ID (for debugging/monitoring).
97
+ #
98
+ # @return [String, nil] The oldest event ID or nil if no events
99
+ def oldest_event_id
100
+ @events.first&.id
101
+ end
102
+
103
+ # Gets the newest event ID (for debugging/monitoring).
104
+ #
105
+ # @return [String, nil] The newest event ID or nil if no events
106
+ def newest_event_id
107
+ @events.last&.id
108
+ end
109
+
110
+ # Checks if an event ID exists in the store.
111
+ #
112
+ # @param event_id [String] The event ID to check
113
+ # @return [Boolean] True if event exists
114
+ def event_exists?(event_id)
115
+ @event_index.key?(event_id)
116
+ end
117
+
118
+ # Clears all stored events.
119
+ #
120
+ # @return [void]
121
+ def clear
122
+ @events.clear
123
+ @event_index.clear
124
+ end
125
+
126
+ # Gets statistics about the event store.
127
+ #
128
+ # @return [Hash] Statistics hash
129
+ def stats
130
+ {
131
+ total_events: event_count,
132
+ max_events: @max_events,
133
+ oldest_event_id: oldest_event_id,
134
+ newest_event_id: newest_event_id,
135
+ memory_usage_ratio: event_count.to_f / @max_events
136
+ }
137
+ end
138
+
139
+ private
140
+
141
+ # Generates a unique event ID.
142
+ #
143
+ # @return [String] A unique event ID
144
+ def generate_event_id
145
+ sequence = @current_sequence.increment
146
+ "#{Time.now.to_i}-#{sequence}-#{SecureRandom.hex(4)}"
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end