vector_mcp 0.3.1 → 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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/lib/vector_mcp/definitions.rb +25 -9
  4. data/lib/vector_mcp/errors.rb +2 -3
  5. data/lib/vector_mcp/handlers/core.rb +206 -50
  6. data/lib/vector_mcp/logger.rb +148 -0
  7. data/lib/vector_mcp/middleware/base.rb +171 -0
  8. data/lib/vector_mcp/middleware/context.rb +76 -0
  9. data/lib/vector_mcp/middleware/hook.rb +169 -0
  10. data/lib/vector_mcp/middleware/manager.rb +179 -0
  11. data/lib/vector_mcp/middleware.rb +43 -0
  12. data/lib/vector_mcp/request_context.rb +182 -0
  13. data/lib/vector_mcp/sampling/result.rb +11 -1
  14. data/lib/vector_mcp/security/middleware.rb +2 -28
  15. data/lib/vector_mcp/security/strategies/api_key.rb +2 -24
  16. data/lib/vector_mcp/security/strategies/jwt_token.rb +6 -3
  17. data/lib/vector_mcp/server/capabilities.rb +5 -7
  18. data/lib/vector_mcp/server/message_handling.rb +11 -5
  19. data/lib/vector_mcp/server.rb +74 -20
  20. data/lib/vector_mcp/session.rb +131 -8
  21. data/lib/vector_mcp/transport/base_session_manager.rb +320 -0
  22. data/lib/vector_mcp/transport/http_stream/event_store.rb +151 -0
  23. data/lib/vector_mcp/transport/http_stream/session_manager.rb +189 -0
  24. data/lib/vector_mcp/transport/http_stream/stream_handler.rb +269 -0
  25. data/lib/vector_mcp/transport/http_stream.rb +779 -0
  26. data/lib/vector_mcp/transport/sse.rb +74 -19
  27. data/lib/vector_mcp/transport/sse_session_manager.rb +188 -0
  28. data/lib/vector_mcp/transport/stdio.rb +70 -13
  29. data/lib/vector_mcp/transport/stdio_session_manager.rb +181 -0
  30. data/lib/vector_mcp/util.rb +39 -1
  31. data/lib/vector_mcp/version.rb +1 -1
  32. data/lib/vector_mcp.rb +10 -35
  33. metadata +25 -24
  34. data/lib/vector_mcp/logging/component.rb +0 -131
  35. data/lib/vector_mcp/logging/configuration.rb +0 -156
  36. data/lib/vector_mcp/logging/constants.rb +0 -21
  37. data/lib/vector_mcp/logging/core.rb +0 -175
  38. data/lib/vector_mcp/logging/filters/component.rb +0 -69
  39. data/lib/vector_mcp/logging/filters/level.rb +0 -23
  40. data/lib/vector_mcp/logging/formatters/base.rb +0 -52
  41. data/lib/vector_mcp/logging/formatters/json.rb +0 -83
  42. data/lib/vector_mcp/logging/formatters/text.rb +0 -72
  43. data/lib/vector_mcp/logging/outputs/base.rb +0 -64
  44. data/lib/vector_mcp/logging/outputs/console.rb +0 -35
  45. data/lib/vector_mcp/logging/outputs/file.rb +0 -157
  46. data/lib/vector_mcp/logging.rb +0 -71
@@ -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)
@@ -95,10 +177,43 @@ module VectorMCP
95
177
  def sample(request_params, timeout: nil)
96
178
  validate_sampling_preconditions
97
179
 
98
- sampling_req_obj = VectorMCP::Sampling::Request.new(request_params)
99
- @logger.info("[Session #{@id}] Sending sampling/createMessage request to client.")
180
+ # Create middleware context for sampling
181
+ context = VectorMCP::Middleware::Context.new(
182
+ operation_type: :sampling,
183
+ operation_name: "createMessage",
184
+ params: request_params,
185
+ session: self,
186
+ server: @server,
187
+ metadata: { start_time: Time.now, timeout: timeout }
188
+ )
189
+
190
+ # Execute before_sampling_request hooks
191
+ context = @server.middleware_manager.execute_hooks(:before_sampling_request, context)
192
+ raise context.error if context.error?
193
+
194
+ begin
195
+ sampling_req_obj = VectorMCP::Sampling::Request.new(request_params)
196
+ @logger.debug("[Session #{@id}] Sending sampling/createMessage request to client.")
100
197
 
101
- send_sampling_request(sampling_req_obj, timeout)
198
+ result = send_sampling_request(sampling_req_obj, timeout)
199
+
200
+ # Set result in context
201
+ context.result = result
202
+
203
+ # Execute after_sampling_response hooks
204
+ context = @server.middleware_manager.execute_hooks(:after_sampling_response, context)
205
+
206
+ context.result
207
+ rescue StandardError => e
208
+ # Set error in context and execute error hooks
209
+ context.error = e
210
+ context = @server.middleware_manager.execute_hooks(:on_sampling_error, context)
211
+
212
+ # Re-raise unless middleware handled the error
213
+ raise e unless context.result
214
+
215
+ context.result
216
+ end
102
217
  end
103
218
 
104
219
  private
@@ -128,17 +243,25 @@ module VectorMCP
128
243
  send_request_kwargs = {}
129
244
  send_request_kwargs[:timeout] = timeout if timeout
130
245
 
131
- 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
+
132
254
  VectorMCP::Sampling::Result.new(raw_result)
133
255
  rescue ArgumentError => e
134
256
  @logger.error("[Session #{@id}] Invalid parameters for sampling request or result: #{e.message}")
135
- 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 })
136
259
  rescue VectorMCP::SamplingError => e
137
260
  @logger.warn("[Session #{@id}] Sampling request failed: #{e.message}")
138
261
  raise e
139
262
  rescue StandardError => e
140
263
  @logger.error("[Session #{@id}] Unexpected error during sampling: #{e.class.name}: #{e.message}")
141
- 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 })
142
265
  end
143
266
  end
144
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